Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for threads #46

Merged
merged 6 commits into from
Jan 6, 2025
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
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+ {0,3}([-_*])[ \t]*(?:\1[ \t]*){2,}\n+/g);
Copy link
Member

Choose a reason for hiding this comment

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

I feel that at some point, it would be good to include a proper markdown parser - for example, we can support alternative link text, which is not currently supported in bluesky UI, but supported in their API (the hacker news bot uses this extensively https://bsky.app/profile/newsycombinator.bsky.social), and we can support that too with markdown link syntax, which is a nice-to-have.

const requests = threadElements?.length ?
threadElements.map((richText, i) => ({
...request,
...(i === 0 ? undefined : {
action: 'reply',
replyURL: REPLY_IN_THREAD,
}),
richText,
})) : [request];
Copy link
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
19 changes: 19 additions & 0 deletions automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Loading