Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

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

feat: post on bluesky action

Hugo b6108c7e 09473794

+1248 -17
+130 -3
app/islands/AutomationForm.tsx
··· 26 26 recordTemplate: string; 27 27 comment: string; 28 28 }; 29 - type ActionDraft = WebhookDraft | RecordDraft; 29 + type BskyPostDraft = { 30 + type: "bsky-post"; 31 + textTemplate: string; 32 + langs: string[]; 33 + labels: string[]; 34 + comment: string; 35 + }; 36 + type ActionDraft = WebhookDraft | RecordDraft | BskyPostDraft; 30 37 31 38 export type AutomationInitial = { 32 39 rkey?: string; ··· 235 242 } 236 243 237 244 // --------------------------------------------------------------------------- 245 + // Bluesky Post action editor 246 + // --------------------------------------------------------------------------- 247 + 248 + const BSKY_LABELS = [ 249 + { value: "sexual", label: "Suggestive" }, 250 + { value: "nudity", label: "Nudity" }, 251 + { value: "porn", label: "Adult Content" }, 252 + { value: "graphic-media", label: "Graphic Media" }, 253 + ]; 254 + 255 + function BskyPostActionEditor({ 256 + action, 257 + onChange, 258 + }: { 259 + action: BskyPostDraft; 260 + onChange: (a: BskyPostDraft) => void; 261 + }) { 262 + const [langsText, setLangsText] = useState(action.langs.join(", ")); 263 + 264 + return ( 265 + <> 266 + <div class={s.fieldGroup}> 267 + <label class={s.label}>Post text</label> 268 + <textarea 269 + class={s.textarea} 270 + placeholder={"Write your post here...\nYou can use {{placeholders}}."} 271 + value={action.textTemplate} 272 + onInput={(e: Event) => 273 + onChange({ ...action, textTemplate: (e.target as HTMLTextAreaElement).value }) 274 + } 275 + rows={4} 276 + required 277 + /> 278 + <span class={s.hint}> 279 + Mentions (@handle), links, and #hashtags are detected automatically. 280 + </span> 281 + </div> 282 + 283 + <div class={s.fieldGroup}> 284 + <label class={s.label}> 285 + Languages <span class={s.hint}>(optional, max 3)</span> 286 + </label> 287 + <input 288 + class={s.input} 289 + type="text" 290 + placeholder="e.g. en, fr, pt" 291 + value={langsText} 292 + onInput={(e: Event) => setLangsText((e.target as HTMLInputElement).value)} 293 + onBlur={() => { 294 + const langs = langsText 295 + .split(",") 296 + .map((l) => l.trim()) 297 + .filter(Boolean); 298 + setLangsText(langs.join(", ")); 299 + onChange({ ...action, langs }); 300 + }} 301 + /> 302 + <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 303 + </div> 304 + 305 + <div class={s.fieldGroup}> 306 + <span class={s.label}> 307 + Content warnings <span class={s.hint}>(optional)</span> 308 + </span> 309 + <div class={s.operationCheckboxes}> 310 + {BSKY_LABELS.map(({ value, label }) => ( 311 + <label key={value} class={s.checkboxLabel}> 312 + <input 313 + type="checkbox" 314 + class={s.checkbox} 315 + checked={action.labels.includes(value)} 316 + onChange={() => { 317 + const labels = action.labels.includes(value) 318 + ? action.labels.filter((l) => l !== value) 319 + : [...action.labels, value]; 320 + onChange({ ...action, labels }); 321 + }} 322 + /> 323 + {label} 324 + </label> 325 + ))} 326 + </div> 327 + </div> 328 + </> 329 + ); 330 + } 331 + 332 + // --------------------------------------------------------------------------- 238 333 // Copy-to-clipboard placeholder 239 334 // --------------------------------------------------------------------------- 240 335 ··· 273 368 return actions.map((a) => { 274 369 if (a.$type === "webhook") { 275 370 return { type: "webhook", callbackUrl: a.callbackUrl, comment: a.comment ?? "" }; 371 + } 372 + if (a.$type === "bsky-post") { 373 + return { 374 + type: "bsky-post", 375 + textTemplate: a.textTemplate, 376 + langs: a.langs ?? [], 377 + labels: a.labels ?? [], 378 + comment: a.comment ?? "", 379 + }; 276 380 } 277 381 return { 278 382 type: "record", ··· 458 562 setFetches((prev) => prev.map((f, i) => (i === index ? { ...f, [key]: val } : f))); 459 563 }, []); 460 564 461 - const addAction = useCallback((type: "webhook" | "record") => { 565 + const addAction = useCallback((type: "webhook" | "record" | "bsky-post") => { 462 566 if (type === "webhook") { 463 567 setActions((prev) => [...prev, { type: "webhook", callbackUrl: "", comment: "" }]); 568 + } else if (type === "bsky-post") { 569 + setActions((prev) => [ 570 + ...prev, 571 + { type: "bsky-post", textTemplate: "", langs: [], labels: [], comment: "" }, 572 + ]); 464 573 } else { 465 574 setActions((prev) => [ 466 575 ...prev, ··· 500 609 payload.actions = actions.map((a) => { 501 610 const comment = a.comment ? { comment: a.comment } : {}; 502 611 if (a.type === "webhook") return { type: "webhook", callbackUrl: a.callbackUrl, ...comment }; 612 + if (a.type === "bsky-post") { 613 + return { 614 + type: "bsky-post", 615 + textTemplate: a.textTemplate, 616 + ...(a.langs.length > 0 ? { langs: a.langs } : {}), 617 + ...(a.labels.length > 0 ? { labels: a.labels } : {}), 618 + ...comment, 619 + }; 620 + } 503 621 return { 504 622 type: "record", 505 623 targetCollection: a.targetCollection, ··· 897 1015 <div key={i} class={s.actionCard}> 898 1016 <div class={s.actionHeader}> 899 1017 <span class={s.actionTitle}> 900 - {action.type === "webhook" ? "Webhook" : "Record"}{" "} 1018 + {action.type === "webhook" 1019 + ? "Webhook" 1020 + : action.type === "bsky-post" 1021 + ? "Bluesky Post" 1022 + : "Record"}{" "} 901 1023 {actions.filter((a, j) => a.type === action.type && j <= i).length} 902 1024 </span> 903 1025 <button type="button" class={s.removeBtn} onClick={() => removeAction(i)}> ··· 906 1028 </div> 907 1029 {action.type === "webhook" ? ( 908 1030 <WebhookActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 1031 + ) : action.type === "bsky-post" ? ( 1032 + <BskyPostActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 909 1033 ) : ( 910 1034 <RecordActionEditor 911 1035 action={action} ··· 934 1058 </button> 935 1059 <button type="button" class={s.addBtn} onClick={() => addAction("record")}> 936 1060 + Add record action 1061 + </button> 1062 + <button type="button" class={s.addBtn} onClick={() => addAction("bsky-post")}> 1063 + + Add Bluesky post 937 1064 </button> 938 1065 </div> 939 1066 </div>
+62 -2
app/routes/api/automations/[rkey].ts
··· 8 8 type Action, 9 9 type WebhookAction, 10 10 type RecordAction, 11 + type BskyPostAction, 11 12 type FetchStep, 12 13 } from "@/db/schema.js"; 13 14 import { isValidNsid } from "@/lexicons/resolver.js"; ··· 20 21 } from "@/automations/pds.js"; 21 22 import { verifyCallback } from "@/automations/verify.js"; 22 23 import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 23 - import { validateTemplate, validateFetchStep } from "@/actions/template.js"; 24 + import { validateTemplate, validateTextTemplate, validateFetchStep } from "@/actions/template.js"; 24 25 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 25 26 26 27 type ActionInput = 27 28 | { type: "webhook"; callbackUrl: string; comment?: string } 28 - | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 29 + | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 30 + | { 31 + type: "bsky-post"; 32 + textTemplate: string; 33 + langs?: string[]; 34 + labels?: string[]; 35 + comment?: string; 36 + }; 29 37 30 38 const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 31 39 const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 40 + const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 41 + const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 32 42 33 43 function findAutomation(did: string, rkey: string) { 34 44 return db.query.automations.findFirst({ ··· 266 276 recordTemplate: input.recordTemplate, 267 277 ...(input.comment ? { comment: input.comment } : {}), 268 278 }); 279 + } else if (input.type === "bsky-post") { 280 + if (!input.textTemplate || !input.textTemplate.trim()) { 281 + return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 282 + } 283 + const textValidation = validateTextTemplate(input.textTemplate, fetchNames); 284 + if (!textValidation.valid) { 285 + return c.json({ error: textValidation.error }, 400); 286 + } 287 + if (input.langs && input.langs.length > 3) { 288 + return c.json({ error: "Maximum 3 languages allowed" }, 400); 289 + } 290 + if (input.langs?.some((l) => !BCP47_RE.test(l))) { 291 + return c.json( 292 + { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 293 + 400, 294 + ); 295 + } 296 + if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 297 + return c.json( 298 + { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 299 + 400, 300 + ); 301 + } 302 + 303 + const langs = input.langs?.filter(Boolean); 304 + const labels = input.labels?.filter(Boolean); 305 + 306 + newLocalActions.push({ 307 + $type: "bsky-post", 308 + textTemplate: input.textTemplate, 309 + ...(langs && langs.length > 0 ? { langs } : {}), 310 + ...(labels && labels.length > 0 ? { labels } : {}), 311 + ...(input.comment ? { comment: input.comment } : {}), 312 + } satisfies BskyPostAction); 313 + newPdsActions.push({ 314 + $type: "run.airglow.automation#bskyPostAction", 315 + textTemplate: input.textTemplate, 316 + ...(langs && langs.length > 0 ? { langs } : {}), 317 + ...(labels && labels.length > 0 ? { labels } : {}), 318 + ...(input.comment ? { comment: input.comment } : {}), 319 + }); 269 320 } else { 270 321 return c.json({ error: "Invalid action type" }, 400); 271 322 } ··· 302 353 return { 303 354 $type: "run.airglow.automation#webhookAction", 304 355 callbackUrl: a.callbackUrl, 356 + ...(a.comment ? { comment: a.comment } : {}), 357 + }; 358 + } 359 + if (a.$type === "bsky-post") { 360 + return { 361 + $type: "run.airglow.automation#bskyPostAction", 362 + textTemplate: a.textTemplate, 363 + ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}), 364 + ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}), 305 365 ...(a.comment ? { comment: a.comment } : {}), 306 366 }; 307 367 }
+53 -2
app/routes/api/automations/index.ts
··· 7 7 type Action, 8 8 type WebhookAction, 9 9 type RecordAction, 10 + type BskyPostAction, 10 11 type FetchStep, 11 12 } from "@/db/schema.js"; 12 13 import { config } from "@/config.js"; ··· 19 20 type PdsAction, 20 21 type PdsFetchStep, 21 22 } from "@/automations/pds.js"; 22 - import { validateTemplate, validateFetchStep } from "@/actions/template.js"; 23 + import { validateTemplate, validateTextTemplate, validateFetchStep } from "@/actions/template.js"; 23 24 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 24 25 25 26 type ActionInput = 26 27 | { type: "webhook"; callbackUrl: string; comment?: string } 27 - | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 28 + | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 29 + | { 30 + type: "bsky-post"; 31 + textTemplate: string; 32 + langs?: string[]; 33 + labels?: string[]; 34 + comment?: string; 35 + }; 28 36 29 37 const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 30 38 const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 39 + const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 40 + const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 31 41 32 42 export const GET = createRoute(async (c) => { 33 43 const user = c.get("user"); ··· 219 229 $type: "run.airglow.automation#recordAction", 220 230 targetCollection: input.targetCollection, 221 231 recordTemplate: input.recordTemplate, 232 + ...(input.comment ? { comment: input.comment } : {}), 233 + }); 234 + } else if (input.type === "bsky-post") { 235 + if (!input.textTemplate || !input.textTemplate.trim()) { 236 + return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 237 + } 238 + const textValidation = validateTextTemplate(input.textTemplate, fetchNames); 239 + if (!textValidation.valid) { 240 + return c.json({ error: textValidation.error }, 400); 241 + } 242 + if (input.langs && input.langs.length > 3) { 243 + return c.json({ error: "Maximum 3 languages allowed" }, 400); 244 + } 245 + if (input.langs?.some((l) => !BCP47_RE.test(l))) { 246 + return c.json( 247 + { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 248 + 400, 249 + ); 250 + } 251 + if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 252 + return c.json( 253 + { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 254 + 400, 255 + ); 256 + } 257 + 258 + const langs = input.langs?.filter(Boolean); 259 + const labels = input.labels?.filter(Boolean); 260 + 261 + localActions.push({ 262 + $type: "bsky-post", 263 + textTemplate: input.textTemplate, 264 + ...(langs && langs.length > 0 ? { langs } : {}), 265 + ...(labels && labels.length > 0 ? { labels } : {}), 266 + ...(input.comment ? { comment: input.comment } : {}), 267 + } satisfies BskyPostAction); 268 + pdsActions.push({ 269 + $type: "run.airglow.automation#bskyPostAction", 270 + textTemplate: input.textTemplate, 271 + ...(langs && langs.length > 0 ? { langs } : {}), 272 + ...(labels && labels.length > 0 ? { labels } : {}), 222 273 ...(input.comment ? { comment: input.comment } : {}), 223 274 }); 224 275 } else {
+25 -1
app/routes/dashboard/automations/[rkey].tsx
··· 166 166 <Card key={i} variant="flat"> 167 167 <Stack gap={2}> 168 168 <h4> 169 - {action.$type === "webhook" ? "Webhook" : "Record"} {i + 1} 169 + {action.$type === "webhook" 170 + ? "Webhook" 171 + : action.$type === "bsky-post" 172 + ? "Bluesky Post" 173 + : "Record"}{" "} 174 + {i + 1} 170 175 {action.$type === "webhook" && ( 171 176 <> 172 177 {" "} ··· 188 193 <dd> 189 194 <InlineCode>{action.secret}</InlineCode> 190 195 </dd> 196 + </> 197 + ) : action.$type === "bsky-post" ? ( 198 + <> 199 + <dt>Text Template</dt> 200 + <dd> 201 + <CodeBlock>{action.textTemplate}</CodeBlock> 202 + </dd> 203 + {action.langs && action.langs.length > 0 && ( 204 + <> 205 + <dt>Languages</dt> 206 + <dd>{action.langs.join(", ")}</dd> 207 + </> 208 + )} 209 + {action.labels && action.labels.length > 0 && ( 210 + <> 211 + <dt>Content Warnings</dt> 212 + <dd>{action.labels.join(", ")}</dd> 213 + </> 214 + )} 191 215 </> 192 216 ) : ( 193 217 <>
+19 -1
app/routes/u/[handle]/[rkey].tsx
··· 203 203 <Card key={i} variant="flat"> 204 204 <Stack gap={2}> 205 205 <h4> 206 - {action.$type === "webhook" ? "Webhook" : "Record"} {i + 1} 206 + {action.$type === "webhook" 207 + ? "Webhook" 208 + : action.$type === "bsky-post" 209 + ? "Bluesky Post" 210 + : "Record"}{" "} 211 + {i + 1} 207 212 {action.$type === "webhook" && ( 208 213 <> 209 214 {" "} ··· 221 226 <dd> 222 227 <InlineCode>{action.callbackDomain}</InlineCode> 223 228 </dd> 229 + </> 230 + ) : action.$type === "bsky-post" ? ( 231 + <> 232 + <dt>Text Template</dt> 233 + <dd> 234 + <CodeBlock>{action.textTemplate}</CodeBlock> 235 + </dd> 236 + {action.langs && action.langs.length > 0 && ( 237 + <> 238 + <dt>Languages</dt> 239 + <dd>{action.langs.join(", ")}</dd> 240 + </> 241 + )} 224 242 </> 225 243 ) : ( 226 244 <>
+185
lib/actions/bsky-post.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + vi.mock("@/db/index.js", async () => { 4 + const { createTestDb } = await import("../test/db.js"); 5 + return { db: createTestDb() }; 6 + }); 7 + 8 + vi.mock("@/automations/pds.js", () => ({ 9 + createArbitraryRecord: vi.fn(), 10 + })); 11 + 12 + vi.mock("./richtext.js", () => ({ 13 + detectFacets: vi.fn(), 14 + })); 15 + 16 + import { executeBskyPost } from "./bsky-post.js"; 17 + import { createArbitraryRecord } from "../automations/pds.js"; 18 + import { detectFacets } from "./richtext.js"; 19 + import { db } from "../db/index.js"; 20 + import { automations, deliveryLogs } from "../db/schema.js"; 21 + import { makeMatch, makeBskyPostAction, makeAutomation } from "../test/fixtures.js"; 22 + 23 + const mockCreateRecord = vi.mocked(createArbitraryRecord); 24 + const mockDetectFacets = vi.mocked(detectFacets); 25 + 26 + describe("executeBskyPost", () => { 27 + beforeEach(async () => { 28 + vi.useFakeTimers(); 29 + vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 30 + mockCreateRecord.mockReset(); 31 + mockDetectFacets.mockReset(); 32 + mockDetectFacets.mockResolvedValue([]); 33 + 34 + await db.delete(deliveryLogs); 35 + await db.delete(automations); 36 + await db.insert(automations).values(makeAutomation()); 37 + }); 38 + 39 + afterEach(() => { 40 + vi.useRealTimers(); 41 + }); 42 + 43 + it("renders text, detects facets, and creates post on PDS", async () => { 44 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 45 + mockDetectFacets.mockResolvedValueOnce([ 46 + { 47 + index: { byteStart: 0, byteEnd: 5 }, 48 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "hello" }], 49 + }, 50 + ]); 51 + 52 + const action = makeBskyPostAction({ textTemplate: "#hello from {{event.did}}" }); 53 + const match = makeMatch({ automation: { actions: [action] } }); 54 + await executeBskyPost(match, 0); 55 + 56 + expect(mockDetectFacets).toHaveBeenCalledWith("#hello from did:plc:testuser123"); 57 + expect(mockCreateRecord).toHaveBeenCalledWith( 58 + match.automation.did, 59 + "app.bsky.feed.post", 60 + expect.objectContaining({ 61 + text: "#hello from did:plc:testuser123", 62 + createdAt: "2024-06-15T12:00:00.000Z", 63 + facets: [ 64 + { 65 + index: { byteStart: 0, byteEnd: 5 }, 66 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "hello" }], 67 + }, 68 + ], 69 + }), 70 + ); 71 + 72 + const logs = await db.query.deliveryLogs.findMany(); 73 + expect(logs).toHaveLength(1); 74 + expect(logs[0]!.statusCode).toBe(200); 75 + }); 76 + 77 + it("omits facets key when none detected", async () => { 78 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 79 + mockDetectFacets.mockResolvedValueOnce([]); 80 + 81 + const action = makeBskyPostAction({ textTemplate: "plain text" }); 82 + const match = makeMatch({ automation: { actions: [action] } }); 83 + await executeBskyPost(match, 0); 84 + 85 + const record = mockCreateRecord.mock.calls[0]![2]!; 86 + expect(record).not.toHaveProperty("facets"); 87 + }); 88 + 89 + it("includes langs when provided", async () => { 90 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 91 + 92 + const action = makeBskyPostAction({ textTemplate: "Bonjour", langs: ["fr"] }); 93 + const match = makeMatch({ automation: { actions: [action] } }); 94 + await executeBskyPost(match, 0); 95 + 96 + const record = mockCreateRecord.mock.calls[0]![2]!; 97 + expect(record.langs).toEqual(["fr"]); 98 + }); 99 + 100 + it("includes labels as selfLabels structure", async () => { 101 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 102 + 103 + const action = makeBskyPostAction({ textTemplate: "content", labels: ["sexual"] }); 104 + const match = makeMatch({ automation: { actions: [action] } }); 105 + await executeBskyPost(match, 0); 106 + 107 + const record = mockCreateRecord.mock.calls[0]![2]!; 108 + expect(record.labels).toEqual({ 109 + $type: "com.atproto.label.defs#selfLabels", 110 + values: [{ val: "sexual" }], 111 + }); 112 + }); 113 + 114 + it("omits langs and labels when empty", async () => { 115 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 116 + 117 + const action = makeBskyPostAction({ textTemplate: "text", langs: [], labels: [] }); 118 + const match = makeMatch({ automation: { actions: [action] } }); 119 + await executeBskyPost(match, 0); 120 + 121 + const record = mockCreateRecord.mock.calls[0]![2]!; 122 + expect(record).not.toHaveProperty("langs"); 123 + expect(record).not.toHaveProperty("labels"); 124 + }); 125 + 126 + it("logs template rendering error with status 0", async () => { 127 + // Force a template rendering error by using an event with missing data 128 + // renderTextTemplate won't throw for missing placeholders (returns ""), 129 + // so we test the facet detection error path instead 130 + mockDetectFacets.mockRejectedValueOnce(new Error("Facet detection failed")); 131 + 132 + const action = makeBskyPostAction(); 133 + const match = makeMatch({ automation: { actions: [action] } }); 134 + await executeBskyPost(match, 0); 135 + 136 + expect(mockCreateRecord).not.toHaveBeenCalled(); 137 + const logs = await db.query.deliveryLogs.findMany(); 138 + expect(logs).toHaveLength(1); 139 + expect(logs[0]!.statusCode).toBe(0); 140 + expect(logs[0]!.error).toContain("Facet detection error"); 141 + }); 142 + 143 + it("extracts status code from PDS error message", async () => { 144 + mockCreateRecord.mockRejectedValueOnce( 145 + new Error("PDS com.atproto.repo.createRecord failed (400): bad request"), 146 + ); 147 + 148 + const action = makeBskyPostAction(); 149 + const match = makeMatch({ automation: { actions: [action] } }); 150 + await executeBskyPost(match, 0); 151 + 152 + const logs = await db.query.deliveryLogs.findMany(); 153 + expect(logs).toHaveLength(1); 154 + expect(logs[0]!.statusCode).toBe(400); 155 + }); 156 + 157 + it("retries on 5xx PDS errors", async () => { 158 + mockCreateRecord 159 + .mockRejectedValueOnce(new Error("PDS failed (500): internal")) 160 + .mockResolvedValueOnce({ uri: "at://x/col/rk", cid: "c" }); 161 + 162 + const action = makeBskyPostAction(); 163 + const match = makeMatch({ automation: { actions: [action] } }); 164 + await executeBskyPost(match, 0); 165 + 166 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 167 + 168 + await vi.advanceTimersByTimeAsync(5_000); 169 + expect(mockCreateRecord).toHaveBeenCalledTimes(2); 170 + 171 + const logs = await db.query.deliveryLogs.findMany(); 172 + expect(logs).toHaveLength(2); 173 + }); 174 + 175 + it("does not retry on 4xx PDS errors", async () => { 176 + mockCreateRecord.mockRejectedValueOnce(new Error("PDS failed (400): bad request")); 177 + 178 + const action = makeBskyPostAction(); 179 + const match = makeMatch({ automation: { actions: [action] } }); 180 + await executeBskyPost(match, 0); 181 + 182 + await vi.advanceTimersByTimeAsync(60_000); 183 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 184 + }); 185 + });
+153
lib/actions/bsky-post.ts
··· 1 + import { db } from "../db/index.js"; 2 + import { deliveryLogs, type BskyPostAction } from "../db/schema.js"; 3 + import { createArbitraryRecord } from "../automations/pds.js"; 4 + import { renderTextTemplate, type FetchContext } from "./template.js"; 5 + import { detectFacets } from "./richtext.js"; 6 + import type { MatchedEvent } from "../jetstream/consumer.js"; 7 + 8 + const TARGET_COLLECTION = "app.bsky.feed.post"; 9 + const RETRY_DELAYS = [5_000, 30_000]; 10 + 11 + async function execute( 12 + match: MatchedEvent, 13 + action: BskyPostAction, 14 + fetchContext?: FetchContext, 15 + ): Promise<{ statusCode: number; error?: string }> { 16 + const { automation, event } = match; 17 + 18 + let text: string; 19 + try { 20 + text = renderTextTemplate(action.textTemplate, event, fetchContext, automation.did); 21 + } catch (err) { 22 + return { 23 + statusCode: 0, 24 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 25 + }; 26 + } 27 + 28 + let facets; 29 + try { 30 + facets = await detectFacets(text); 31 + } catch (err) { 32 + return { 33 + statusCode: 0, 34 + error: `Facet detection error: ${err instanceof Error ? err.message : String(err)}`, 35 + }; 36 + } 37 + 38 + const record: Record<string, unknown> = { 39 + text, 40 + createdAt: new Date().toISOString(), 41 + }; 42 + 43 + if (facets.length > 0) { 44 + record.facets = facets; 45 + } 46 + if (action.langs && action.langs.length > 0) { 47 + record.langs = action.langs; 48 + } 49 + if (action.labels && action.labels.length > 0) { 50 + record.labels = { 51 + $type: "com.atproto.label.defs#selfLabels", 52 + values: action.labels.map((val) => ({ val })), 53 + }; 54 + } 55 + 56 + try { 57 + await createArbitraryRecord(automation.did, TARGET_COLLECTION, record); 58 + return { statusCode: 200 }; 59 + } catch (err) { 60 + const message = err instanceof Error ? err.message : String(err); 61 + const statusMatch = message.match(/\((\d{3})\)/); 62 + const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 63 + return { statusCode, error: message }; 64 + } 65 + } 66 + 67 + async function logDelivery( 68 + automationUri: string, 69 + actionIndex: number, 70 + eventTimeUs: number, 71 + payload: string | null, 72 + statusCode: number, 73 + error: string | null, 74 + attempt: number, 75 + ) { 76 + await db.insert(deliveryLogs).values({ 77 + automationUri, 78 + actionIndex, 79 + eventTimeUs, 80 + payload, 81 + statusCode, 82 + error, 83 + attempt, 84 + createdAt: new Date(), 85 + }); 86 + } 87 + 88 + function isSuccess(code: number): boolean { 89 + return code === 200; 90 + } 91 + 92 + function isRetryable(code: number): boolean { 93 + return code >= 500 || code === 0; 94 + } 95 + 96 + function scheduleRetry( 97 + match: MatchedEvent, 98 + actionIndex: number, 99 + retryIndex: number, 100 + fetchContext?: FetchContext, 101 + ) { 102 + if (retryIndex >= RETRY_DELAYS.length) return; 103 + 104 + setTimeout(async () => { 105 + try { 106 + const action = match.automation.actions[actionIndex] as BskyPostAction; 107 + const result = await execute(match, action, fetchContext); 108 + const body = JSON.stringify({ textTemplate: action.textTemplate }); 109 + 110 + await logDelivery( 111 + match.automation.uri, 112 + actionIndex, 113 + match.event.time_us, 114 + isSuccess(result.statusCode) ? null : body, 115 + result.statusCode, 116 + result.error ?? null, 117 + retryIndex + 2, 118 + ); 119 + 120 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 121 + scheduleRetry(match, actionIndex, retryIndex + 1, fetchContext); 122 + } 123 + } catch (err) { 124 + console.error("Bsky-post retry error:", err); 125 + } 126 + }, RETRY_DELAYS[retryIndex]); 127 + } 128 + 129 + /** Execute a bsky-post action for a matched event. */ 130 + export async function executeBskyPost( 131 + match: MatchedEvent, 132 + actionIndex: number, 133 + fetchContext?: FetchContext, 134 + ) { 135 + const action = match.automation.actions[actionIndex] as BskyPostAction; 136 + const result = await execute(match, action, fetchContext); 137 + 138 + const body = JSON.stringify({ textTemplate: action.textTemplate }); 139 + 140 + await logDelivery( 141 + match.automation.uri, 142 + actionIndex, 143 + match.event.time_us, 144 + isSuccess(result.statusCode) ? null : body, 145 + result.statusCode, 146 + result.error ?? null, 147 + 1, 148 + ); 149 + 150 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 151 + scheduleRetry(match, actionIndex, 0, fetchContext); 152 + } 153 + }
+229
lib/actions/richtext.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { detectFacets } from "./richtext.js"; 3 + 4 + const encoder = new TextEncoder(); 5 + 6 + function byteLen(s: string): number { 7 + return encoder.encode(s).byteLength; 8 + } 9 + 10 + function byteOffset(text: string, charIndex: number): number { 11 + return encoder.encode(text.slice(0, charIndex)).byteLength; 12 + } 13 + 14 + // Mock global fetch for handle resolution 15 + const mockFetch = vi.fn(); 16 + 17 + beforeEach(() => { 18 + vi.stubGlobal("fetch", mockFetch); 19 + mockFetch.mockReset(); 20 + }); 21 + 22 + afterEach(() => { 23 + vi.unstubAllGlobals(); 24 + }); 25 + 26 + function mockResolveHandle(handle: string, did: string) { 27 + mockFetch.mockImplementation(async (url: string) => { 28 + if (url.includes(`handle=${encodeURIComponent(handle)}`)) { 29 + return new Response(JSON.stringify({ did }), { status: 200 }); 30 + } 31 + return new Response("", { status: 404 }); 32 + }); 33 + } 34 + 35 + function mockResolveHandles(map: Record<string, string>) { 36 + mockFetch.mockImplementation(async (url: string) => { 37 + for (const [handle, did] of Object.entries(map)) { 38 + if (url.includes(`handle=${encodeURIComponent(handle)}`)) { 39 + return new Response(JSON.stringify({ did }), { status: 200 }); 40 + } 41 + } 42 + return new Response("", { status: 404 }); 43 + }); 44 + } 45 + 46 + describe("detectFacets", () => { 47 + describe("mentions", () => { 48 + it("detects a mention and resolves its DID", async () => { 49 + mockResolveHandle("alice.bsky.social", "did:plc:alice"); 50 + const text = "Hello @alice.bsky.social!"; 51 + const facets = await detectFacets(text); 52 + 53 + expect(facets).toHaveLength(1); 54 + expect(facets[0]!.features[0]).toEqual({ 55 + $type: "app.bsky.richtext.facet#mention", 56 + did: "did:plc:alice", 57 + }); 58 + expect(facets[0]!.index.byteStart).toBe(byteOffset(text, 6)); 59 + expect(facets[0]!.index.byteEnd).toBe(byteOffset(text, 6 + "@alice.bsky.social".length)); 60 + }); 61 + 62 + it("skips unresolvable mentions", async () => { 63 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 64 + const facets = await detectFacets("Hi @nonexistent.handle.xyz"); 65 + expect(facets).toHaveLength(0); 66 + }); 67 + 68 + it("detects multiple mentions", async () => { 69 + mockResolveHandles({ 70 + "alice.bsky.social": "did:plc:alice", 71 + "bob.test": "did:plc:bob", 72 + }); 73 + const facets = await detectFacets("@alice.bsky.social and @bob.test"); 74 + expect(facets).toHaveLength(2); 75 + expect(facets[0]!.features[0]).toEqual({ 76 + $type: "app.bsky.richtext.facet#mention", 77 + did: "did:plc:alice", 78 + }); 79 + expect(facets[1]!.features[0]).toEqual({ 80 + $type: "app.bsky.richtext.facet#mention", 81 + did: "did:plc:bob", 82 + }); 83 + }); 84 + 85 + it("does not match bare @word without a dot", async () => { 86 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 87 + const facets = await detectFacets("Hello @alice"); 88 + expect(facets).toHaveLength(0); 89 + expect(mockFetch).not.toHaveBeenCalled(); 90 + }); 91 + }); 92 + 93 + describe("links", () => { 94 + it("detects an https link", async () => { 95 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 96 + const text = "Check https://example.com/page"; 97 + const facets = await detectFacets(text); 98 + 99 + expect(facets).toHaveLength(1); 100 + expect(facets[0]!.features[0]).toEqual({ 101 + $type: "app.bsky.richtext.facet#link", 102 + uri: "https://example.com/page", 103 + }); 104 + const linkStart = text.indexOf("https://"); 105 + expect(facets[0]!.index.byteStart).toBe(byteOffset(text, linkStart)); 106 + expect(facets[0]!.index.byteEnd).toBe( 107 + byteOffset(text, linkStart + "https://example.com/page".length), 108 + ); 109 + }); 110 + 111 + it("strips trailing punctuation from links", async () => { 112 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 113 + const facets = await detectFacets("Visit https://example.com."); 114 + expect(facets).toHaveLength(1); 115 + expect(facets[0]!.features[0]).toEqual({ 116 + $type: "app.bsky.richtext.facet#link", 117 + uri: "https://example.com", 118 + }); 119 + }); 120 + 121 + it("prepends https:// to bare domains", async () => { 122 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 123 + const facets = await detectFacets("Go to example.com/path"); 124 + expect(facets).toHaveLength(1); 125 + expect((facets[0]!.features[0] as any).uri).toBe("https://example.com/path"); 126 + }); 127 + }); 128 + 129 + describe("hashtags", () => { 130 + it("detects a hashtag", async () => { 131 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 132 + const text = "Hello #world"; 133 + const facets = await detectFacets(text); 134 + 135 + expect(facets).toHaveLength(1); 136 + expect(facets[0]!.features[0]).toEqual({ 137 + $type: "app.bsky.richtext.facet#tag", 138 + tag: "world", 139 + }); 140 + const tagStart = text.indexOf("#"); 141 + expect(facets[0]!.index.byteStart).toBe(byteOffset(text, tagStart)); 142 + expect(facets[0]!.index.byteEnd).toBe(byteOffset(text, tagStart + "#world".length)); 143 + }); 144 + 145 + it("strips trailing punctuation from hashtags", async () => { 146 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 147 + const facets = await detectFacets("Check #coding!"); 148 + expect(facets).toHaveLength(1); 149 + expect((facets[0]!.features[0] as any).tag).toBe("coding"); 150 + }); 151 + 152 + it("does not match # followed by digit", async () => { 153 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 154 + const facets = await detectFacets("Issue #123"); 155 + expect(facets).toHaveLength(0); 156 + }); 157 + 158 + it("skips tags longer than 66 characters", async () => { 159 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 160 + const longTag = "#" + "a".repeat(66); 161 + const facets = await detectFacets(longTag); 162 + expect(facets).toHaveLength(0); 163 + }); 164 + }); 165 + 166 + describe("UTF-8 byte offsets", () => { 167 + it("computes correct byte offsets with emoji", async () => { 168 + mockResolveHandle("alice.bsky.social", "did:plc:alice"); 169 + // 🌍 is U+1F30D = 4 UTF-8 bytes, but 2 UTF-16 code units 170 + const text = "🌍 @alice.bsky.social"; 171 + const facets = await detectFacets(text); 172 + 173 + expect(facets).toHaveLength(1); 174 + // "🌍 " is 4 + 1 = 5 bytes in UTF-8 175 + expect(facets[0]!.index.byteStart).toBe(5); 176 + expect(facets[0]!.index.byteEnd).toBe(5 + byteLen("@alice.bsky.social")); 177 + }); 178 + 179 + it("computes correct byte offsets with CJK characters", async () => { 180 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 181 + // Each CJK char is 3 UTF-8 bytes 182 + const text = "你好 #test"; 183 + const facets = await detectFacets(text); 184 + 185 + expect(facets).toHaveLength(1); 186 + // "你好 " = 3+3+1 = 7 bytes 187 + expect(facets[0]!.index.byteStart).toBe(7); 188 + expect(facets[0]!.index.byteEnd).toBe(7 + byteLen("#test")); 189 + }); 190 + }); 191 + 192 + describe("mixed content", () => { 193 + it("detects mentions, links, and tags in the same text", async () => { 194 + mockResolveHandle("alice.bsky.social", "did:plc:alice"); 195 + const text = "Hey @alice.bsky.social check https://example.com #cool"; 196 + const facets = await detectFacets(text); 197 + 198 + const types = facets.map((f) => f.features[0]!.$type); 199 + expect(types).toContain("app.bsky.richtext.facet#mention"); 200 + expect(types).toContain("app.bsky.richtext.facet#link"); 201 + expect(types).toContain("app.bsky.richtext.facet#tag"); 202 + }); 203 + 204 + it("removes overlapping facets (first wins)", async () => { 205 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 206 + // A URL that contains what looks like a domain 207 + const text = "Visit https://example.com"; 208 + const facets = await detectFacets(text); 209 + 210 + // Should not have duplicate/overlapping facets for the same URL 211 + const starts = facets.map((f) => f.index.byteStart); 212 + const unique = new Set(starts); 213 + expect(starts.length).toBe(unique.size); 214 + }); 215 + }); 216 + 217 + describe("empty and no-match input", () => { 218 + it("returns empty array for plain text", async () => { 219 + mockFetch.mockResolvedValue(new Response("", { status: 404 })); 220 + const facets = await detectFacets("Just some plain text."); 221 + expect(facets).toEqual([]); 222 + }); 223 + 224 + it("returns empty array for empty string", async () => { 225 + const facets = await detectFacets(""); 226 + expect(facets).toEqual([]); 227 + }); 228 + }); 229 + });
+196
lib/actions/richtext.ts
··· 1 + /** 2 + * Bluesky rich-text facet detection. 3 + * 4 + * Detects mentions (@handle), links (URLs), and hashtags (#tag) in plain text, 5 + * computes UTF-8 byte indices, and resolves mention handles to DIDs. 6 + * 7 + * Reference: https://docs.bsky.app/docs/advanced-guides/post-richtext 8 + */ 9 + 10 + const encoder = new TextEncoder(); 11 + 12 + // --- Regex patterns (from Bluesky reference implementation) --- 13 + 14 + const MENTION_RE = 15 + /(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 16 + const LINK_RE = /(^|\s|\()((https?:\/\/[\S]+)|(([a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim; 17 + const TAG_RE = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g; 18 + 19 + // Trailing punctuation to strip from URLs 20 + const TRAILING_PUNCT_RE = /[.,;:!?)"'\]]+$/; 21 + 22 + export type Facet = { 23 + index: { byteStart: number; byteEnd: number }; 24 + features: Array< 25 + | { $type: "app.bsky.richtext.facet#mention"; did: string } 26 + | { $type: "app.bsky.richtext.facet#link"; uri: string } 27 + | { $type: "app.bsky.richtext.facet#tag"; tag: string } 28 + >; 29 + }; 30 + 31 + /** Get UTF-8 byte offset for a character index in the string. */ 32 + function utf8ByteOffset(text: string, charIndex: number): number { 33 + return encoder.encode(text.slice(0, charIndex)).byteLength; 34 + } 35 + 36 + /** Resolve a Bluesky handle to a DID. Returns null on failure. */ 37 + async function resolveHandle(handle: string): Promise<string | null> { 38 + try { 39 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 40 + const res = await fetch(url); 41 + if (!res.ok) return null; 42 + const data = (await res.json()) as { did?: string }; 43 + return data.did ?? null; 44 + } catch { 45 + return null; 46 + } 47 + } 48 + 49 + /** Detect mention facets. Resolves handles to DIDs (skips unresolvable). */ 50 + async function detectMentions(text: string): Promise<Facet[]> { 51 + const facets: Facet[] = []; 52 + const promises: Array<{ 53 + handle: string; 54 + matchStart: number; 55 + matchEnd: number; 56 + promise: Promise<string | null>; 57 + }> = []; 58 + 59 + for (const match of text.matchAll(MENTION_RE)) { 60 + const fullMatch = match[0]; 61 + const offset = match.index; 62 + 63 + // The match may include a leading space/paren — find where '@' starts 64 + const atIndex = fullMatch.indexOf("@"); 65 + const mentionStart = offset + atIndex; 66 + const handle = fullMatch.slice(atIndex + 1); // without '@' 67 + const mentionEnd = mentionStart + 1 + handle.length; // +1 for '@' 68 + 69 + promises.push({ 70 + handle, 71 + matchStart: mentionStart, 72 + matchEnd: mentionEnd, 73 + promise: resolveHandle(handle), 74 + }); 75 + } 76 + 77 + const results = await Promise.all(promises.map((p) => p.promise)); 78 + for (let i = 0; i < promises.length; i++) { 79 + const did = results[i]; 80 + if (!did) continue; 81 + const { matchStart, matchEnd } = promises[i]!; 82 + facets.push({ 83 + index: { 84 + byteStart: utf8ByteOffset(text, matchStart), 85 + byteEnd: utf8ByteOffset(text, matchEnd), 86 + }, 87 + features: [{ $type: "app.bsky.richtext.facet#mention", did }], 88 + }); 89 + } 90 + 91 + return facets; 92 + } 93 + 94 + /** Detect link facets. */ 95 + function detectLinks(text: string): Facet[] { 96 + const facets: Facet[] = []; 97 + 98 + for (const match of text.matchAll(LINK_RE)) { 99 + const fullMatch = match[0]; 100 + const offset = match.index; 101 + 102 + // Skip leading whitespace/paren that's part of the match 103 + const leadingWs = fullMatch.match(/^[\s(]*/)?.[0] ?? ""; 104 + const linkStart = offset + leadingWs.length; 105 + let link = fullMatch.slice(leadingWs.length); 106 + 107 + // Strip trailing punctuation 108 + link = link.replace(TRAILING_PUNCT_RE, ""); 109 + 110 + // Ensure URI has protocol 111 + let uri = link; 112 + if (!uri.startsWith("http://") && !uri.startsWith("https://")) { 113 + uri = `https://${uri}`; 114 + } 115 + 116 + // Validate it looks like a real URL 117 + try { 118 + new URL(uri); 119 + } catch { 120 + continue; 121 + } 122 + 123 + const linkEnd = linkStart + link.length; 124 + 125 + facets.push({ 126 + index: { 127 + byteStart: utf8ByteOffset(text, linkStart), 128 + byteEnd: utf8ByteOffset(text, linkEnd), 129 + }, 130 + features: [{ $type: "app.bsky.richtext.facet#link", uri }], 131 + }); 132 + } 133 + 134 + return facets; 135 + } 136 + 137 + /** Detect hashtag facets. */ 138 + function detectTags(text: string): Facet[] { 139 + const facets: Facet[] = []; 140 + 141 + for (const match of text.matchAll(TAG_RE)) { 142 + let tag = match[1]!; 143 + const offset = match.index; 144 + 145 + // The match includes leading whitespace — find where '#' starts 146 + const hashStart = offset + match[0].indexOf("#"); 147 + 148 + // Strip trailing punctuation from tag 149 + tag = tag.replace(TRAILING_PUNCT_RE, ""); 150 + 151 + // Max 64 chars for tag value (excluding #), 66 total 152 + if (tag.length > 66) continue; 153 + 154 + const hashEnd = hashStart + tag.length; 155 + const tagValue = tag.slice(1); // without '#' 156 + 157 + if (!tagValue) continue; 158 + 159 + facets.push({ 160 + index: { 161 + byteStart: utf8ByteOffset(text, hashStart), 162 + byteEnd: utf8ByteOffset(text, hashEnd), 163 + }, 164 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: tagValue }], 165 + }); 166 + } 167 + 168 + return facets; 169 + } 170 + 171 + /** 172 + * Detect all rich-text facets in a post text. 173 + * Resolves mention handles to DIDs (async). Unresolvable mentions are skipped. 174 + */ 175 + export async function detectFacets(text: string): Promise<Facet[]> { 176 + const [mentions, links, tags] = await Promise.all([ 177 + detectMentions(text), 178 + Promise.resolve(detectLinks(text)), 179 + Promise.resolve(detectTags(text)), 180 + ]); 181 + 182 + const all = [...mentions, ...links, ...tags]; 183 + // Sort by byteStart, discard overlapping facets 184 + all.sort((a, b) => a.index.byteStart - b.index.byteStart); 185 + 186 + const result: Facet[] = []; 187 + let lastEnd = -1; 188 + for (const facet of all) { 189 + if (facet.index.byteStart >= lastEnd) { 190 + result.push(facet); 191 + lastEnd = facet.index.byteEnd; 192 + } 193 + } 194 + 195 + return result; 196 + }
+90
lib/actions/template.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 2 import { 3 3 validateTemplate, 4 + validateTextTemplate, 4 5 validateFetchStep, 5 6 validateFetchUri, 6 7 renderTemplate, 8 + renderTextTemplate, 7 9 resolveEventPlaceholder, 8 10 } from "./template.js"; 9 11 import { makeEvent } from "../test/fixtures.js"; ··· 245 247 expect(resolveEventPlaceholder("profile.record.name", event)).toBeUndefined(); 246 248 }); 247 249 }); 250 + 251 + describe("validateTextTemplate", () => { 252 + it("returns valid for plain text with placeholders", () => { 253 + const result = validateTextTemplate("Hello {{event.did}}"); 254 + expect(result).toEqual({ valid: true, placeholders: ["event.did"] }); 255 + }); 256 + 257 + it("returns valid for text without placeholders (static post)", () => { 258 + const result = validateTextTemplate("Just a static post"); 259 + expect(result).toEqual({ valid: true, placeholders: [] }); 260 + }); 261 + 262 + it("rejects empty template", () => { 263 + const result = validateTextTemplate(""); 264 + expect(result).toEqual({ valid: false, error: "Text template is required" }); 265 + }); 266 + 267 + it("rejects whitespace-only template", () => { 268 + const result = validateTextTemplate(" "); 269 + expect(result).toEqual({ valid: false, error: "Text template is required" }); 270 + }); 271 + 272 + it("rejects unknown placeholder roots", () => { 273 + const result = validateTextTemplate("Value: {{unknown.field}}"); 274 + expect(result).toEqual({ valid: false, error: "Invalid placeholder: {{unknown.field}}" }); 275 + }); 276 + 277 + it("accepts fetch name placeholders when fetchNames provided", () => { 278 + const result = validateTextTemplate("Name: {{profile.record.displayName}}", ["profile"]); 279 + expect(result).toEqual({ valid: true, placeholders: ["profile.record.displayName"] }); 280 + }); 281 + }); 282 + 283 + describe("renderTextTemplate", () => { 284 + beforeEach(() => { 285 + vi.useFakeTimers(); 286 + vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 287 + }); 288 + 289 + afterEach(() => { 290 + vi.useRealTimers(); 291 + }); 292 + 293 + const event = makeEvent({ 294 + did: "did:plc:user1", 295 + commit: { 296 + rev: "r1", 297 + operation: "create", 298 + collection: "app.bsky.feed.post", 299 + rkey: "rk1", 300 + record: { text: "hello", count: 5 }, 301 + cid: "bafycid", 302 + }, 303 + }); 304 + 305 + it("renders placeholders as plain text", () => { 306 + const result = renderTextTemplate("Post by {{event.did}}", event); 307 + expect(result).toBe("Post by did:plc:user1"); 308 + }); 309 + 310 + it("renders {{now}} as ISO datetime", () => { 311 + const result = renderTextTemplate("Created at {{now}}", event); 312 + expect(result).toBe("Created at 2024-06-15T12:00:00.000Z"); 313 + }); 314 + 315 + it("renders {{self}} as ownerDid", () => { 316 + const result = renderTextTemplate("By {{self}}", event, undefined, "did:plc:owner"); 317 + expect(result).toBe("By did:plc:owner"); 318 + }); 319 + 320 + it("renders undefined placeholders as empty string", () => { 321 + const result = renderTextTemplate("Value: {{event.commit.record.missing}}", event); 322 + expect(result).toBe("Value: "); 323 + }); 324 + 325 + it("does not JSON-escape text (no backslash or quote escaping)", () => { 326 + const fetchContext = { 327 + data: { uri: "at://x", cid: "c", record: { name: 'He said "hi"' } }, 328 + }; 329 + const result = renderTextTemplate("{{data.record.name}}", event, fetchContext); 330 + expect(result).toBe('He said "hi"'); 331 + }); 332 + 333 + it("renders text with no placeholders as-is", () => { 334 + const result = renderTextTemplate("Just a static post", event); 335 + expect(result).toBe("Just a static post"); 336 + }); 337 + });
+42
lib/actions/template.ts
··· 163 163 return { valid: true, placeholders }; 164 164 } 165 165 166 + /** Validate a plain-text template (for bsky-post actions). */ 167 + export function validateTextTemplate( 168 + template: string, 169 + fetchNames?: string[], 170 + ): { valid: true; placeholders: string[] } | { valid: false; error: string } { 171 + if (!template.trim()) { 172 + return { valid: false, error: "Text template is required" }; 173 + } 174 + 175 + const placeholders: string[] = []; 176 + template.replace(PLACEHOLDER_RE, (_, path: string) => { 177 + placeholders.push(path.trim()); 178 + return ""; 179 + }); 180 + 181 + const fetchSet = new Set(fetchNames ?? []); 182 + for (const p of placeholders) { 183 + if (p === "now" || p === "self" || p.startsWith("event.")) continue; 184 + const root = p.split(".")[0]!; 185 + if (fetchSet.has(root)) continue; 186 + return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 187 + } 188 + 189 + return { valid: true, placeholders }; 190 + } 191 + 192 + /** Render a plain-text template (for bsky-post actions). */ 193 + export function renderTextTemplate( 194 + template: string, 195 + event: JetstreamEvent, 196 + fetchContext?: FetchContext, 197 + ownerDid?: string, 198 + ): string { 199 + return template.replace(PLACEHOLDER_RE, (_match, path: string) => { 200 + const value = resolvePlaceholder(path.trim(), event, fetchContext, ownerDid); 201 + if (value === undefined) return ""; 202 + if (typeof value === "string") return value; 203 + if (typeof value === "number" || typeof value === "boolean") return String(value); 204 + return JSON.stringify(value); 205 + }); 206 + } 207 + 166 208 /** Render a template by resolving all {{placeholder}} expressions against event data. */ 167 209 export function renderTemplate( 168 210 template: string,
+9 -1
lib/automations/pds.ts
··· 44 44 comment?: string; 45 45 }; 46 46 47 - export type PdsAction = PdsWebhookAction | PdsRecordAction; 47 + type PdsBskyPostAction = { 48 + $type: "run.airglow.automation#bskyPostAction"; 49 + textTemplate: string; 50 + langs?: string[]; 51 + labels?: string[]; 52 + comment?: string; 53 + }; 54 + 55 + export type PdsAction = PdsWebhookAction | PdsRecordAction | PdsBskyPostAction; 48 56 49 57 export type PdsFetchStep = { 50 58 $type: "run.airglow.automation#fetchStep";
+8 -1
lib/automations/sanitize.ts
··· 2 2 3 3 export type PublicAction = 4 4 | { $type: "webhook"; callbackDomain: string; verified?: boolean; comment?: string } 5 - | { $type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 5 + | { $type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 6 + | { 7 + $type: "bsky-post"; 8 + textTemplate: string; 9 + langs?: string[]; 10 + labels?: string[]; 11 + comment?: string; 12 + }; 6 13 7 14 /** Strip instance-local secrets and truncate webhook URLs to domain-only. */ 8 15 export function sanitizeActions(actions: Action[]): PublicAction[] {
+9 -1
lib/db/schema.ts
··· 23 23 comment?: string; 24 24 }; 25 25 26 - export type Action = WebhookAction | RecordAction; 26 + export type BskyPostAction = { 27 + $type: "bsky-post"; 28 + textTemplate: string; 29 + langs?: string[]; 30 + labels?: string[]; 31 + comment?: string; 32 + }; 33 + 34 + export type Action = WebhookAction | RecordAction | BskyPostAction; 27 35 28 36 export type FetchStep = { 29 37 name: string;
+23 -4
lib/jetstream/handler.ts
··· 1 1 import { db } from "../db/index.js"; 2 - import { deliveryLogs, type WebhookAction, type RecordAction } from "../db/schema.js"; 2 + import { deliveryLogs, type Action } from "../db/schema.js"; 3 3 import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 4 import { executeAction } from "../actions/executor.js"; 5 + import { executeBskyPost } from "../actions/bsky-post.js"; 5 6 import { resolveFetches } from "../actions/fetcher.js"; 6 - import { renderTemplate, type FetchContext } from "../actions/template.js"; 7 + import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 7 8 import type { MatchedEvent } from "./consumer.js"; 8 9 9 10 /** Handle a matched Jetstream event: resolve fetches, then dispatch all actions. */ ··· 38 39 39 40 for (let i = 0; i < match.automation.actions.length; i++) { 40 41 const action = match.automation.actions[i]!; 41 - const handler = action.$type === "record" ? executeAction : dispatch; 42 + const handler = 43 + action.$type === "bsky-post" 44 + ? executeBskyPost 45 + : action.$type === "record" 46 + ? executeAction 47 + : dispatch; 42 48 handler(match, i, fetchContext).catch((err) => { 43 49 console.error(`Action ${i} (${action.$type}) delivery error:`, err); 44 50 }); ··· 48 54 async function logDryRun( 49 55 match: MatchedEvent, 50 56 actionIndex: number, 51 - action: WebhookAction | RecordAction, 57 + action: Action, 52 58 fetchContext: FetchContext, 53 59 failedFetches: string[], 54 60 ) { ··· 61 67 } else if (action.$type === "webhook") { 62 68 message = `Would POST to ${action.callbackUrl}`; 63 69 payload = JSON.stringify(buildPayload(match, fetchContext)); 70 + } else if (action.$type === "bsky-post") { 71 + try { 72 + const text = renderTextTemplate( 73 + action.textTemplate, 74 + match.event, 75 + fetchContext, 76 + match.automation.did, 77 + ); 78 + message = `Would post to Bluesky`; 79 + payload = JSON.stringify({ text, langs: action.langs, labels: action.labels }); 80 + } catch (err) { 81 + error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 82 + } 64 83 } else { 65 84 try { 66 85 const rendered = renderTemplate(
+15 -1
lib/test/fixtures.ts
··· 1 1 import type { JetstreamEvent } from "../jetstream/matcher.js"; 2 - import type { Action, WebhookAction, RecordAction, FetchStep } from "../db/schema.js"; 2 + import type { 3 + Action, 4 + WebhookAction, 5 + RecordAction, 6 + BskyPostAction, 7 + FetchStep, 8 + } from "../db/schema.js"; 3 9 import type { MatchedEvent } from "../jetstream/consumer.js"; 4 10 5 11 type Automation = { ··· 54 60 $type: "record", 55 61 targetCollection: "app.bsky.feed.post", 56 62 recordTemplate: '{"text":"{{event.did}}","createdAt":"{{now}}"}', 63 + ...overrides, 64 + }; 65 + } 66 + 67 + export function makeBskyPostAction(overrides?: Partial<BskyPostAction>): BskyPostAction { 68 + return { 69 + $type: "bsky-post", 70 + textTemplate: "Post by {{event.did}}", 57 71 ...overrides, 58 72 }; 59 73 }