[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Bluesky convenience node (Post, Like, Repost, Follow)

Adds a task-shaped Bluesky node that hides NSIDs and lexicon details
for the most common app.bsky.* operations. The generic AT Protocol
node remains for everything else.

Resources & operations:
- Post \u2192 Create / Reply / Quote
- Like \u2192 Create
- Repost \u2192 Create
- Follow \u2192 Create

Affordances handled internally:
- Rich-text facet auto-detection (mentions / links / hashtags)
via RichText.detectFacets() from @atproto/api, with correct
UTF-8 byte offsets
- Handle \u2192 DID resolution for follows and mentions
- Parent \u2192 root walking when building reply refs (re-uses the
parent's reply.root if present)
- Both at:// URIs and https://bsky.app/profile/.../post/... URLs
accepted everywhere a post reference is requested
- Optional image embed via binary property + uploadBlob

Files:
- src/nodes/Bluesky/Bluesky.node.ts
- src/nodes/Bluesky/operations.ts
- src/nodes/Bluesky/postUri.ts
- src/nodes/Bluesky/bluesky.svg
- tests/bluesky.test.ts (8 new tests)

Node marker: usableAsTool: true so AI agents can call it as a tool.
176 \u2192 184 tests passing. Build clean.

+859 -1
+2 -1
package.json
··· 37 37 ], 38 38 "nodes": [ 39 39 "dist/nodes/Atproto/Atproto.node.js", 40 - "dist/nodes/Atproto/AtprotoJetstreamTrigger.node.js" 40 + "dist/nodes/Atproto/AtprotoJetstreamTrigger.node.js", 41 + "dist/nodes/Bluesky/Bluesky.node.js" 41 42 ] 42 43 }, 43 44 "devDependencies": {
+1
src/index.ts
··· 4 4 5 5 export { Atproto } from './nodes/Atproto/Atproto.node'; 6 6 export { AtprotoJetstreamTrigger } from './nodes/Atproto/AtprotoJetstreamTrigger.node'; 7 + export { Bluesky } from './nodes/Bluesky/Bluesky.node'; 7 8 export { AtprotoApi } from './credentials/AtprotoApi.credentials';
+426
src/nodes/Bluesky/Bluesky.node.ts
··· 1 + /** 2 + * Bluesky node — opinionated, task-shaped wrappers around the 3 + * AT Protocol `app.bsky.*` lexicons. Hides NSIDs and lexicon details 4 + * for the most common Bluesky tasks: posting, replying, quoting, 5 + * liking, reposting and following. 6 + * 7 + * Power users should keep using the generic AT Protocol node for 8 + * anything not covered here, or for non-default collection NSIDs. 9 + */ 10 + 11 + import type { 12 + IDataObject, 13 + IExecuteFunctions, 14 + INodeExecutionData, 15 + INodeType, 16 + INodeTypeDescription, 17 + } from 'n8n-workflow'; 18 + import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; 19 + 20 + import { createAgent } from '../Atproto/shared'; 21 + import { 22 + createPost, 23 + followUser, 24 + likePost, 25 + quotePost, 26 + replyToPost, 27 + repostPost, 28 + } from './operations'; 29 + 30 + export class Bluesky implements INodeType { 31 + description: INodeTypeDescription = { 32 + displayName: 'Bluesky', 33 + name: 'bluesky', 34 + icon: 'file:bluesky.svg', 35 + group: ['transform'], 36 + version: 1, 37 + subtitle: 38 + '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', 39 + description: 'Post, reply, quote, like, repost and follow on Bluesky', 40 + defaults: { 41 + name: 'Bluesky', 42 + }, 43 + usableAsTool: true, 44 + inputs: [NodeConnectionTypes.Main], 45 + outputs: [NodeConnectionTypes.Main], 46 + credentials: [ 47 + { 48 + name: 'atprotoApi', 49 + required: true, 50 + }, 51 + ], 52 + properties: [ 53 + // ------------------------------------------------------------------ 54 + // Resource 55 + // ------------------------------------------------------------------ 56 + { 57 + displayName: 'Resource', 58 + name: 'resource', 59 + type: 'options', 60 + noDataExpression: true, 61 + options: [ 62 + { name: 'Post', value: 'post' }, 63 + { name: 'Like', value: 'like' }, 64 + { name: 'Repost', value: 'repost' }, 65 + { name: 'Follow', value: 'follow' }, 66 + ], 67 + default: 'post', 68 + }, 69 + 70 + // ------------------------------------------------------------------ 71 + // Operation — Post 72 + // ------------------------------------------------------------------ 73 + { 74 + displayName: 'Operation', 75 + name: 'operation', 76 + type: 'options', 77 + noDataExpression: true, 78 + displayOptions: { 79 + show: { resource: ['post'] }, 80 + }, 81 + options: [ 82 + { 83 + name: 'Create', 84 + value: 'create', 85 + description: 'Create a new post', 86 + action: 'Create a post', 87 + }, 88 + { 89 + name: 'Reply', 90 + value: 'reply', 91 + description: 'Reply to an existing post', 92 + action: 'Reply to a post', 93 + }, 94 + { 95 + name: 'Quote', 96 + value: 'quote', 97 + description: 'Quote-post an existing post', 98 + action: 'Quote a post', 99 + }, 100 + ], 101 + default: 'create', 102 + }, 103 + 104 + // ------------------------------------------------------------------ 105 + // Operation — Like / Repost / Follow (single operation, hidden) 106 + // ------------------------------------------------------------------ 107 + { 108 + displayName: 'Operation', 109 + name: 'operation', 110 + type: 'options', 111 + noDataExpression: true, 112 + displayOptions: { 113 + show: { resource: ['like', 'repost', 'follow'] }, 114 + }, 115 + options: [ 116 + { 117 + name: 'Create', 118 + value: 'create', 119 + action: 'Create', 120 + }, 121 + ], 122 + default: 'create', 123 + }, 124 + 125 + // ------------------------------------------------------------------ 126 + // Text — Post Create / Reply / Quote 127 + // ------------------------------------------------------------------ 128 + { 129 + displayName: 'Text', 130 + name: 'text', 131 + type: 'string', 132 + required: true, 133 + default: '', 134 + placeholder: 'Hello @alice.bsky.social, check this out!', 135 + description: 136 + 'The post text. Mentions (@handle), URLs and #hashtags are auto-detected and rendered as rich-text facets.', 137 + typeOptions: { 138 + rows: 4, 139 + }, 140 + displayOptions: { 141 + show: { 142 + resource: ['post'], 143 + operation: ['create', 'reply', 'quote'], 144 + }, 145 + }, 146 + }, 147 + 148 + // ------------------------------------------------------------------ 149 + // Parent URI — Reply 150 + // ------------------------------------------------------------------ 151 + { 152 + displayName: 'Reply To', 153 + name: 'parentUri', 154 + type: 'string', 155 + required: true, 156 + default: '', 157 + placeholder: 158 + 'at://did:plc:.../app.bsky.feed.post/3jzfc... or https://bsky.app/profile/.../post/3jzfc...', 159 + description: 160 + 'The post to reply to. Accepts either an at:// URI or a bsky.app URL.', 161 + displayOptions: { 162 + show: { 163 + resource: ['post'], 164 + operation: ['reply'], 165 + }, 166 + }, 167 + }, 168 + 169 + // ------------------------------------------------------------------ 170 + // Quoted URI — Quote 171 + // ------------------------------------------------------------------ 172 + { 173 + displayName: 'Quoted Post', 174 + name: 'quotedUri', 175 + type: 'string', 176 + required: true, 177 + default: '', 178 + placeholder: 179 + 'at://did:plc:.../app.bsky.feed.post/3jzfc... or https://bsky.app/profile/.../post/3jzfc...', 180 + description: 181 + 'The post to quote. Accepts either an at:// URI or a bsky.app URL.', 182 + displayOptions: { 183 + show: { 184 + resource: ['post'], 185 + operation: ['quote'], 186 + }, 187 + }, 188 + }, 189 + 190 + // ------------------------------------------------------------------ 191 + // Subject — Like / Repost 192 + // ------------------------------------------------------------------ 193 + { 194 + displayName: 'Post', 195 + name: 'subjectUri', 196 + type: 'string', 197 + required: true, 198 + default: '', 199 + placeholder: 200 + 'at://did:plc:.../app.bsky.feed.post/3jzfc... or https://bsky.app/profile/.../post/3jzfc...', 201 + description: 'The post to like or repost. Accepts at:// URI or bsky.app URL.', 202 + displayOptions: { 203 + show: { 204 + resource: ['like', 'repost'], 205 + }, 206 + }, 207 + }, 208 + 209 + // ------------------------------------------------------------------ 210 + // User — Follow 211 + // ------------------------------------------------------------------ 212 + { 213 + displayName: 'User', 214 + name: 'subjectUser', 215 + type: 'string', 216 + required: true, 217 + default: '', 218 + placeholder: 'alice.bsky.social or did:plc:...', 219 + description: 'The handle or DID of the user to follow', 220 + displayOptions: { 221 + show: { 222 + resource: ['follow'], 223 + }, 224 + }, 225 + }, 226 + 227 + // ------------------------------------------------------------------ 228 + // Options — Post Create / Reply / Quote 229 + // ------------------------------------------------------------------ 230 + { 231 + displayName: 'Options', 232 + name: 'options', 233 + type: 'collection', 234 + placeholder: 'Add Option', 235 + default: {}, 236 + displayOptions: { 237 + show: { 238 + resource: ['post'], 239 + operation: ['create', 'reply', 'quote'], 240 + }, 241 + }, 242 + options: [ 243 + { 244 + displayName: 'Languages', 245 + name: 'langs', 246 + type: 'string', 247 + default: 'en', 248 + placeholder: 'en or en,is,fr', 249 + description: 250 + 'Comma-separated BCP-47 language tags for the post. Defaults to en.', 251 + }, 252 + { 253 + displayName: 'Image Binary Property', 254 + name: 'imageBinaryProperty', 255 + type: 'string', 256 + default: '', 257 + placeholder: 'data', 258 + description: 259 + 'Name of the binary property on the incoming item containing an image to embed. Leave empty for a text-only post.', 260 + displayOptions: { 261 + show: { 262 + '/operation': ['create'], 263 + }, 264 + }, 265 + }, 266 + { 267 + displayName: 'Image Alt Text', 268 + name: 'imageAlt', 269 + type: 'string', 270 + default: '', 271 + description: 'Accessibility description of the embedded image', 272 + displayOptions: { 273 + show: { 274 + '/operation': ['create'], 275 + }, 276 + }, 277 + }, 278 + ], 279 + }, 280 + ], 281 + }; 282 + 283 + async execute(this: IExecuteFunctions) { 284 + const items = this.getInputData(); 285 + const returnData: INodeExecutionData[] = []; 286 + 287 + const credentials = await this.getCredentials('atprotoApi'); 288 + const agent = await createAgent(credentials as IDataObject); 289 + 290 + for (let i = 0; i < items.length; i++) { 291 + try { 292 + const resource = this.getNodeParameter('resource', i) as string; 293 + const operation = this.getNodeParameter('operation', i) as string; 294 + 295 + let result: IDataObject; 296 + 297 + if (resource === 'post' && operation === 'create') { 298 + const text = this.getNodeParameter('text', i) as string; 299 + const options = this.getNodeParameter( 300 + 'options', 301 + i, 302 + {}, 303 + ) as { 304 + langs?: string; 305 + imageBinaryProperty?: string; 306 + imageAlt?: string; 307 + }; 308 + 309 + const langs = parseLangs(options.langs); 310 + let image: { blob: unknown; alt: string } | undefined; 311 + 312 + if (options.imageBinaryProperty) { 313 + const binaryData = await this.helpers.getBinaryDataBuffer( 314 + i, 315 + options.imageBinaryProperty, 316 + ); 317 + const mimeType = 318 + items[i].binary?.[options.imageBinaryProperty]?.mimeType ?? 319 + 'application/octet-stream'; 320 + const uploaded = await agent.com.atproto.repo.uploadBlob( 321 + binaryData, 322 + { encoding: mimeType }, 323 + ); 324 + image = { 325 + blob: uploaded.data.blob, 326 + alt: options.imageAlt ?? '', 327 + }; 328 + } 329 + 330 + result = (await createPost(agent, { 331 + text, 332 + langs, 333 + image, 334 + })) as unknown as IDataObject; 335 + } else if (resource === 'post' && operation === 'reply') { 336 + const text = this.getNodeParameter('text', i) as string; 337 + const parentUri = this.getNodeParameter('parentUri', i) as string; 338 + const options = this.getNodeParameter('options', i, {}) as { 339 + langs?: string; 340 + }; 341 + 342 + result = (await replyToPost(agent, { 343 + text, 344 + parentUri, 345 + langs: parseLangs(options.langs), 346 + })) as unknown as IDataObject; 347 + } else if (resource === 'post' && operation === 'quote') { 348 + const text = this.getNodeParameter('text', i) as string; 349 + const quotedUri = this.getNodeParameter('quotedUri', i) as string; 350 + const options = this.getNodeParameter('options', i, {}) as { 351 + langs?: string; 352 + }; 353 + 354 + result = (await quotePost(agent, { 355 + text, 356 + quotedUri, 357 + langs: parseLangs(options.langs), 358 + })) as unknown as IDataObject; 359 + } else if (resource === 'like') { 360 + const subjectUri = this.getNodeParameter('subjectUri', i) as string; 361 + result = (await likePost( 362 + agent, 363 + subjectUri, 364 + )) as unknown as IDataObject; 365 + } else if (resource === 'repost') { 366 + const subjectUri = this.getNodeParameter('subjectUri', i) as string; 367 + result = (await repostPost( 368 + agent, 369 + subjectUri, 370 + )) as unknown as IDataObject; 371 + } else if (resource === 'follow') { 372 + const subjectUser = this.getNodeParameter( 373 + 'subjectUser', 374 + i, 375 + ) as string; 376 + result = (await followUser( 377 + agent, 378 + subjectUser, 379 + )) as unknown as IDataObject; 380 + } else { 381 + throw new NodeOperationError( 382 + this.getNode(), 383 + `Unsupported resource/operation: ${resource}/${operation}`, 384 + { itemIndex: i }, 385 + ); 386 + } 387 + 388 + returnData.push({ 389 + json: result, 390 + pairedItem: { item: i }, 391 + }); 392 + } catch (error) { 393 + if (this.continueOnFail()) { 394 + returnData.push({ 395 + json: { 396 + error: error instanceof Error ? error.message : String(error), 397 + }, 398 + pairedItem: { item: i }, 399 + }); 400 + continue; 401 + } 402 + throw new NodeOperationError( 403 + this.getNode(), 404 + error instanceof Error ? error : new Error(String(error)), 405 + { itemIndex: i }, 406 + ); 407 + } 408 + } 409 + 410 + return [returnData]; 411 + } 412 + } 413 + 414 + /** 415 + * Normalize a user-supplied "en,is,fr" string into a string[] for the 416 + * record's `langs` field. Returns undefined for empty input so the 417 + * caller falls back to its own default. 418 + */ 419 + function parseLangs(input: string | undefined): string[] | undefined { 420 + if (!input) return undefined; 421 + const parts = input 422 + .split(',') 423 + .map((s) => s.trim()) 424 + .filter((s) => s.length > 0); 425 + return parts.length > 0 ? parts : undefined; 426 + }
+7
src/nodes/Bluesky/bluesky.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"> 2 + <!-- AT Protocol butterfly logo - simplified vector --> 3 + <circle cx="32" cy="32" r="30" fill="#1A1A2E"/> 4 + <path d="M32 12C24 12 18 18 18 26C18 30 20 34 23 36L32 52L41 36C44 34 46 30 46 26C46 18 40 12 32 12Z" fill="#4A90D9" opacity="0.9"/> 5 + <path d="M32 16C26 16 22 20 22 26C22 30 24 33 27 35L32 44L37 35C40 33 42 30 42 26C42 20 38 16 32 16Z" fill="#6BB5FF" opacity="0.8"/> 6 + <path d="M32 20C28 20 26 23 26 26C26 29 27 30 29 31L32 36L35 31C37 30 38 29 38 26C38 23 36 20 32 20Z" fill="#FFFFFF" opacity="0.9"/> 7 + </svg>
+255
src/nodes/Bluesky/operations.ts
··· 1 + /** 2 + * Bluesky-specific operations. Thin wrappers around the AT Protocol 3 + * createRecord XRPC that handle the per-record-type ceremony: 4 + * 5 + * - Post.create → builds the rich-text record (auto-facet detection), 6 + * optional language tags, optional image embed 7 + * - Post.reply → walks parent → root and builds the reply ref 8 + * - Post.quote → builds an `app.bsky.embed.record` embed 9 + * - Like.create → fetches subject CID, creates `app.bsky.feed.like` 10 + * - Repost.create → same shape as Like, in `app.bsky.feed.repost` 11 + * - Follow.create → resolves handle → DID, creates `app.bsky.graph.follow` 12 + * 13 + * All operations return `{ uri, cid }` from the underlying createRecord 14 + * call so the workflow can chain on the new record. 15 + */ 16 + 17 + import type { Agent } from '@atproto/api'; 18 + import { RichText } from '@atproto/api'; 19 + 20 + import { fetchPostRef, parsePostUri } from './postUri'; 21 + 22 + // --------------------------------------------------------------------------- 23 + // Common types 24 + // --------------------------------------------------------------------------- 25 + 26 + type StrongRef = { uri: string; cid: string }; 27 + 28 + export interface CreateResult { 29 + uri: string; 30 + cid: string; 31 + } 32 + 33 + // --------------------------------------------------------------------------- 34 + // Rich-text builder — used by Create / Reply / Quote 35 + // --------------------------------------------------------------------------- 36 + 37 + /** 38 + * Build the rich-text payload for a post: `{ text, facets }`. 39 + * 40 + * Uses `RichText.detectFacets(agent)` from @atproto/api, which finds 41 + * @mentions (and resolves them to DIDs via the PDS), URLs and #tags, 42 + * with correct byte offsets per the lexicon. 43 + */ 44 + async function buildRichText( 45 + agent: Agent, 46 + text: string, 47 + ): Promise<{ text: string; facets?: unknown[] }> { 48 + const rt = new RichText({ text }); 49 + await rt.detectFacets(agent); 50 + return { 51 + text: rt.text, 52 + ...(rt.facets && rt.facets.length > 0 ? { facets: rt.facets } : {}), 53 + }; 54 + } 55 + 56 + // --------------------------------------------------------------------------- 57 + // Post — Create 58 + // --------------------------------------------------------------------------- 59 + 60 + export interface CreatePostParams { 61 + text: string; 62 + langs?: string[]; 63 + /** Optional image embed: pre-uploaded blob + alt text */ 64 + image?: { blob: unknown; alt: string }; 65 + } 66 + 67 + export async function createPost( 68 + agent: Agent, 69 + params: CreatePostParams, 70 + ): Promise<CreateResult> { 71 + const rt = await buildRichText(agent, params.text); 72 + 73 + const record: Record<string, unknown> = { 74 + $type: 'app.bsky.feed.post', 75 + ...rt, 76 + langs: params.langs && params.langs.length > 0 ? params.langs : ['en'], 77 + createdAt: new Date().toISOString(), 78 + }; 79 + 80 + if (params.image) { 81 + record.embed = { 82 + $type: 'app.bsky.embed.images', 83 + images: [{ alt: params.image.alt, image: params.image.blob }], 84 + }; 85 + } 86 + 87 + const res = await agent.com.atproto.repo.createRecord({ 88 + repo: agent.did!, 89 + collection: 'app.bsky.feed.post', 90 + record, 91 + }); 92 + 93 + return { uri: res.data.uri, cid: res.data.cid }; 94 + } 95 + 96 + // --------------------------------------------------------------------------- 97 + // Post — Reply 98 + // --------------------------------------------------------------------------- 99 + 100 + export interface ReplyParams { 101 + parentUri: string; 102 + text: string; 103 + langs?: string[]; 104 + } 105 + 106 + /** 107 + * Reply to a post. Walks the parent record to discover the thread root — 108 + * if the parent is itself a reply, we reuse its `reply.root`; otherwise 109 + * the parent IS the root. 110 + */ 111 + export async function replyToPost( 112 + agent: Agent, 113 + params: ReplyParams, 114 + ): Promise<CreateResult> { 115 + const parentRef = parsePostUri(params.parentUri); 116 + const parent = await fetchPostRef(agent, parentRef); 117 + 118 + const parentStrongRef: StrongRef = { uri: parent.uri, cid: parent.cid }; 119 + const parentReply = (parent.value as { reply?: { root?: StrongRef } }).reply; 120 + const root: StrongRef = parentReply?.root ?? parentStrongRef; 121 + 122 + const rt = await buildRichText(agent, params.text); 123 + 124 + const record = { 125 + $type: 'app.bsky.feed.post', 126 + ...rt, 127 + langs: 128 + params.langs && params.langs.length > 0 ? params.langs : ['en'], 129 + reply: { root, parent: parentStrongRef }, 130 + createdAt: new Date().toISOString(), 131 + }; 132 + 133 + const res = await agent.com.atproto.repo.createRecord({ 134 + repo: agent.did!, 135 + collection: 'app.bsky.feed.post', 136 + record, 137 + }); 138 + 139 + return { uri: res.data.uri, cid: res.data.cid }; 140 + } 141 + 142 + // --------------------------------------------------------------------------- 143 + // Post — Quote 144 + // --------------------------------------------------------------------------- 145 + 146 + export interface QuoteParams { 147 + quotedUri: string; 148 + text: string; 149 + langs?: string[]; 150 + } 151 + 152 + export async function quotePost( 153 + agent: Agent, 154 + params: QuoteParams, 155 + ): Promise<CreateResult> { 156 + const ref = parsePostUri(params.quotedUri); 157 + const subject = await fetchPostRef(agent, ref); 158 + 159 + const rt = await buildRichText(agent, params.text); 160 + 161 + const record = { 162 + $type: 'app.bsky.feed.post', 163 + ...rt, 164 + langs: 165 + params.langs && params.langs.length > 0 ? params.langs : ['en'], 166 + embed: { 167 + $type: 'app.bsky.embed.record', 168 + record: { uri: subject.uri, cid: subject.cid }, 169 + }, 170 + createdAt: new Date().toISOString(), 171 + }; 172 + 173 + const res = await agent.com.atproto.repo.createRecord({ 174 + repo: agent.did!, 175 + collection: 'app.bsky.feed.post', 176 + record, 177 + }); 178 + 179 + return { uri: res.data.uri, cid: res.data.cid }; 180 + } 181 + 182 + // --------------------------------------------------------------------------- 183 + // Engagement — Like / Repost 184 + // --------------------------------------------------------------------------- 185 + 186 + async function createSubjectRef( 187 + agent: Agent, 188 + postUri: string, 189 + collection: 'app.bsky.feed.like' | 'app.bsky.feed.repost', 190 + ): Promise<CreateResult> { 191 + const ref = parsePostUri(postUri); 192 + const subject = await fetchPostRef(agent, ref); 193 + 194 + const res = await agent.com.atproto.repo.createRecord({ 195 + repo: agent.did!, 196 + collection, 197 + record: { 198 + $type: collection, 199 + subject: { uri: subject.uri, cid: subject.cid }, 200 + createdAt: new Date().toISOString(), 201 + }, 202 + }); 203 + 204 + return { uri: res.data.uri, cid: res.data.cid }; 205 + } 206 + 207 + export function likePost( 208 + agent: Agent, 209 + postUri: string, 210 + ): Promise<CreateResult> { 211 + return createSubjectRef(agent, postUri, 'app.bsky.feed.like'); 212 + } 213 + 214 + export function repostPost( 215 + agent: Agent, 216 + postUri: string, 217 + ): Promise<CreateResult> { 218 + return createSubjectRef(agent, postUri, 'app.bsky.feed.repost'); 219 + } 220 + 221 + // --------------------------------------------------------------------------- 222 + // Graph — Follow 223 + // --------------------------------------------------------------------------- 224 + 225 + /** 226 + * Follow a user. Accepts either a handle (`alice.bsky.social`) or a DID. 227 + * Handles are resolved via `com.atproto.identity.resolveHandle` first. 228 + */ 229 + export async function followUser( 230 + agent: Agent, 231 + handleOrDid: string, 232 + ): Promise<CreateResult> { 233 + const trimmed = handleOrDid.trim(); 234 + let did = trimmed; 235 + if (!did.startsWith('did:')) { 236 + // Strip leading @ if the user typed @alice.bsky.social 237 + const handle = did.startsWith('@') ? did.slice(1) : did; 238 + const resolved = await agent.com.atproto.identity.resolveHandle({ 239 + handle, 240 + }); 241 + did = resolved.data.did; 242 + } 243 + 244 + const res = await agent.com.atproto.repo.createRecord({ 245 + repo: agent.did!, 246 + collection: 'app.bsky.graph.follow', 247 + record: { 248 + $type: 'app.bsky.graph.follow', 249 + subject: did, 250 + createdAt: new Date().toISOString(), 251 + }, 252 + }); 253 + 254 + return { uri: res.data.uri, cid: res.data.cid }; 255 + }
+74
src/nodes/Bluesky/postUri.ts
··· 1 + /** 2 + * Parse a Bluesky post reference into its repo/collection/rkey parts. 3 + * 4 + * Accepts either an AT-URI or an HTTPS bsky.app URL: 5 + * at://did:plc:abc.../app.bsky.feed.post/3jzfcijpj2z2a 6 + * https://bsky.app/profile/handle.bsky.social/post/3jzfcijpj2z2a 7 + * 8 + * Returns `{ repo, collection, rkey }` where `repo` is a DID or handle. 9 + * For the HTTPS form, `repo` is the handle from the URL — call 10 + * `agent.com.atproto.identity.resolveHandle` if you need a DID. 11 + */ 12 + 13 + import type { Agent } from '@atproto/api'; 14 + 15 + export interface PostRef { 16 + repo: string; 17 + collection: string; 18 + rkey: string; 19 + } 20 + 21 + const AT_URI_RE = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/; 22 + const BSKY_URL_RE = 23 + /^https?:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/; 24 + 25 + export function parsePostUri(input: string): PostRef { 26 + const trimmed = input.trim(); 27 + 28 + const atMatch = trimmed.match(AT_URI_RE); 29 + if (atMatch) { 30 + return { repo: atMatch[1], collection: atMatch[2], rkey: atMatch[3] }; 31 + } 32 + 33 + const urlMatch = trimmed.match(BSKY_URL_RE); 34 + if (urlMatch) { 35 + return { 36 + repo: urlMatch[1], 37 + collection: 'app.bsky.feed.post', 38 + rkey: urlMatch[2], 39 + }; 40 + } 41 + 42 + throw new Error( 43 + `Could not parse post reference '${input}'. Expected an at:// URI or a https://bsky.app/profile/.../post/... URL.`, 44 + ); 45 + } 46 + 47 + /** 48 + * Resolve a `PostRef` to its actual `{ uri, cid }` by calling getRecord. 49 + * Handles the HTTPS-URL case where `repo` is a handle (resolves to DID first). 50 + */ 51 + export async function fetchPostRef( 52 + agent: Agent, 53 + ref: PostRef, 54 + ): Promise<{ uri: string; cid: string; value: Record<string, unknown> }> { 55 + let repo = ref.repo; 56 + if (!repo.startsWith('did:')) { 57 + const resolved = await agent.com.atproto.identity.resolveHandle({ 58 + handle: repo, 59 + }); 60 + repo = resolved.data.did; 61 + } 62 + 63 + const res = await agent.com.atproto.repo.getRecord({ 64 + repo, 65 + collection: ref.collection, 66 + rkey: ref.rkey, 67 + }); 68 + 69 + return { 70 + uri: res.data.uri, 71 + cid: res.data.cid ?? '', 72 + value: res.data.value as Record<string, unknown>, 73 + }; 74 + }
+92
tests/bluesky.test.ts
··· 1 + /** 2 + * Tests for the Bluesky convenience node. 3 + * 4 + * Covers: post-URI parsing, language list normalization, 5 + * rich-text facet detection round-trip. 6 + */ 7 + 8 + import { describe, it, expect } from 'vitest'; 9 + import { RichText } from '@atproto/api'; 10 + 11 + import { parsePostUri } from '../src/nodes/Bluesky/postUri'; 12 + 13 + describe('parsePostUri', () => { 14 + it('parses a canonical at:// URI', () => { 15 + const ref = parsePostUri( 16 + 'at://did:plc:abc123/app.bsky.feed.post/3jzfcijpj2z2a', 17 + ); 18 + expect(ref).toEqual({ 19 + repo: 'did:plc:abc123', 20 + collection: 'app.bsky.feed.post', 21 + rkey: '3jzfcijpj2z2a', 22 + }); 23 + }); 24 + 25 + it('parses a bsky.app URL', () => { 26 + const ref = parsePostUri( 27 + 'https://bsky.app/profile/alice.bsky.social/post/3jzfcijpj2z2a', 28 + ); 29 + expect(ref).toEqual({ 30 + repo: 'alice.bsky.social', 31 + collection: 'app.bsky.feed.post', 32 + rkey: '3jzfcijpj2z2a', 33 + }); 34 + }); 35 + 36 + it('strips a trailing query string from bsky.app URLs', () => { 37 + const ref = parsePostUri( 38 + 'https://bsky.app/profile/alice.bsky.social/post/3jzfcijpj2z2a?ref=foo', 39 + ); 40 + expect(ref.rkey).toBe('3jzfcijpj2z2a'); 41 + }); 42 + 43 + it('trims whitespace from input', () => { 44 + const ref = parsePostUri( 45 + ' at://did:plc:abc123/app.bsky.feed.post/3jzfcijpj2z2a\n', 46 + ); 47 + expect(ref.repo).toBe('did:plc:abc123'); 48 + }); 49 + 50 + it('throws on garbage input', () => { 51 + expect(() => parsePostUri('not a uri')).toThrow(/Could not parse/); 52 + }); 53 + 54 + it('throws on a URL pointing to a non-bsky host', () => { 55 + expect(() => 56 + parsePostUri('https://example.com/profile/x/post/y'), 57 + ).toThrow(); 58 + }); 59 + }); 60 + 61 + describe('rich-text facet detection (without resolution)', () => { 62 + it('detects mentions, links and tags with correct byte offsets', () => { 63 + const rt = new RichText({ 64 + text: 'Hello @alice.bsky.social check https://example.com #cool', 65 + }); 66 + rt.detectFacetsWithoutResolution(); 67 + 68 + const features = (rt.facets ?? []) 69 + .flatMap((f) => f.features.map((feat) => feat.$type)) 70 + .sort(); 71 + 72 + expect(features).toEqual([ 73 + 'app.bsky.richtext.facet#link', 74 + 'app.bsky.richtext.facet#mention', 75 + 'app.bsky.richtext.facet#tag', 76 + ]); 77 + 78 + // Verify offsets are byte-based, not character-based — emoji test 79 + const rt2 = new RichText({ 80 + text: '\uD83D\uDE0E #vibes', // 😎 is 4 UTF-8 bytes 81 + }); 82 + rt2.detectFacetsWithoutResolution(); 83 + const tag = rt2.facets?.[0]; 84 + expect(tag?.index.byteStart).toBe(5); // 4 bytes for emoji + 1 space 85 + }); 86 + 87 + it('returns empty facets for plain text', () => { 88 + const rt = new RichText({ text: 'Just some plain text.' }); 89 + rt.detectFacetsWithoutResolution(); 90 + expect(rt.facets ?? []).toHaveLength(0); 91 + }); 92 + });
+2
vite.config.build.ts
··· 19 19 entry: { 20 20 'nodes/Atproto/Atproto.node': resolve(__dirname, 'src/nodes/Atproto/Atproto.node.ts'), 21 21 'nodes/Atproto/AtprotoJetstreamTrigger.node': resolve(__dirname, 'src/nodes/Atproto/AtprotoJetstreamTrigger.node.ts'), 22 + 'nodes/Bluesky/Bluesky.node': resolve(__dirname, 'src/nodes/Bluesky/Bluesky.node.ts'), 22 23 'credentials/AtprotoApi.credentials': resolve( 23 24 __dirname, 24 25 'src/credentials/AtprotoApi.credentials.ts', ··· 53 54 const staticPatterns = [ 54 55 { src: 'src/nodes/Atproto/atproto.svg', dest: 'dist/nodes/Atproto/atproto.svg' }, 55 56 { src: 'src/nodes/Atproto/zstd_dictionary', dest: 'dist/nodes/Atproto/zstd_dictionary' }, 57 + { src: 'src/nodes/Bluesky/bluesky.svg', dest: 'dist/nodes/Bluesky/bluesky.svg' }, 56 58 ]; 57 59 for (const { src, dest } of staticPatterns) { 58 60 mkdirSync(resolve(__dirname, dest, '..'), { recursive: true });