Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 3 additions & 1 deletion actions/lib/posts.js
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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,
};
}
Expand Down
5 changes: 3 additions & 2 deletions actions/lib/validator.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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:
Expand All @@ -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]);
Expand Down
17 changes: 14 additions & 3 deletions actions/login-and-validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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\n---+\n\n/g);
const requests = threadElements?.length ?
threadElements.map((richText, i) => ({
...request,
...(i === 0 ? undefined : {
action: 'reply',
replyURL: REPLY_IN_THREAD,
}),
richText,
})) : [request];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this would be a lot more readable if it's just if-else blocks instead of lots of ternary branches, it kills way more brain cells than it deserves to understand this expression...


// 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 };
74 changes: 43 additions & 31 deletions actions/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand All @@ -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}`);
Expand Down
18 changes: 18 additions & 0 deletions automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Comment thread
aduh95 marked this conversation as resolved.
Outdated

```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.
Expand Down