diff --git a/actions/lib/posts.js b/actions/lib/posts.js index 2d65227..a9f2d6e 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} @@ -100,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/lib/validator.js b/actions/lib/validator.js index 6fd6ffc..6f105e3 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'); @@ -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: @@ -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 97b3b94..65cc472 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. @@ -20,15 +21,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+ {0,3}([-_*])[ \t]*(?:\1[ \t]*){2,}\n+/g); +const requests = threadElements?.length ? + threadElements.map((richText, i) => ({ + ...request, + ...(i === 0 ? undefined : { + action: 'reply', + replyURL: REPLY_IN_THREAD, + }), + 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..02902cb 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. @@ -14,39 +14,51 @@ 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; - } - 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; +let rootPostInfo; +let previousPostInfo; +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 === REPLY_IN_THREAD) { + request.replyInfo = previousPostInfo; + request.rootInfo = rootPostInfo; + } + console.log(`Replying...`, request.replyURL, request.richText); + result = await post(agent, request); + break; + } + default: + assert.fail('Unknown action ' + request.action); } - 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; + previousPostInfo = { + uri: result.uri, + cid: result.cid, + }; + rootPostInfo ??= previousPostInfo; } -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 +82,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..ccf9341 100644 --- a/automation.md +++ b/automation.md @@ -48,6 +48,23 @@ 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 [thematic break][] 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. @@ -87,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