Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support bluesky post embed cards for links #131

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/helpers/bluesky/__tests__/fetch-link-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
69 changes: 69 additions & 0 deletions src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { exec } from "node:child_process";

Check warning on line 1 in src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts

View workflow job for this annotation

GitHub Actions / Eslint Validation

'exec' is defined but never used. Allowed unused vars must match /^_/u

import { BskyAgent } from "@atproto/api";

import { fetchLinkMetadata } from "../fetch-link-metadata.js";

Check warning on line 5 in src/helpers/bluesky/__tests__/get-bluesky-link-metadata.spec.ts

View workflow job for this annotation

GitHub Actions / Eslint Validation

'fetchLinkMetadata' is defined but never used. Allowed unused vars must match /^_/u
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();
});
});
11 changes: 11 additions & 0 deletions src/helpers/bluesky/__tests__/mocks/metadata.ts
Original file line number Diff line number Diff line change
@@ -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",
};
25 changes: 25 additions & 0 deletions src/helpers/bluesky/fetch-link-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<LinkMetadata> | null} - A promise that resolves with the fetched metadata or null if an error occurred.
*/
export const fetchLinkMetadata = (
url: string,
): Promise<LinkMetadata | null> | 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;
});
};
28 changes: 28 additions & 0 deletions src/helpers/bluesky/get-bluesky-chunk-link-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<BlueskyLinkMetadata | null>} 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<BlueskyLinkMetadata | null> => {
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;
};
39 changes: 39 additions & 0 deletions src/helpers/bluesky/get-bluesky-link-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<BlueskyLinkMetadata | null>} - A promise that resolves to the Bluesky Link metadata or null if not found.
*/
export const getBlueskyLinkMetadata = async (
url: string,
client: bsky.BskyAgent,
): Promise<BlueskyLinkMetadata | null> => {
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;
};
1 change: 1 addition & 0 deletions src/helpers/bluesky/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./build-reply-entry.js";
export * from "./get-bluesky-chunk-link-metadata.js";
89 changes: 62 additions & 27 deletions src/services/bluesky-sender.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
14 changes: 14 additions & 0 deletions src/types/link-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<LinkMetadata, "image"> & {
image: bsky.ComAtprotoRepoUploadBlob.Response;
};