Skip to content

Commit

Permalink
Support embed url card (#43)
Browse files Browse the repository at this point in the history
* Support embed url card

* fixup! Support embed url card
  • Loading branch information
joyeecheung authored Jan 6, 2025
1 parent 5ff8795 commit 3e40dd1
Show file tree
Hide file tree
Showing 25 changed files with 404 additions and 103 deletions.
1 change: 1 addition & 0 deletions actions/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
node_modules
.env*

test/.tmp
197 changes: 167 additions & 30 deletions actions/lib/posts.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AtpAgent, { RichText } from "@atproto/api";
import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api";
import assert from 'node:assert';
import * as cheerio from 'cheerio';

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

Expand Down Expand Up @@ -69,43 +70,179 @@ export async function getPostURLFromURI(agent, uri) {
}

/**
* TODO(joyeecheung): support 'imageFiles' field in JSON files.
* @param {AtpAgent} agent
* @param {object} request
* @param {ArrayBuffer} imgData
* @returns {BlobRef}
*/
export async function post(agent, request) {
// TODO(joyeecheung): support images and embeds.
// TODO(joyeecheung): When Bluesky supports markdown or snippets, we should ideally
// read a relative path in the request containing those contents instead of reading from
// strings in a JSON.
const rt = new RichText({ text: request.richText });

await rt.detectFacets(agent); // automatically detects mentions and links

const record = {
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
async function uploadImage(agent, imgData) {
const res = await agent.uploadBlob(imgData, {
encoding: 'image/jpeg'
});
return res.data.blob;
}

// https://docs.bsky.app/docs/advanced-guides/posts#website-card-embeds
async function fetchEmbedUrlCard(url) {
console.log('Fetching embed card from', url);

// The required fields for every embed card
const card = {
uri: url,
title: '',
description: '',
};

// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
if (request.repostURL) {
if (!request.repostInfo) {
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
try {
// Fetch the HTML
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch URL: ${resp.status} ${resp.statusText}`);
}
record.embed = {
$type: 'app.bsky.embed.record',
record: request.repostInfo
};
} else if (request.replyURL) {
if (!request.replyInfo) {
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
const html = await resp.text();
const $ = cheerio.load(html);

// Parse out the "og:title" and "og:description" HTML meta tags
const titleTag = $('meta[property="og:title"]').attr('content');
if (titleTag) {
card.title = titleTag;
}

const descriptionTag = $('meta[property="og:description"]').attr('content');
if (descriptionTag) {
card.description = descriptionTag;
}

// If there is an "og:image" HTML meta tag, fetch and upload that image
const imageTag = $('meta[property="og:image"]').attr('content');
if (imageTag) {
let imgURL = imageTag;

// Naively turn a "relative" URL (just a path) into a full URL, if needed
if (!imgURL.includes('://')) {
imgURL = new URL(imgURL, url).href;
}
card.thumb = { $TO_BE_UPLOADED: imgURL };
}
record.reply = {
root: request.rootInfo || request.replyInfo,
parent: request.replyInfo,

return {
$type: 'app.bsky.embed.external',
external: card,
};
} catch (error) {
console.error('Error generating embed URL card:', error.message);
throw error;
}
}

/**
* @typedef ReplyRequest
* @property {string} richText
* @property {string} replyURL
* @property {{cid: string, uri: string}?} replyInfo
*/

/**
* @typedef PostRequest
* @property {string} richText
*/

/**
* @typedef QuotePostRequest
* @property {string} richText
* @property {string} repostURL
* @property {{cid: string, uri: string}?} repostInfo
*/

/**
* It should be possible to invoked this method on the same request at least twice -
* once to populate the facets and the embed without uploading any files if shouldUploadImage
* is false, and then again uploading files if shouldUploadImage is true.
* @param {AtpAgent} agent
* @param {ReplyRequest|PostRequest|QuotePostRequest} request
* @param {boolean} shouldUploadImage
* @returns {AppBskyFeedPost.Record}
*/
export async function populateRecord(agent, request, shouldUploadImage = false) {
console.log(`Generating record, shouldUploadImage = ${shouldUploadImage}, request = `, request);

if (request.repostURL && !request.repostInfo) {
request.repostInfo = await getPostInfoFromUrl(agent, request.repostURL);
}
if (request.replyURL && request.replyURL !== REPLY_IN_THREAD && !request.replyInfo) {
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
}

if (request.richText && !request.record) {
// TODO(joyeecheung): When Bluesky supports markdown or snippets, we should render the text
// as markdown.
const rt = new RichText({ text: request.richText });

await rt.detectFacets(agent); // automatically detects mentions and links

const record = {
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
};

// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
if (request.repostInfo) {
record.embed = {
$type: 'app.bsky.embed.record',
record: request.repostInfo
};
} else if (request.replyInfo) {
record.reply = {
root: request.rootInfo || request.replyInfo,
parent: request.replyInfo,
};
}

// If there is already another embed, don't generate the card embed.
if (!record.embed) {
// Find the first URL, match until the first whitespace or punctuation.
const urlMatch = request.richText.match(/https?:\/\/[^\s\]\[\"\'\<\>]+/);
if (urlMatch !== null) {
const url = urlMatch[0];
const card = await fetchEmbedUrlCard(url);
record.embed = card;
}
}
request.record = record;
}

if (shouldUploadImage && request.record?.embed?.external?.thumb?.$TO_BE_UPLOADED) {
const card = request.record.embed.external;
const imgURL = card.thumb.$TO_BE_UPLOADED;
try {
console.log('Fetching image', imgURL);
const imgResp = await fetch(imgURL);
if (!imgResp.ok) {
throw new Error(`Failed to fetch image ${imgURL}: ${imgResp.status} ${imgResp.statusText}`);
}
const imgData = await imgResp.arrayBuffer();
console.log('Uploading image', imgURL, 'size = ', imgData.byteLength);
card.thumb = await uploadImage(agent, imgData);
} catch (e) {
// If image upload fails, post the embed card without the image, at worst we see a
// link card without an image which is not a big deal.
console.log(`Failed to fetch or upload image ${imgURL}`, e);
}
}

console.log('Generated record');
console.dir(request.record, { depth: 3 });

return request;
}

/**
* @param {AtpAgent} agent
* @param {object} request
*/
export async function post(agent, request) {
const { record } = await populateRecord(agent, request, true);
return agent.post(record);
}
42 changes: 0 additions & 42 deletions actions/lib/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,45 +50,3 @@ export function validateRequest(request) {
assert.fail('Unknown action ' + request.action);
}
}

/**
* @param {import('@atproto/api').AtpAgent} agent
* @param {object} request
* @param {string} fieldName
*/
async function validatePostURLInRequest(agent, request, fieldName) {
if (request.replyURL === REPLY_IN_THREAD) return request.replyInfo;
let result;
try {
result = await getPostInfoFromUrl(agent, request[fieldName]);
} finally {
if (!result) {
console.error(`Invalid "${fieldName}" field, ${request[fieldName]}`);
}
}
return result;
}

/**
* Validate the post URLs in the request and extend them into { uri, cid } pairs
* if necessary.
* @param {import('@atproto/api').AtpAgent} agent
* @param {object} request
*/
export async function validateAndExtendRequestReferences(agent, request) {
switch(request.action) {
case 'repost':
case 'quote-post': {
const info = await validatePostURLInRequest(agent, request, 'repostURL');
request.repostInfo = info;
break;
}
case 'reply': {
const info = await validatePostURLInRequest(agent, request, 'replyURL');
request.replyInfo = info;
break;
}
default:
break;
}
}
6 changes: 3 additions & 3 deletions actions/login-and-validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import fs from 'node:fs';
import process from 'node:process';
import path from 'node:path';
import { login } from './lib/login.js';
import { validateAccount, validateRequest, validateAndExtendRequestReferences } from './lib/validator.js';
import { REPLY_IN_THREAD } from './lib/posts.js';
import { validateAccount, validateRequest } from './lib/validator.js';
import { populateRecord, REPLY_IN_THREAD } from './lib/posts.js';

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

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

export { agent, requests, requestFilePath, richTextFile };
3 changes: 2 additions & 1 deletion actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"repository": "https://github.com/nodejs/bluesky-playground",
"packageManager": "[email protected]",
"dependencies": {
"@atproto/api": "^0.13.18"
"@atproto/api": "^0.13.18",
"cheerio": "^1.0.0"
}
}
2 changes: 1 addition & 1 deletion actions/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ for (const request of requests) {
};
case 'repost': {
console.log('Reposting...', request.repostURL);
assert(request.repostInfo); // Extended by validateAndExtendRequestReferences.
assert(request.repostInfo); // Extended by populateRecord.
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
break;
}
Expand Down
4 changes: 2 additions & 2 deletions actions/test/examples/new/post.json.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"action": "post",
"account": "PIXEL",
"richText": "Hello from automation!"
"account": "PRIMARY",
"richText": "Hello from automation https://github.com/nodejs/bluesky"
}
4 changes: 2 additions & 2 deletions actions/test/examples/new/quote-post.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "quote-post",
"account": "PIXEL",
"richText": "Quote post from automation",
"account": "PRIMARY",
"richText": "Quote post from automation https://github.com/nodejs/bluesky",
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
}
4 changes: 2 additions & 2 deletions actions/test/examples/new/reply.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "reply",
"account": "PIXEL",
"richText": "Reply from automation",
"account": "PRIMARY",
"richText": "Reply from automation https://github.com/nodejs/bluesky",
"replyURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
}
2 changes: 1 addition & 1 deletion actions/test/examples/new/repost.json.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"action": "repost",
"account": "PIXEL",
"account": "PRIMARY",
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbg7zd32an2s"
}
2 changes: 1 addition & 1 deletion actions/test/examples/processed/post.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "post",
"account": "PIXEL",
"account": "PRIMARY",
"richText": "Hello from automation!",
"result": {
"uri": "at://did:plc:tw2ov5bciclbz7b45sh4xlua/app.bsky.feed.post/3lbnijyd24t2i",
Expand Down
2 changes: 1 addition & 1 deletion actions/test/examples/processed/quote-post.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "quote-post",
"account": "PIXEL",
"account": "PRIMARY",
"richText": "Quote post from automation",
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbnijyd24t2i",
"repostInfo": {
Expand Down
2 changes: 1 addition & 1 deletion actions/test/examples/processed/reply.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "reply",
"account": "PIXEL",
"account": "PRIMARY",
"richText": "Reply from automation",
"replyURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbnik24wgk2i",
"replyInfo": {
Expand Down
2 changes: 1 addition & 1 deletion actions/test/examples/processed/repost.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"action": "repost",
"account": "PIXEL",
"account": "PRIMARY",
"repostURL": "https://bsky.app/profile/pixel-voyager.bsky.social/post/3lbnik3jixl2h",
"repostInfo": {
"uri": "at://did:plc:tw2ov5bciclbz7b45sh4xlua/app.bsky.feed.post/3lbnik3jixl2h",
Expand Down
2 changes: 1 addition & 1 deletion actions/test/integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async function getURLFromLastResult(lastStdout) {
lastStdout = lastStdout.toString();
const postMatch = lastStdout.match(/Processed and moved file: (.*) -> (.*)/);
assert(postMatch);
const processed = loadJSON(postMatch[2]);
const processed = loadJSON(postMatch[2])[0];
assert(processed.result.uri);
const uriParts = processed.result.uri.split('/');
const postId = uriParts[uriParts.length - 1];
Expand Down
Loading

0 comments on commit 3e40dd1

Please sign in to comment.