diff --git a/src/utils/atproto-adapter.js b/src/utils/atproto-adapter.js index baf1b5c9..0bb8f698 100644 --- a/src/utils/atproto-adapter.js +++ b/src/utils/atproto-adapter.js @@ -3,6 +3,10 @@ import { getPdsEndpoint } from '@atproto/common-web'; import { encodeAtprotoID } from './atproto-route'; import { createAtprotoOAuthAgent } from './atproto-oauth'; +import { + createAtprotoExternalEmbed, + getFirstPostURL, +} from './atproto-unfurl'; import { BSKY_PDS, resolveAtprotoLoginService, @@ -1801,6 +1805,17 @@ export function createAtprotoClient({ } } } + if (!record.embed) { + const externalEmbed = await createAtprotoExternalEmbed( + agent, + params.card_url || + params.cardUrl || + params.external_url || + params.externalUrl || + getFirstPostURL(rt.text), + ); + if (externalEmbed) record.embed = externalEmbed; + } const res = await agent.post(record); const id = encodeAtprotoID(res.uri); for (let i = 0; i < 10; i++) { diff --git a/src/utils/atproto-unfurl.js b/src/utils/atproto-unfurl.js new file mode 100644 index 00000000..3596922d --- /dev/null +++ b/src/utils/atproto-unfurl.js @@ -0,0 +1,63 @@ +export const BSKY_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='; + +const HTTP_URL_RE = /https?:\/\/[^\s<>"']+/i; + +export function getFirstPostURL(text = '') { + const match = HTTP_URL_RE.exec(text); + return match?.[0]?.replace(/[),.;!?]+$/, '') || null; +} + +function getAssociatedRecord(value) { + if (!value || typeof value !== 'object') return undefined; + return value; +} + +export async function createAtprotoExternalEmbed( + agent, + uri, + { fetcher = fetch } = {}, +) { + if (!uri) return null; + let metadata; + try { + const res = await fetcher(`${BSKY_LINK_META_PROXY}${encodeURIComponent(uri)}`); + if (!res.ok) return null; + metadata = await res.json(); + } catch (e) { + console.error('Failed to fetch Bluesky link metadata', e); + return null; + } + if (metadata?.error) return null; + + const external = { + uri: metadata.url || uri, + title: metadata.title || '', + description: metadata.description || '', + }; + const associatedRecord = getAssociatedRecord( + metadata.associatedRecord || metadata.associated_record, + ); + if (associatedRecord) external.associatedRecord = associatedRecord; + + if (metadata.image) { + try { + const imageRes = await fetcher(metadata.image); + if (imageRes.ok) { + const imageBlob = await imageRes.blob(); + if (imageBlob.size > 0) { + const uploadRes = await agent.uploadBlob(imageBlob, { + encoding: imageBlob.type || 'image/jpeg', + }); + if (uploadRes.data?.blob) external.thumb = uploadRes.data.blob; + } + } + } catch (e) { + console.error('Failed to upload Bluesky link thumbnail', e); + } + } + + return { + $type: 'app.bsky.embed.external', + external, + }; +} diff --git a/tests/atproto-unfurl.spec.js b/tests/atproto-unfurl.spec.js new file mode 100644 index 00000000..ca509f31 --- /dev/null +++ b/tests/atproto-unfurl.spec.js @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; + +import { + createAtprotoExternalEmbed, + getFirstPostURL, +} from '../src/utils/atproto-unfurl.js'; + +test.describe('ATProto compose helpers', () => { + test('builds external embeds from cardyb metadata', async () => { + const uploads = []; + const agent = { + uploadBlob: async (blob, options) => { + uploads.push({ size: blob.size, encoding: options.encoding }); + return { + data: { + blob: { + $type: 'blob', + ref: { $link: 'bafkreicardthumb' }, + mimeType: options.encoding, + size: blob.size, + }, + }, + }; + }, + }; + const fetcher = async (url) => { + const urlString = String(url); + if (urlString.startsWith('https://cardyb.bsky.app/v1/extract?url=')) { + return Response.json({ + error: '', + url: 'https://docs.bsky.app/', + title: 'Bluesky Documentation', + description: 'Explore guides and tutorials.', + image: 'https://image.test/card.png', + associated_record: { + uri: 'at://did:plc:test/app.bsky.feed.post/abc', + cid: 'bafyreitest', + }, + }); + } + if (urlString === 'https://image.test/card.png') { + return new Response(new Blob(['png'], { type: 'image/png' })); + } + return new Response('', { status: 404 }); + }; + + const embed = await createAtprotoExternalEmbed( + agent, + 'https://docs.bsky.app', + { fetcher }, + ); + + expect(getFirstPostURL('read https://docs.bsky.app please')).toBe( + 'https://docs.bsky.app', + ); + expect(getFirstPostURL('read https://docs.bsky.app.')).toBe( + 'https://docs.bsky.app', + ); + expect(uploads).toEqual([{ size: 3, encoding: 'image/png' }]); + expect(embed).toEqual({ + $type: 'app.bsky.embed.external', + external: { + uri: 'https://docs.bsky.app/', + title: 'Bluesky Documentation', + description: 'Explore guides and tutorials.', + associatedRecord: { + uri: 'at://did:plc:test/app.bsky.feed.post/abc', + cid: 'bafyreitest', + }, + thumb: { + $type: 'blob', + ref: { $link: 'bafkreicardthumb' }, + mimeType: 'image/png', + size: 3, + }, + }, + }); + }); +});