Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Lint

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
biome:
name: Biome
runs-on: ubuntu-latest

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.

Suggested change
runs-on: ubuntu-latest
runs-on: ubuntu-slim

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false

# Must be done before setup-node.
- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"
cache-dependency-path: actions/yarn.lock

- name: Install Dependencies
run: yarn install --frozen-lockfile
working-directory: ./actions

- name: Run Biome
run: yarn ci
working-directory: ./actions
38 changes: 38 additions & 0 deletions actions/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**/*.js"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
2 changes: 1 addition & 1 deletion actions/lib/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export async function getFeedFor(agent, handle, limit) {
return agent.getAuthorFeed({
actor: did,
filter: 'posts_and_author_threads',
limit: limit
limit: limit,
});
}
6 changes: 3 additions & 3 deletions actions/lib/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { AtpAgent } from '@atproto/api';
// TODO(joyeecheung): implement OAuth
export async function login(account) {
const agent = new AtpAgent({
service: 'https://bsky.social'
service: 'https://bsky.social',
});

await agent.login({
identifier: account.identifier,
password: account.password
password: account.password,
});

return agent;
};
}
42 changes: 23 additions & 19 deletions actions/lib/posts.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api";
import assert from 'node:assert';
import AtpAgent, { AppBskyFeedPost, BlobRef, RichText } from '@atproto/api';
import * as cheerio from 'cheerio';

export const REPLY_IN_THREAD = Symbol('Reply in thread');
Expand All @@ -11,7 +11,7 @@ export const REPLY_IN_THREAD = Symbol('Reply in thread');
const kURLPattern = /https:\/\/bsky\.app\/profile\/(.+)\/post\/(.+)/;

/**
* @param {string} url
* @param {string} url
*/
export function validatePostURL(url) {
const match = url.match(kURLPattern);
Expand All @@ -20,12 +20,12 @@ export function validatePostURL(url) {
return {
handle: match[1],
postId: match[2],
isDid: match[1].startsWith('did:')
isDid: match[1].startsWith('did:'),
};
}

/**
* @param {AtpAgent} agent
* @param {AtpAgent} agent
* @param {string} postUrl
*/
export async function getPostInfoFromUrl(agent, postUrl) {
Expand All @@ -46,14 +46,14 @@ export async function getPostInfoFromUrl(agent, postUrl) {
}

// URI format: at://${did}/app.bsky.feed.post/${postId}
const kURIPattern = /at:\/\/(.*)+\/app\.bsky\.feed\.post\/(.*)+/
const kURIPattern = /at:\/\/(.*)+\/app\.bsky\.feed\.post\/(.*)+/;
export function validatePostURI(uri) {
const match = uri.match(kURIPattern);
assert(match, `Post URI ${uri} does not match the expected pattern`);

return {
did: match[1],
postId: match[2]
postId: match[2],
};
}

Expand All @@ -77,7 +77,7 @@ export async function getPostURLFromURI(agent, uri) {
*/
async function uploadImage(agent, imgData) {
const res = await agent.uploadBlob(imgData, {
encoding: 'image/jpeg'
encoding: 'image/jpeg',
});
return res.data.blob;
}
Expand Down Expand Up @@ -179,27 +179,27 @@ export async function populateRecord(agent, request, shouldUploadImage = false)
const rt = new RichText({ text: request.richText });

await rt.detectFacets(agent); // automatically detects mentions and links

const record = {
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
};

// https://docs.bsky.app/docs/tutorials/creating-a-post#quote-posts
if (request.repostInfo) {
record.embed = {
$type: 'app.bsky.embed.record',
record: request.repostInfo
record: request.repostInfo,
};
}
updateReplyRecord(request, record);

// If there is already another embed, don't generate the card embed.
if (!record.embed) {
// Find the first URL, match until the first whitespace or punctuation.
const urlMatch = request.richText.match(/https?:\/\/[^\s\]\[\"\'\<\>]+/);
const urlMatch = request.richText.match(/https?:\/\/[^\s\]["'<>]+/);
if (urlMatch !== null) {
const url = urlMatch[0];
const card = await fetchEmbedUrlCard(url);
Expand Down Expand Up @@ -256,13 +256,15 @@ export function maybeUpdateReplyInThread(request, previousPostInfo, rootPostInfo
// If the request contains rich text with thematic breaks, it will split the request into multiple
// requests.
export function maybeSplitRequests(request) {
if (request.action === 'repost') { // reposts are always single posts.
if (request.action === 'repost') {
// reposts are always single posts.
return [request];
}
if (!request.richText) {
return [request];
}
const thread = request.richText.split(/^\s*(?:[-*_]\s*){2,}\s*$/m)
const thread = request.richText
.split(/^\s*(?:[-*_]\s*){2,}\s*$/m)
.map((text) => text.trim())
.filter((text) => text.length > 0);

Expand All @@ -271,12 +273,14 @@ export function maybeSplitRequests(request) {
}

return thread.map((richText, i) => ({
...request,
...(i === 0 ? undefined : {
action: 'reply', // Posts other than the first one are replies.
replyURL: REPLY_IN_THREAD,
}),
richText,
...request,
...(i === 0
? undefined
: {
action: 'reply', // Posts other than the first one are replies.
replyURL: REPLY_IN_THREAD,
}),
richText,
}));
}

Expand Down
24 changes: 15 additions & 9 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, REPLY_IN_THREAD } from './posts.js';
import { REPLY_IN_THREAD } from './posts.js';

export function validateAccount(request, env) {
assert(request.account, 'JSON must contain "account" field');
Expand All @@ -10,22 +10,23 @@ export function validateAccount(request, env) {
assert(env[passwordKey], `Must provide ${passwordKey} in the environment variable.`);
return {
identifier: env[identifierKey],
password: env[passwordKey]
password: env[passwordKey],
};
}

/**
* Validate the request based on the action requested.
*/
export function validateRequest(request) {
switch(request.action) {
switch (request.action) {
case 'post': {
assert(typeof request.richText === 'string', 'JSON must contain "richText" string field');
assert(
request.richText.length > 0 && request.richText.length <= 300,
'"richText" field cannot be longer than 300 chars');
'"richText" field cannot be longer than 300 chars',
);
break;
};
}
case 'repost': {
assert(typeof request.repostURL === 'string', 'JSON must contain "repostURL" string field');
break;
Expand All @@ -34,19 +35,24 @@ export function validateRequest(request) {
assert(typeof request.richText === 'string', 'JSON must contain "richText" string field');
assert(
request.richText.length > 0 && request.richText.length <= 300,
'"richText" field cannot be longer than 300 chars');
'"richText" field cannot be longer than 300 chars',
);
assert(typeof request.repostURL === 'string', 'JSON must contain "repostURL" string field');
break;
}
case 'reply': {
assert(typeof request.richText === 'string', 'JSON must contain "richText" string field');
assert(
request.richText.length > 0 && request.richText.length <= 300,
'"richText" field cannot be longer than 300 chars');
assert(typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD, 'JSON must contain "replyURL" string field');
'"richText" field cannot be longer than 300 chars',
);
assert(
typeof request.replyURL === 'string' || request.replyURL === REPLY_IN_THREAD,
'JSON must contain "replyURL" string field',
);
break;
}
default:
assert.fail('Unknown action ' + request.action);
assert.fail(`Unknown action ${request.action}`);
}
}
8 changes: 4 additions & 4 deletions actions/login-and-validate.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#!/usr/bin/env node
import assert from 'node:assert';
import fs from 'node:fs';
import process from 'node:process';
import path from 'node:path';
import process from 'node:process';
import { login } from './lib/login.js';
import { maybeSplitRequests, populateRecord } from './lib/posts.js';
import { validateAccount, validateRequest } from './lib/validator.js';
import { populateRecord, maybeSplitRequests } 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 @@ -32,6 +32,6 @@ requests.forEach(validateRequest);
const agent = await login(account);

// Validate and extend the post URLs in the request into { cid, uri } records.
await Promise.all(requests.map(request => populateRecord(agent, request, false)));
await Promise.all(requests.map((request) => populateRecord(agent, request, false)));

export { agent, requests, requestFilePath, richTextFile };
export { agent, requestFilePath, requests, richTextFile };
9 changes: 9 additions & 0 deletions actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@
"type": "module",
"repository": "https://github.com/nodejs/bluesky-playground",
"packageManager": "yarn@4.5.3",
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"ci": "biome ci ."
},
"dependencies": {
"@atproto/api": "^0.13.18",
"cheerio": "^1.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.6"
}
}
15 changes: 7 additions & 8 deletions actions/process.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env node

import fs from 'node:fs';
import assert from 'node:assert';
import process from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import { post, maybeUpdateReplyInThread } from './lib/posts.js';
import process from 'node:process';
import { maybeUpdateReplyInThread, post } 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 @@ -20,15 +20,15 @@ let rootPostInfo;
let previousPostInfo;
for (const request of requests) {
let result;
switch(request.action) {
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 populateRecord.
assert(request.repostInfo); // Extended by populateRecord.
result = await agent.repost(request.repostInfo.uri, request.repostInfo.cid);
break;
}
Expand All @@ -44,7 +44,7 @@ for (const request of requests) {
break;
}
default:
assert.fail('Unknown action ' + request.action);
assert.fail(`Unknown action ${request.action}`);
}
console.log('Result', result);
// Extend the result to be written to the processed JSON file.
Expand Down Expand Up @@ -89,5 +89,4 @@ if (richTextFile) {
fs.rmSync(richTextFile);
}


console.log(`Processed and moved file: ${requestFilePath} -> ${newFilePath}`);
Loading