From 7b2c72017a704358d0e40bf0b0ce3e333454d345 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 22 Dec 2024 23:08:22 +0100 Subject: [PATCH 1/6] feat: add support for threads --- actions/login-and-validate.js | 16 +++++++-- actions/process.js | 66 +++++++++++++++++++---------------- automation.md | 18 ++++++++++ 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/actions/login-and-validate.js b/actions/login-and-validate.js index 97b3b94..72b1f9d 100755 --- a/actions/login-and-validate.js +++ b/actions/login-and-validate.js @@ -20,15 +20,25 @@ if (Object.hasOwn(request, 'richTextFile')) { richTextFile = path.resolve(path.dirname(requestFilePath), request.richTextFile); request.richText = fs.readFileSync(richTextFile, 'utf-8'); } +const threadElements = request.action !== 'repost' && request.richText?.split(/\n\n---+\n\n/g); +const requests = threadElements?.length ? + threadElements.map((richText, i) => ({ + ...request, + ...(i === 0 ? undefined : { + action: 'reply', + replyURL: 'REPLACEME' + }), + richText, + })) : [request]; // Validate the account field. const account = validateAccount(request, process.env); -validateRequest(request); +requests.forEach(validateRequest); // Authenticate. const agent = await login(account); // Validate and extend the post URLs in the request into { cid, uri } records. -await validateAndExtendRequestReferences(agent, request); +await Promise.all(requests.map(request => validateAndExtendRequestReferences(agent, request))); -export { agent, request, requestFilePath, richTextFile }; +export { agent, requests, requestFilePath, richTextFile }; diff --git a/actions/process.js b/actions/process.js index 70b1080..7b5a3d5 100755 --- a/actions/process.js +++ b/actions/process.js @@ -14,39 +14,45 @@ import { post } from './lib/posts.js'; // and already in the processed directory. assert(process.argv[2], `Usage: node process.js $base_path/new/$any_name.json`); -const { agent, request, requestFilePath, richTextFile } = await import('./login-and-validate.js'); +const { agent, requests, requestFilePath, richTextFile } = await import('./login-and-validate.js'); -let result; -switch(request.action) { - case 'post': { - console.log(`Posting...`, request.richText); - result = await post(agent, request); - break; - }; - case 'repost': { - console.log('Reposting...', request.repostURL); - assert(request.repostInfo); // Extended by validateAndExtendRequestReferences. - result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid); - break; +let previousPostURL; +for (const request of requests) { + let result; + switch(request.action) { + case 'post': { + console.log(`Posting...`, request.richText); + result = await post(agent, request); + break; + }; + case 'repost': { + console.log('Reposting...', request.repostURL); + assert(request.repostInfo); // Extended by validateAndExtendRequestReferences. + result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid); + break; + } + case 'quote-post': { + console.log(`Quote posting...`, request.repostURL, request.richText); + result = await post(agent, request); + break; + } + case 'reply': { + if (request.replyURL === 'REPLACEME') { + request.replyURL = previousPostURL; + } + console.log(`Replying...`, request.replyURL, request.richText); + result = await post(agent, request); + break; + } + default: + assert.fail('Unknown action ' + request.action); } - case 'quote-post': { - console.log(`Quote posting...`, request.repostURL, request.richText); - result = await post(agent, request); - break; - } - case 'reply': { - console.log(`Replying...`, request.replyURL, request.richText); - result = await post(agent, request); - break; - } - default: - assert.fail('Unknown action ' + request.action); + console.log('Result', result); + // Extend the result to be written to the processed JSON file. + request.result = result; + previousPostURL = result.uri; } -console.log('Result', result); -// Extend the result to be written to the processed JSON file. -request.result = result; - const date = new Date().toISOString().slice(0, 10); const processedDir = path.join(requestFilePath, '..', '..', 'processed'); @@ -70,7 +76,7 @@ do { } while (newFile == null); console.log('Writing..', newFilePath); -await newFile.writeFile(JSON.stringify(request, null, 2), 'utf8'); +await newFile.writeFile(JSON.stringify(requests, null, 2), 'utf8'); await newFile.close(); console.log(`Removing..${requestFilePath}`); diff --git a/automation.md b/automation.md index 0f031a5..984cb15 100644 --- a/automation.md +++ b/automation.md @@ -48,6 +48,24 @@ Content automation is done in the form of adding new JSON files to `records/new` 4. When the PR is merged (either with _Squash and merge_ or with a merge commit, _Rebase and merge_ is not supported), the [process-json](./.github/workflows/process.yml) workflow will run to perform the requested actions, and when it's done, it will move the processed JSON files to `./records/processed` and renamed the file to `YYYY-MM-DD-ID.json` where ID is an incremental ID based on the number of files already processed on that date. It will also add in additional details of the performed actions (e.g. CID and URI of the posted post). 5. When the process workflow is complete (likely within a minute), you should see a commit from the GitHub bot in the main branch moving the JSON file. +### Threads + +To send several messages replying to one another, you can separate each tweet +using a Markdown separator (a new paragraph consisting of only three dashes or +more) inside the `richText` or `richTextFile`: + +```markdown +Here is the first tweet. + +--- + +Here is the second tweet. + +--- + +Here is the third tweet. +``` + ## Set up automation in a repository 1. [Set up repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) for accounts. From e0ee64b2bcaa6a6ca7ee94b2abf4250c7429c089 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 23 Dec 2024 00:21:23 +0100 Subject: [PATCH 2/6] use symbol --- actions/lib/posts.js | 2 ++ actions/lib/validator.js | 3 ++- actions/login-and-validate.js | 3 ++- actions/process.js | 13 ++++++++----- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/actions/lib/posts.js b/actions/lib/posts.js index 2d65227..3df6e79 100644 --- a/actions/lib/posts.js +++ b/actions/lib/posts.js @@ -1,6 +1,8 @@ import AtpAgent, { RichText } from "@atproto/api"; import assert from 'node:assert'; +export const REPLY_IN_THREAD = Symbol('Reply in thread'); + // URL format: // 1. https://bsky.app/profile/${handle}/post/${postId} // 2. https://bsky.app/profile/${did}/post/${postId} diff --git a/actions/lib/validator.js b/actions/lib/validator.js index 6fd6ffc..c542f70 100644 --- a/actions/lib/validator.js +++ b/actions/lib/validator.js @@ -1,5 +1,5 @@ import assert from 'node:assert'; -import { getPostInfoFromUrl } from './posts.js'; +import { getPostInfoFromUrl, REPLY_IN_THREAD } from './posts.js'; export function validateAccount(request, env) { assert(request.account, 'JSON must contain "account" field'); @@ -57,6 +57,7 @@ export function validateRequest(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]); diff --git a/actions/login-and-validate.js b/actions/login-and-validate.js index 72b1f9d..ae58630 100755 --- a/actions/login-and-validate.js +++ b/actions/login-and-validate.js @@ -5,6 +5,7 @@ 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'; // The JSON file must contains the following fields: // - "account": a string field indicating the account to use to perform the action. @@ -26,7 +27,7 @@ const requests = threadElements?.length ? ...request, ...(i === 0 ? undefined : { action: 'reply', - replyURL: 'REPLACEME' + replyURL: REPLY_IN_THREAD, }), richText, })) : [request]; diff --git a/actions/process.js b/actions/process.js index 7b5a3d5..1579b84 100755 --- a/actions/process.js +++ b/actions/process.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import assert from 'node:assert'; import process from 'node:process'; import path from 'node:path'; -import { post } from './lib/posts.js'; +import { post, REPLY_IN_THREAD } from './lib/posts.js'; // This script takes a path to a JSON with the pattern $base_path/new/$any_name.json, // where $any_name can be anything, and then performs the action specified in it. @@ -16,7 +16,7 @@ import { post } from './lib/posts.js'; assert(process.argv[2], `Usage: node process.js $base_path/new/$any_name.json`); const { agent, requests, requestFilePath, richTextFile } = await import('./login-and-validate.js'); -let previousPostURL; +let previousPostInfo; for (const request of requests) { let result; switch(request.action) { @@ -37,8 +37,8 @@ for (const request of requests) { break; } case 'reply': { - if (request.replyURL === 'REPLACEME') { - request.replyURL = previousPostURL; + if (request.replyURL === REPLY_IN_THREAD) { + request.replyInfo = previousPostInfo; } console.log(`Replying...`, request.replyURL, request.richText); result = await post(agent, request); @@ -50,7 +50,10 @@ for (const request of requests) { console.log('Result', result); // Extend the result to be written to the processed JSON file. request.result = result; - previousPostURL = result.uri; + previousPostInfo = { + uri: result.uri, + cid: result.cid, + }; } const date = new Date().toISOString().slice(0, 10); From 78f5f9f768246ef47acdd5255019b504fee764d8 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 23 Dec 2024 00:25:09 +0100 Subject: [PATCH 3/6] fixup! use symbol --- actions/lib/validator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/lib/validator.js b/actions/lib/validator.js index c542f70..6f105e3 100644 --- a/actions/lib/validator.js +++ b/actions/lib/validator.js @@ -43,7 +43,7 @@ export function validateRequest(request) { assert( request.richText.length > 0 && request.richText.length <= 300, '"richText" field cannot be longer than 300 chars'); - assert(typeof request.replyURL === 'string', 'JSON must contain "replyURL" string field'); + assert(typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD, 'JSON must contain "replyURL" string field'); break; } default: From 833887a833e254855422c83f6261eb083c821c20 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 23 Dec 2024 00:47:59 +0100 Subject: [PATCH 4/6] With root --- actions/lib/posts.js | 2 +- actions/process.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/actions/lib/posts.js b/actions/lib/posts.js index 3df6e79..a9f2d6e 100644 --- a/actions/lib/posts.js +++ b/actions/lib/posts.js @@ -102,7 +102,7 @@ export async function post(agent, request) { request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL); } record.reply = { - root: request.replyInfo, + root: request.rootInfo || request.replyInfo, parent: request.replyInfo, }; } diff --git a/actions/process.js b/actions/process.js index 1579b84..02902cb 100755 --- a/actions/process.js +++ b/actions/process.js @@ -16,6 +16,7 @@ import { post, REPLY_IN_THREAD } from './lib/posts.js'; assert(process.argv[2], `Usage: node process.js $base_path/new/$any_name.json`); const { agent, requests, requestFilePath, richTextFile } = await import('./login-and-validate.js'); +let rootPostInfo; let previousPostInfo; for (const request of requests) { let result; @@ -39,6 +40,7 @@ for (const request of requests) { case 'reply': { if (request.replyURL === REPLY_IN_THREAD) { request.replyInfo = previousPostInfo; + request.rootInfo = rootPostInfo; } console.log(`Replying...`, request.replyURL, request.richText); result = await post(agent, request); @@ -54,6 +56,7 @@ for (const request of requests) { uri: result.uri, cid: result.cid, }; + rootPostInfo ??= previousPostInfo; } const date = new Date().toISOString().slice(0, 10); From 711fcc91c67d896b58fa2924163fe0fe8e81e17f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 24 Dec 2024 13:15:26 +0100 Subject: [PATCH 5/6] use CommonMark vocab --- actions/login-and-validate.js | 2 +- automation.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/login-and-validate.js b/actions/login-and-validate.js index ae58630..47ad462 100755 --- a/actions/login-and-validate.js +++ b/actions/login-and-validate.js @@ -21,7 +21,7 @@ if (Object.hasOwn(request, 'richTextFile')) { richTextFile = path.resolve(path.dirname(requestFilePath), request.richTextFile); request.richText = fs.readFileSync(richTextFile, 'utf-8'); } -const threadElements = request.action !== 'repost' && request.richText?.split(/\n\n---+\n\n/g); +const threadElements = request.action !== 'repost' && request.richText?.split(/\n\n {0,3}[-_*]{3,}\n\n/g); const requests = threadElements?.length ? threadElements.map((richText, i) => ({ ...request, diff --git a/automation.md b/automation.md index 984cb15..3dda320 100644 --- a/automation.md +++ b/automation.md @@ -51,8 +51,8 @@ Content automation is done in the form of adding new JSON files to `records/new` ### Threads To send several messages replying to one another, you can separate each tweet -using a Markdown separator (a new paragraph consisting of only three dashes or -more) inside the `richText` or `richTextFile`: +using a Markdown thematic break (e.g. a new paragraph consisting of only three +dashes) inside the `richText` or `richTextFile`: ```markdown Here is the first tweet. From 12af83a4073b7d0471940141551953ba5f28dc8b Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 24 Dec 2024 13:21:46 +0100 Subject: [PATCH 6/6] fixup! use CommonMark vocab --- actions/login-and-validate.js | 2 +- automation.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/actions/login-and-validate.js b/actions/login-and-validate.js index 47ad462..65cc472 100755 --- a/actions/login-and-validate.js +++ b/actions/login-and-validate.js @@ -21,7 +21,7 @@ if (Object.hasOwn(request, 'richTextFile')) { richTextFile = path.resolve(path.dirname(requestFilePath), request.richTextFile); request.richText = fs.readFileSync(richTextFile, 'utf-8'); } -const threadElements = request.action !== 'repost' && request.richText?.split(/\n\n {0,3}[-_*]{3,}\n\n/g); +const threadElements = request.action !== 'repost' && request.richText?.split(/\n+ {0,3}([-_*])[ \t]*(?:\1[ \t]*){2,}\n+/g); const requests = threadElements?.length ? threadElements.map((richText, i) => ({ ...request, diff --git a/automation.md b/automation.md index 3dda320..ccf9341 100644 --- a/automation.md +++ b/automation.md @@ -51,8 +51,7 @@ Content automation is done in the form of adding new JSON files to `records/new` ### Threads To send several messages replying to one another, you can separate each tweet -using a Markdown thematic break (e.g. a new paragraph consisting of only three -dashes) inside the `richText` or `richTextFile`: +using a Markdown [thematic break][] inside the `richText` or `richTextFile`: ```markdown Here is the first tweet. @@ -105,3 +104,5 @@ All files starting with .env are ignored in this repository, you could save the BLUESKY_IDENTIFIER_PIXEL=... # The Bluesky handle BLUESKY_APP_PASSWORD_PIXEL=... # The app password ``` + +[thematic break]: https://spec.commonmark.org/0.31.2/#thematic-breaks