Skip to content

Commit 4b0c899

Browse files
committed
Support embed url card
1 parent 97295c7 commit 4b0c899

24 files changed

Lines changed: 404 additions & 84 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
// URL format:
56
// 1. https://bsky.app/profile/${handle}/post/${postId}
@@ -67,43 +68,179 @@ export async function getPostURLFromURI(agent, uri) {
6768
}
6869

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

89-
// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
90-
if (request.repostURL) {
91-
if (!request.repostInfo) {
92-
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
94+
try {
95+
// Fetch the HTML
96+
const resp = await fetch(url);
97+
if (!resp.ok) {
98+
throw new Error(`Failed to fetch URL: ${resp.status} ${resp.statusText}`);
9399
}
94-
record.embed = {
95-
$type: 'app.bsky.embed.record',
96-
record: request.repostInfo
97-
};
98-
} else if (request.replyURL) {
99-
if (!request.replyInfo) {
100-
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
100+
const html = await resp.text();
101+
const $ = cheerio.load(html);
102+
103+
// Parse out the "og:title" and "og:description" HTML meta tags
104+
const titleTag = $('meta[property="og:title"]').attr('content');
105+
if (titleTag) {
106+
card.title = titleTag;
107+
}
108+
109+
const descriptionTag = $('meta[property="og:description"]').attr('content');
110+
if (descriptionTag) {
111+
card.description = descriptionTag;
112+
}
113+
114+
// If there is an "og:image" HTML meta tag, fetch and upload that image
115+
const imageTag = $('meta[property="og:image"]').attr('content');
116+
if (imageTag) {
117+
let imgURL = imageTag;
118+
119+
// Naively turn a "relative" URL (just a path) into a full URL, if needed
120+
if (!imgURL.includes('://')) {
121+
imgURL = new URL(imgURL, url).href;
122+
}
123+
card.thumb = { $TO_BE_UPLOADED: imgURL };
101124
}
102-
record.reply = {
103-
root: request.replyInfo,
104-
parent: request.replyInfo,
125+
126+
return {
127+
$type: 'app.bsky.embed.external',
128+
external: card,
129+
};
130+
} catch (error) {
131+
console.error('Error generating embed URL card:', error.message);
132+
throw error;
133+
}
134+
}
135+
136+
/**
137+
* @typedef ReplyRequest
138+
* @property {string} richText
139+
* @property {string} replyURL
140+
* @property {{cid: string, uri: string}?} replyInfo
141+
*/
142+
143+
/**
144+
* @typedef PostRequest
145+
* @property {string} richText
146+
*/
147+
148+
/**
149+
* @typedef QuotePostRequest
150+
* @property {string} richText
151+
* @property {string} repostURL
152+
* @property {{cid: string, uri: string}?} repostInfo
153+
*/
154+
155+
/**
156+
* It should be possible to invoked this method on the same request at least twice -
157+
* once to populate the facets and the embed without uploading any files if shouldUploadImage
158+
* is false, and then again uploading files if shouldUploadImage is true.
159+
* @param {AtpAgent} agent
160+
* @param {ReplyRequest|PostRequest|QuotePostRequest} request
161+
* @param {boolean} shouldUploadImage
162+
* @returns {AppBskyFeedPost.Record}
163+
*/
164+
export async function populateRecord(agent, request, shouldUploadImage = false) {
165+
console.log(`Generating record, shouldUploadImage = ${shouldUploadImage}, request = `, request);
166+
167+
if (request.repostURL && !request.repostInfo) {
168+
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
169+
}
170+
if (request.replyURL && !request.replyInfo) {
171+
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
172+
}
173+
174+
if (request.richText && !request.record) {
175+
// TODO(joyeecheung): When Bluesky supports markdown or snippets, we should render the text
176+
// as markdown.
177+
const rt = new RichText({ text: request.richText });
178+
179+
await rt.detectFacets(agent); // automatically detects mentions and links
180+
181+
const record = {
182+
$type: 'app.bsky.feed.post',
183+
text: rt.text,
184+
facets: rt.facets,
185+
createdAt: new Date().toISOString(),
105186
};
187+
188+
// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
189+
if (request.repostInfo) {
190+
record.embed = {
191+
$type: 'app.bsky.embed.record',
192+
record: request.repostInfo
193+
};
194+
} else if (request.replyInfo) {
195+
record.reply = {
196+
root: request.replyInfo,
197+
parent: request.replyInfo,
198+
};
199+
}
200+
201+
// If there is already another embed, don't generate the card embed.
202+
if (!record.embed) {
203+
// Find the first URL, match until the first whitespace or punctuation.
204+
const urlMatch = request.richText.match(/https?:\/\/[^\s\]\[\"\'\<\>]+/);
205+
if (urlMatch !== null) {
206+
const url = urlMatch[0];
207+
const card = await fetchEmbedUrlCard(url);
208+
record.embed = card;
209+
}
210+
}
211+
request.record = record;
212+
}
213+
214+
if (shouldUploadImage && request.record?.embed?.external?.thumb?.$TO_BE_UPLOADED) {
215+
const card = request.record.embed.external;
216+
const imgURL = card.thumb.$TO_BE_UPLOADED;
217+
try {
218+
console.log('Fetching image', imgURL);
219+
const imgResp = await fetch(imgURL);
220+
if (!imgResp.ok) {
221+
throw new Error(`Failed to fetch image ${imgURL}: ${imgResp.status} ${imgResp.statusText}`);
222+
}
223+
const imgData = await imgResp.arrayBuffer();
224+
console.log('Uploading image', imgURL, 'size = ', imgData.byteLength);
225+
card.thumb = await uploadImage(agent, imgData);
226+
} catch (e) {
227+
// If image upload fails, post the embed card without the image, at worst we see a
228+
// link card without an image which is not a big deal.
229+
console.log(`Failed to fetch or upload image ${imgURL}`, e);
230+
}
106231
}
107232

233+
console.log('Generated record');
234+
console.dir(request.record, { depth: 3 });
235+
236+
return request;
237+
}
238+
239+
/**
240+
* @param {AtpAgent} agent
241+
* @param {object} request
242+
*/
243+
export async function post(agent, request) {
244+
const { record } = await populateRecord(agent, request, true);
108245
return agent.post(record);
109246
}

actions/lib/validator.js

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,3 @@ async function validatePostURLInRequest(agent, request, fieldName) {
6767
}
6868
return result;
6969
}
70-
71-
/**
72-
* Validate the post URLs in the request and extend them into { uri, cid } pairs
73-
* if necessary.
74-
* @param {import('@atproto/api').AtpAgent} agent
75-
* @param {object} request
76-
*/
77-
export async function validateAndExtendRequestReferences(agent, request) {
78-
switch(request.action) {
79-
case 'repost':
80-
case 'quote-post': {
81-
const info = await validatePostURLInRequest(agent, request, 'repostURL');
82-
request.repostInfo = info;
83-
break;
84-
}
85-
case 'reply': {
86-
const info = await validatePostURLInRequest(agent, request, 'replyURL');
87-
request.replyInfo = info;
88-
break;
89-
}
90-
default:
91-
break;
92-
}
93-
}

actions/login-and-validate.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +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';
7+
import { validateAccount, validateRequest } from './lib/validator.js';
8+
import { populateRecord } from './lib/posts.js';
89

910
// The JSON file must contains the following fields:
1011
// - "account": a string field indicating the account to use to perform the action.
@@ -28,7 +29,7 @@ validateRequest(request);
2829
// Authenticate.
2930
const agent = await login(account);
3031

31-
// Validate and extend the post URLs in the request into { cid, uri } records.
32-
await validateAndExtendRequestReferences(agent, request);
32+
// Check if the links included are reachable.
33+
await populateRecord(agent, request, false);
3334

3435
export { agent, request, 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
@@ -25,7 +25,7 @@ switch(request.action) {
2525
};
2626
case 'repost': {
2727
console.log('Reposting...', request.repostURL);
28-
assert(request.repostInfo); // Extended by validateAndExtendRequestReferences.
28+
assert(request.repostInfo); // Extended by populateRecord in login-and-validate.js.
2929
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
3030
break;
3131
}
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)