Skip to content

Commit 944754d

Browse files
committed
Support embed url card
1 parent 5ff8795 commit 944754d

24 files changed

Lines changed: 403 additions & 102 deletions

actions/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
node_modules
1616
.env*
1717

18+
test/.tmp

actions/lib/posts.js

Lines changed: 167 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import AtpAgent, { RichText } from "@atproto/api";
1+
import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api";
22
import assert from 'node:assert';
3+
import * as cheerio from 'cheerio';
34

45
export const REPLY_IN_THREAD = Symbol('Reply in thread');
56

@@ -69,43 +70,179 @@ export async function getPostURLFromURI(agent, uri) {
6970
}
7071

7172
/**
73+
* TODO(joyeecheung): support 'imageFiles' field in JSON files.
7274
* @param {AtpAgent} agent
73-
* @param {object} request
75+
* @param {ArrayBuffer} imgData
76+
* @returns {BlobRef}
7477
*/
75-
export async function post(agent, request) {
76-
// TODO(joyeecheung): support images and embeds.
77-
// TODO(joyeecheung): When Bluesky supports markdown or snippets, we should ideally
78-
// read a relative path in the request containing those contents instead of reading from
79-
// strings in a JSON.
80-
const rt = new RichText({ text: request.richText });
81-
82-
await rt.detectFacets(agent); // automatically detects mentions and links
83-
84-
const record = {
85-
$type: 'app.bsky.feed.post',
86-
text: rt.text,
87-
facets: rt.facets,
88-
createdAt: new Date().toISOString(),
78+
async function uploadImage(agent, imgData) {
79+
const res = await agent.uploadBlob(imgData, {
80+
encoding: 'image/jpeg'
81+
});
82+
return res.data.blob;
83+
}
84+
85+
// https://docs.bsky.app/docs/advanced-guides/posts#website-card-embeds
86+
async function fetchEmbedUrlCard(url) {
87+
console.log('Fetching embed card from', url);
88+
89+
// The required fields for every embed card
90+
const card = {
91+
uri: url,
92+
title: '',
93+
description: '',
8994
};
9095

91-
// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
92-
if (request.repostURL) {
93-
if (!request.repostInfo) {
94-
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
96+
try {
97+
// Fetch the HTML
98+
const resp = await fetch(url);
99+
if (!resp.ok) {
100+
throw new Error(`Failed to fetch URL: ${resp.status} ${resp.statusText}`);
95101
}
96-
record.embed = {
97-
$type: 'app.bsky.embed.record',
98-
record: request.repostInfo
99-
};
100-
} else if (request.replyURL) {
101-
if (!request.replyInfo) {
102-
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
102+
const html = await resp.text();
103+
const $ = cheerio.load(html);
104+
105+
// Parse out the "og:title" and "og:description" HTML meta tags
106+
const titleTag = $('meta[property="og:title"]').attr('content');
107+
if (titleTag) {
108+
card.title = titleTag;
109+
}
110+
111+
const descriptionTag = $('meta[property="og:description"]').attr('content');
112+
if (descriptionTag) {
113+
card.description = descriptionTag;
114+
}
115+
116+
// If there is an "og:image" HTML meta tag, fetch and upload that image
117+
const imageTag = $('meta[property="og:image"]').attr('content');
118+
if (imageTag) {
119+
let imgURL = imageTag;
120+
121+
// Naively turn a "relative" URL (just a path) into a full URL, if needed
122+
if (!imgURL.includes('://')) {
123+
imgURL = new URL(imgURL, url).href;
124+
}
125+
card.thumb = { $TO_BE_UPLOADED: imgURL };
103126
}
104-
record.reply = {
105-
root: request.rootInfo || request.replyInfo,
106-
parent: request.replyInfo,
127+
128+
return {
129+
$type: 'app.bsky.embed.external',
130+
external: card,
107131
};
132+
} catch (error) {
133+
console.error('Error generating embed URL card:', error.message);
134+
throw error;
108135
}
136+
}
137+
138+
/**
139+
* @typedef ReplyRequest
140+
* @property {string} richText
141+
* @property {string} replyURL
142+
* @property {{cid: string, uri: string}?} replyInfo
143+
*/
144+
145+
/**
146+
* @typedef PostRequest
147+
* @property {string} richText
148+
*/
149+
150+
/**
151+
* @typedef QuotePostRequest
152+
* @property {string} richText
153+
* @property {string} repostURL
154+
* @property {{cid: string, uri: string}?} repostInfo
155+
*/
109156

157+
/**
158+
* It should be possible to invoked this method on the same request at least twice -
159+
* once to populate the facets and the embed without uploading any files if shouldUploadImage
160+
* is false, and then again uploading files if shouldUploadImage is true.
161+
* @param {AtpAgent} agent
162+
* @param {ReplyRequest|PostRequest|QuotePostRequest} request
163+
* @param {boolean} shouldUploadImage
164+
* @returns {AppBskyFeedPost.Record}
165+
*/
166+
export async function populateRecord(agent, request, shouldUploadImage = false) {
167+
console.log(`Generating record, shouldUploadImage = ${shouldUploadImage}, request = `, request);
168+
169+
if (request.repostURL && !request.repostInfo) {
170+
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
171+
}
172+
if (request.replyURL && request.replyURL !== REPLY_IN_THREAD && !request.replyInfo) {
173+
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
174+
}
175+
176+
if (request.richText && !request.record) {
177+
// TODO(joyeecheung): When Bluesky supports markdown or snippets, we should render the text
178+
// as markdown.
179+
const rt = new RichText({ text: request.richText });
180+
181+
await rt.detectFacets(agent); // automatically detects mentions and links
182+
183+
const record = {
184+
$type: 'app.bsky.feed.post',
185+
text: rt.text,
186+
facets: rt.facets,
187+
createdAt: new Date().toISOString(),
188+
};
189+
190+
// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
191+
if (request.repostInfo) {
192+
record.embed = {
193+
$type: 'app.bsky.embed.record',
194+
record: request.repostInfo
195+
};
196+
} else if (request.replyInfo) {
197+
record.reply = {
198+
root: request.rootInfo || request.replyInfo,
199+
parent: request.replyInfo,
200+
};
201+
}
202+
203+
// If there is already another embed, don't generate the card embed.
204+
if (!record.embed) {
205+
// Find the first URL, match until the first whitespace or punctuation.
206+
const urlMatch = request.richText.match(/https?:\/\/[^\s\]\[\"\'\<\>]+/);
207+
if (urlMatch !== null) {
208+
const url = urlMatch[0];
209+
const card = await fetchEmbedUrlCard(url);
210+
record.embed = card;
211+
}
212+
}
213+
request.record = record;
214+
}
215+
216+
if (shouldUploadImage && request.record?.embed?.external?.thumb?.$TO_BE_UPLOADED) {
217+
const card = request.record.embed.external;
218+
const imgURL = card.thumb.$TO_BE_UPLOADED;
219+
try {
220+
console.log('Fetching image', imgURL);
221+
const imgResp = await fetch(imgURL);
222+
if (!imgResp.ok) {
223+
throw new Error(`Failed to fetch image ${imgURL}: ${imgResp.status} ${imgResp.statusText}`);
224+
}
225+
const imgData = await imgResp.arrayBuffer();
226+
console.log('Uploading image', imgURL, 'size = ', imgData.byteLength);
227+
card.thumb = await uploadImage(agent, imgData);
228+
} catch (e) {
229+
// If image upload fails, post the embed card without the image, at worst we see a
230+
// link card without an image which is not a big deal.
231+
console.log(`Failed to fetch or upload image ${imgURL}`, e);
232+
}
233+
}
234+
235+
console.log('Generated record');
236+
console.dir(request.record, { depth: 3 });
237+
238+
return request;
239+
}
240+
241+
/**
242+
* @param {AtpAgent} agent
243+
* @param {object} request
244+
*/
245+
export async function post(agent, request) {
246+
const { record } = await populateRecord(agent, request, true);
110247
return agent.post(record);
111248
}

actions/lib/validator.js

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -50,45 +50,3 @@ export function validateRequest(request) {
5050
assert.fail('Unknown action ' + request.action);
5151
}
5252
}
53-
54-
/**
55-
* @param {import('@atproto/api').AtpAgent} agent
56-
* @param {object} request
57-
* @param {string} fieldName
58-
*/
59-
async function validatePostURLInRequest(agent, request, fieldName) {
60-
if (request.replyURL === REPLY_IN_THREAD) return request.replyInfo;
61-
let result;
62-
try {
63-
result = await getPostInfoFromUrl(agent, request[fieldName]);
64-
} finally {
65-
if (!result) {
66-
console.error(`Invalid "${fieldName}" field, ${request[fieldName]}`);
67-
}
68-
}
69-
return result;
70-
}
71-
72-
/**
73-
* Validate the post URLs in the request and extend them into { uri, cid } pairs
74-
* if necessary.
75-
* @param {import('@atproto/api').AtpAgent} agent
76-
* @param {object} request
77-
*/
78-
export async function validateAndExtendRequestReferences(agent, request) {
79-
switch(request.action) {
80-
case 'repost':
81-
case 'quote-post': {
82-
const info = await validatePostURLInRequest(agent, request, 'repostURL');
83-
request.repostInfo = info;
84-
break;
85-
}
86-
case 'reply': {
87-
const info = await validatePostURLInRequest(agent, request, 'replyURL');
88-
request.replyInfo = info;
89-
break;
90-
}
91-
default:
92-
break;
93-
}
94-
}

actions/login-and-validate.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import fs from 'node:fs';
44
import process from 'node:process';
55
import path from 'node:path';
66
import { login } from './lib/login.js';
7-
import { validateAccount, validateRequest, validateAndExtendRequestReferences } from './lib/validator.js';
8-
import { REPLY_IN_THREAD } from './lib/posts.js';
7+
import { validateAccount, validateRequest } from './lib/validator.js';
8+
import { populateRecord, REPLY_IN_THREAD } from './lib/posts.js';
99

1010
// The JSON file must contains the following fields:
1111
// - "account": a string field indicating the account to use to perform the action.
@@ -40,6 +40,6 @@ requests.forEach(validateRequest);
4040
const agent = await login(account);
4141

4242
// Validate and extend the post URLs in the request into { cid, uri } records.
43-
await Promise.all(requests.map(request => validateAndExtendRequestReferences(agent, request)));
43+
await Promise.all(requests.map(request => populateRecord(agent, request, false)));
4444

4545
export { agent, requests, requestFilePath, richTextFile };

actions/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"repository": "https://github.com/nodejs/bluesky-playground",
88
"packageManager": "[email protected]",
99
"dependencies": {
10-
"@atproto/api": "^0.13.18"
10+
"@atproto/api": "^0.13.18",
11+
"cheerio": "^1.0.0"
1112
}
1213
}

actions/process.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ for (const request of requests) {
2828
};
2929
case 'repost': {
3030
console.log('Reposting...', request.repostURL);
31-
assert(request.repostInfo); // Extended by validateAndExtendRequestReferences.
31+
assert(request.repostInfo); // Extended by populateRecord.
3232
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
3333
break;
3434
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"action": "post",
3-
"account": "PIXEL",
4-
"richText": "Hello from automation!"
3+
"account": "PRIMARY",
4+
"richText": "Hello from automation https://github.com/nodejs/bluesky"
55
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"action": "quote-post",
3-
"account": "PIXEL",
4-
"richText": "Quote post from automation",
3+
"account": "PRIMARY",
4+
"richText": "Quote post from automation https://github.com/nodejs/bluesky",
55
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
66
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"action": "reply",
3-
"account": "PIXEL",
4-
"richText": "Reply from automation",
3+
"account": "PRIMARY",
4+
"richText": "Reply from automation https://github.com/nodejs/bluesky",
55
"replyURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
66
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"action": "repost",
3-
"account": "PIXEL",
3+
"account": "PRIMARY",
44
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
55
}

0 commit comments

Comments
 (0)