Skip to content
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
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": "yarn@4.5.3",
"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
Loading