Skip to content

Commit 5ff8795

Browse files
authored
feat: add support for threads (#46)
* feat: add support for threads * use symbol * fixup! use symbol * With root * use CommonMark vocab * fixup! use CommonMark vocab
1 parent adad780 commit 5ff8795

5 files changed

Lines changed: 82 additions & 37 deletions

File tree

actions/lib/posts.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import AtpAgent, { RichText } from "@atproto/api";
22
import assert from 'node:assert';
33

4+
export const REPLY_IN_THREAD = Symbol('Reply in thread');
5+
46
// URL format:
57
// 1. https://bsky.app/profile/${handle}/post/${postId}
68
// 2. https://bsky.app/profile/${did}/post/${postId}
@@ -100,7 +102,7 @@ export async function post(agent, request) {
100102
request.replyInfo = await getPostInfoFromUrl(agent, request.replyURL);
101103
}
102104
record.reply = {
103-
root: request.replyInfo,
105+
root: request.rootInfo || request.replyInfo,
104106
parent: request.replyInfo,
105107
};
106108
}

actions/lib/validator.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from 'node:assert';
2-
import { getPostInfoFromUrl } from './posts.js';
2+
import { getPostInfoFromUrl, REPLY_IN_THREAD } from './posts.js';
33

44
export function validateAccount(request, env) {
55
assert(request.account, 'JSON must contain "account" field');
@@ -43,7 +43,7 @@ export function validateRequest(request) {
4343
assert(
4444
request.richText.length > 0 && request.richText.length <= 300,
4545
'"richText" field cannot be longer than 300 chars');
46-
assert(typeof request.replyURL === 'string', 'JSON must contain "replyURL" string field');
46+
assert(typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD, 'JSON must contain "replyURL" string field');
4747
break;
4848
}
4949
default:
@@ -57,6 +57,7 @@ export function validateRequest(request) {
5757
* @param {string} fieldName
5858
*/
5959
async function validatePostURLInRequest(agent, request, fieldName) {
60+
if (request.replyURL === REPLY_IN_THREAD) return request.replyInfo;
6061
let result;
6162
try {
6263
result = await getPostInfoFromUrl(agent, request[fieldName]);

actions/login-and-validate.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import process from 'node:process';
55
import path from 'node:path';
66
import { login } from './lib/login.js';
77
import { validateAccount, validateRequest, validateAndExtendRequestReferences } from './lib/validator.js';
8+
import { REPLY_IN_THREAD } from './lib/posts.js';
89

910
// The JSON file must contains the following fields:
1011
// - "account": a string field indicating the account to use to perform the action.
@@ -20,15 +21,25 @@ if (Object.hasOwn(request, 'richTextFile')) {
2021
richTextFile = path.resolve(path.dirname(requestFilePath), request.richTextFile);
2122
request.richText = fs.readFileSync(richTextFile, 'utf-8');
2223
}
24+
const threadElements = request.action !== 'repost' && request.richText?.split(/\n+ {0,3}([-_*])[ \t]*(?:\1[ \t]*){2,}\n+/g);
25+
const requests = threadElements?.length ?
26+
threadElements.map((richText, i) => ({
27+
...request,
28+
...(i === 0 ? undefined : {
29+
action: 'reply',
30+
replyURL: REPLY_IN_THREAD,
31+
}),
32+
richText,
33+
})) : [request];
2334

2435
// Validate the account field.
2536
const account = validateAccount(request, process.env);
26-
validateRequest(request);
37+
requests.forEach(validateRequest);
2738

2839
// Authenticate.
2940
const agent = await login(account);
3041

3142
// Validate and extend the post URLs in the request into { cid, uri } records.
32-
await validateAndExtendRequestReferences(agent, request);
43+
await Promise.all(requests.map(request => validateAndExtendRequestReferences(agent, request)));
3344

34-
export { agent, request, requestFilePath, richTextFile };
45+
export { agent, requests, requestFilePath, richTextFile };

actions/process.js

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fs from 'node:fs';
44
import assert from 'node:assert';
55
import process from 'node:process';
66
import path from 'node:path';
7-
import { post } from './lib/posts.js';
7+
import { post, REPLY_IN_THREAD } from './lib/posts.js';
88

99
// This script takes a path to a JSON with the pattern $base_path/new/$any_name.json,
1010
// where $any_name can be anything, and then performs the action specified in it.
@@ -14,39 +14,51 @@ import { post } from './lib/posts.js';
1414
// and already in the processed directory.
1515

1616
assert(process.argv[2], `Usage: node process.js $base_path/new/$any_name.json`);
17-
const { agent, request, requestFilePath, richTextFile } = await import('./login-and-validate.js');
17+
const { agent, requests, requestFilePath, richTextFile } = await import('./login-and-validate.js');
1818

19-
let result;
20-
switch(request.action) {
21-
case 'post': {
22-
console.log(`Posting...`, request.richText);
23-
result = await post(agent, request);
24-
break;
25-
};
26-
case 'repost': {
27-
console.log('Reposting...', request.repostURL);
28-
assert(request.repostInfo); // Extended by validateAndExtendRequestReferences.
29-
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
30-
break;
31-
}
32-
case 'quote-post': {
33-
console.log(`Quote posting...`, request.repostURL, request.richText);
34-
result = await post(agent, request);
35-
break;
36-
}
37-
case 'reply': {
38-
console.log(`Replying...`, request.replyURL, request.richText);
39-
result = await post(agent, request);
40-
break;
19+
let rootPostInfo;
20+
let previousPostInfo;
21+
for (const request of requests) {
22+
let result;
23+
switch(request.action) {
24+
case 'post': {
25+
console.log(`Posting...`, request.richText);
26+
result = await post(agent, request);
27+
break;
28+
};
29+
case 'repost': {
30+
console.log('Reposting...', request.repostURL);
31+
assert(request.repostInfo); // Extended by validateAndExtendRequestReferences.
32+
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
33+
break;
34+
}
35+
case 'quote-post': {
36+
console.log(`Quote posting...`, request.repostURL, request.richText);
37+
result = await post(agent, request);
38+
break;
39+
}
40+
case 'reply': {
41+
if (request.replyURL === REPLY_IN_THREAD) {
42+
request.replyInfo = previousPostInfo;
43+
request.rootInfo = rootPostInfo;
44+
}
45+
console.log(`Replying...`, request.replyURL, request.richText);
46+
result = await post(agent, request);
47+
break;
48+
}
49+
default:
50+
assert.fail('Unknown action ' + request.action);
4151
}
42-
default:
43-
assert.fail('Unknown action ' + request.action);
52+
console.log('Result', result);
53+
// Extend the result to be written to the processed JSON file.
54+
request.result = result;
55+
previousPostInfo = {
56+
uri: result.uri,
57+
cid: result.cid,
58+
};
59+
rootPostInfo ??= previousPostInfo;
4460
}
4561

46-
console.log('Result', result);
47-
// Extend the result to be written to the processed JSON file.
48-
request.result = result;
49-
5062
const date = new Date().toISOString().slice(0, 10);
5163

5264
const processedDir = path.join(requestFilePath, '..', '..', 'processed');
@@ -70,7 +82,7 @@ do {
7082
} while (newFile == null);
7183

7284
console.log('Writing..', newFilePath);
73-
await newFile.writeFile(JSON.stringify(request, null, 2), 'utf8');
85+
await newFile.writeFile(JSON.stringify(requests, null, 2), 'utf8');
7486
await newFile.close();
7587

7688
console.log(`Removing..${requestFilePath}`);

automation.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ Content automation is done in the form of adding new JSON files to `records/new`
4848
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).
4949
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.
5050

51+
### Threads
52+
53+
To send several messages replying to one another, you can separate each tweet
54+
using a Markdown [thematic break][] inside the `richText` or `richTextFile`:
55+
56+
```markdown
57+
Here is the first tweet.
58+
59+
---
60+
61+
Here is the second tweet.
62+
63+
---
64+
65+
Here is the third tweet.
66+
```
67+
5168
## Set up automation in a repository
5269

5370
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
87104
BLUESKY_IDENTIFIER_PIXEL=... # The Bluesky handle
88105
BLUESKY_APP_PASSWORD_PIXEL=... # The app password
89106
```
107+
108+
[thematic break]: https://spec.commonmark.org/0.31.2/#thematic-breaks

0 commit comments

Comments
 (0)