diff --git a/src/helpers/bluesky/__tests__/fetch-link-metadata.spec.ts b/src/helpers/bluesky/__tests__/fetch-link-metadata.spec.ts new file mode 100644 index 0000000..186fe2a --- /dev/null +++ b/src/helpers/bluesky/__tests__/fetch-link-metadata.spec.ts @@ -0,0 +1,27 @@ +import { fetchLinkMetadata } from "../fetch-link-metadata.js"; +import { METADATA_MOCK } from "./mocks/metadata.js"; + +jest.mock("../../../constants.js", () => { + return { + TWITTER_HANDLE: "username", + MASTODON_INSTANCE: "mastodon.social", + MASTODON_MAX_POST_LENGTH: 500, + BLUESKY_MAX_POST_LENGTH: 300, + }; +}); + +describe("fetchLinkMetadata", () => { + it("should return the metadata if data is found", async () => { + const result = await fetchLinkMetadata( + "https://github.com/louisgrasset/touitomamout", + ); + expect(JSON.stringify(result)).toStrictEqual(JSON.stringify(METADATA_MOCK)); + }); + + it("should return null if no data is found", async () => { + const result = await fetchLinkMetadata( + "https://thisturldoesnotexist.example.com", + ); + expect(result).toBeNull(); + }); +}); diff --git a/src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts b/src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts new file mode 100644 index 0000000..fac6062 --- /dev/null +++ b/src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts @@ -0,0 +1,69 @@ +import { exec } from "node:child_process"; + +import { BskyAgent } from "@atproto/api"; + +import { fetchLinkMetadata } from "../fetch-link-metadata.js"; +import { getBlueskyLinkMetadata } from "../get-bluesky-link-metadata.js"; +import { METADATA_MOCK } from "./mocks/metadata.js"; + +jest.mock("../../../services/index.js", () => ({ + mediaDownloaderService: jest.fn(() => ({ + blobData: "blobData", + mimeType: "mimeType", + })), +})); + +jest.mock("../../medias/parse-blob-for-bluesky.js", () => ({ + parseBlobForBluesky: jest.fn(() => ({ + blobData: "blobData", + mimeType: "mimeType", + })), +})); + +jest.mock("../../../constants.js", () => { + return { + TWITTER_HANDLE: "username", + MASTODON_INSTANCE: "mastodon.social", + MASTODON_MAX_POST_LENGTH: 500, + BLUESKY_MAX_POST_LENGTH: 300, + }; +}); + +const uploadBlobMock = jest.fn(() => ({ + success: true, + data: { + blob: { + original: "123456789", + }, + }, +})); + +describe("getBlueskyLinkMetadata", () => { + it("should return the metadata if data is found", async () => { + const result = await getBlueskyLinkMetadata( + "https://github.com/louisgrasset/touitomamout", + { uploadBlob: uploadBlobMock } as unknown as BskyAgent, + ); + expect(result).toStrictEqual({ + ...METADATA_MOCK, + image: { + success: true, + data: { + blob: { + original: "123456789", + }, + }, + }, + }); + }); + + it("should return the metadata if data is found", async () => { + const result = await getBlueskyLinkMetadata( + "https://thisturldoesnotexist.example.com", + { + uploadBlob: uploadBlobMock, + } as unknown as BskyAgent, + ); + expect(result).toBeNull(); + }); +}); diff --git a/src/helpers/bluesky/__tests__/mocks/metadata.ts b/src/helpers/bluesky/__tests__/mocks/metadata.ts new file mode 100644 index 0000000..6c34a69 --- /dev/null +++ b/src/helpers/bluesky/__tests__/mocks/metadata.ts @@ -0,0 +1,11 @@ +export const METADATA_MOCK = { + error: "", + likely_type: "html", + url: "https://github.com/louisgrasset/touitomamout", + title: + "GitHub - louisgrasset/touitomamout: Touitomamout is an easy way to synchronize your Twitter's tweets...", + description: + "Touitomamout is an easy way to synchronize your Twitter\u0026#39;s tweets 🦤 to Mastodon 🦣 and Bluesky post ☁️ (also known as Twitter to Mastodon \u0026amp; Bluesky crossposter) - GitHub - louisgrasset...", + image: + "https://cardyb.bsky.app/v1/image?url=https%3A%2F%2Fopengraph.githubassets.com%2Fc62e7ca13ff991d5b45a3218958a7bfefc990ee3386bd75fee3c6159e91c8859%2Flouisgrasset%2Ftouitomamout", +}; diff --git a/src/helpers/bluesky/fetch-link-metadata.ts b/src/helpers/bluesky/fetch-link-metadata.ts new file mode 100644 index 0000000..f7df20c --- /dev/null +++ b/src/helpers/bluesky/fetch-link-metadata.ts @@ -0,0 +1,25 @@ +import { LinkMetadata } from "../../types/link-metadata.js"; + +/** + * Fetches metadata for a given URL. + * @param {string} url - The URL for which to fetch metadata. + * @returns {Promise | null} - A promise that resolves with the fetched metadata or null if an error occurred. + */ +export const fetchLinkMetadata = ( + url: string, +): Promise | null => { + return fetch(`https://cardyb.bsky.app/v1/extract?url=${encodeURI(url)}`, { + method: "GET", + }) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + return null; + } + return data as LinkMetadata; + }) + .catch((e) => { + console.error(`Error while fetching link metadata: ${e}`); + return null; + }); +}; diff --git a/src/helpers/bluesky/get-bluesky-chunk-link-metadata.ts b/src/helpers/bluesky/get-bluesky-chunk-link-metadata.ts new file mode 100644 index 0000000..63d0409 --- /dev/null +++ b/src/helpers/bluesky/get-bluesky-chunk-link-metadata.ts @@ -0,0 +1,28 @@ +import bsky from "@atproto/api"; + +import { BlueskyLinkMetadata } from "../../types/link-metadata.js"; +import { getBlueskyLinkMetadata } from "./get-bluesky-link-metadata.js"; + +/** + * Retrieves the metadata of the first link found in the given richtext. + * + * @param {bsky.RichText} richText - The richtext to search for links. + * @param {bsky.BskyAgent} client - The BskyAgent client for making API calls. + * @returns {Promise} A promise that resolves to the metadata of the first link found, or null if no link is found. + */ +export const getBlueskyChunkLinkMetadata = async ( + richText: bsky.RichText, + client: bsky.BskyAgent, +): Promise => { + let card = null; + for (const seg of richText.segments()) { + if (seg.isLink()) { + const link = seg.link?.uri; + if (link) { + card = await getBlueskyLinkMetadata(link, client); + break; + } + } + } + return card; +}; diff --git a/src/helpers/bluesky/get-bluesky-link-metadata.ts b/src/helpers/bluesky/get-bluesky-link-metadata.ts new file mode 100644 index 0000000..640f604 --- /dev/null +++ b/src/helpers/bluesky/get-bluesky-link-metadata.ts @@ -0,0 +1,39 @@ +import bsky from "@atproto/api"; + +import { mediaDownloaderService } from "../../services/index.js"; +import { BlueskyLinkMetadata } from "../../types/link-metadata.js"; +import { parseBlobForBluesky } from "../medias/parse-blob-for-bluesky.js"; +import { fetchLinkMetadata } from "./fetch-link-metadata.js"; + +/** + * Retrieves Bluesky Link metadata asynchronously. + * + * @param {string} url - The URL of the link for which metadata is to be retrieved. + * @param {bsky.BskyAgent} client - The bsky.BskyAgent client used for uploading the media. + * @returns {Promise} - A promise that resolves to the Bluesky Link metadata or null if not found. + */ +export const getBlueskyLinkMetadata = async ( + url: string, + client: bsky.BskyAgent, +): Promise => { + const data = await fetchLinkMetadata(url); + if (!data) { + return null; + } + + const mediaBlob = await mediaDownloaderService(data.image); + + const blueskyBlob = await parseBlobForBluesky(mediaBlob); + + const media = await client.uploadBlob(blueskyBlob.blobData, { + encoding: blueskyBlob.mimeType, + }); + + if (blueskyBlob) { + return { + ...data, + image: media, + }; + } + return null; +}; diff --git a/src/helpers/bluesky/index.ts b/src/helpers/bluesky/index.ts index 5a907b0..5552a33 100644 --- a/src/helpers/bluesky/index.ts +++ b/src/helpers/bluesky/index.ts @@ -1 +1,2 @@ export * from "./build-reply-entry.js"; +export * from "./get-bluesky-chunk-link-metadata.js"; diff --git a/src/services/bluesky-sender.service.ts b/src/services/bluesky-sender.service.ts index ff49882..a064c3b 100644 --- a/src/services/bluesky-sender.service.ts +++ b/src/services/bluesky-sender.service.ts @@ -2,6 +2,7 @@ import bsky, { BskyAgent } from "@atproto/api"; import { Ora } from "ora"; import { DEBUG, VOID } from "../constants.js"; +import { getBlueskyChunkLinkMetadata } from "../helpers/bluesky/index.js"; import { getCachedPosts } from "../helpers/cache/get-cached-posts.js"; import { savePostToCache } from "../helpers/cache/save-post-to-cache.js"; import { oraProgress } from "../helpers/logs/index.js"; @@ -118,38 +119,59 @@ export const blueskySenderService = async ( createdAt: new Date(post.tweet.timestamp || Date.now()).toISOString(), }; - // Inject embed data only for the first chunk. - if (chunkIndex === 0) { - const quoteRecord = post.quotePost - ? { - record: { - $type: "app.bsky.embed.record", - cid: post.quotePost.cid, - uri: post.quotePost.uri, - }, - } - : {}; - - const mediaRecord = mediaAttachments.length - ? { - media: { - $type: "app.bsky.embed.images", - images: mediaAttachments.map((i) => ({ - alt: i.alt_text ?? "", - image: i.data.blob.original, - })), - }, - } - : {}; + /** + * First, compute the embed data. + * It can be: quote, media, quote + media, or link card. + */ + const quoteRecord = post.quotePost + ? { + record: { + $type: "app.bsky.embed.record", + cid: post.quotePost.cid, + uri: post.quotePost.uri, + }, + } + : {}; + + const mediaRecord = mediaAttachments.length + ? { + media: { + $type: "app.bsky.embed.images", + images: mediaAttachments.map((i) => ({ + alt: i.alt_text ?? "", + image: i.data.blob.original, + })), + }, + } + : {}; + + const card = await getBlueskyChunkLinkMetadata(richText, client); + const externalRecord = card + ? { + external: { + uri: card.url, + title: card.title, + description: card.description, + thumb: card.image.data.blob.original, + $type: "app.bsky.embed.external", + }, + } + : {}; - let embed = {}; + /** + * Then, build the embed object. + */ + let embed = {}; + // Inject media and/or quote data only for the first chunk. + if (chunkIndex === 0) { + // Handle quote if (Object.keys(quoteRecord).length) { embed = { ...quoteRecord, $type: "app.bsky.embed.record", }; - + // ...with media(s) if (Object.keys(mediaRecord).length) { embed = { ...embed, @@ -158,16 +180,29 @@ export const blueskySenderService = async ( }; } } else if (Object.keys(mediaRecord).length) { + // Handle media(s) only embed = { ...mediaRecord.media, }; } + } - if (Object.keys(embed).length) { - data.embed = embed; + // Handle link card if no quote nor media + if (!Object.keys(quoteRecord).length && !Object.keys(mediaRecord).length) { + if (Object.keys(externalRecord).length) { + embed = { + ...embed, + ...externalRecord, + $type: "app.bsky.embed.external", + }; } } + // Inject embed data. + if (Object.keys(embed).length) { + data.embed = embed; + } + if (chunkIndex === 0) { if (post.replyPost) { data.reply = { diff --git a/src/types/link-metadata.ts b/src/types/link-metadata.ts new file mode 100644 index 0000000..58d13d1 --- /dev/null +++ b/src/types/link-metadata.ts @@ -0,0 +1,14 @@ +import bsky from "@atproto/api"; + +export type LinkMetadata = { + error: string; + likely_type: string; + url: string; + title: string; + description: string; + image: string; +}; + +export type BlueskyLinkMetadata = Omit & { + image: bsky.ComAtprotoRepoUploadBlob.Response; +};