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.

refactor: better action placeholder renaming

Hugo e58cfd8a 0f4be8e5

+59 -28
+10 -8
app/islands/AutomationForm.tsx
··· 437 437 {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 438 438 <textarea 439 439 class={s.textarea} 440 - placeholder={'{\n "bskyPostRef": "{{action0.uri}}",\n "updatedAt": "{{now}}"\n}'} 440 + placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 441 441 value={action.recordTemplate} 442 442 onInput={(e: Event) => 443 443 onChange({ ··· 815 815 ); 816 816 817 817 const actionResultPlaceholders = actions.flatMap((a, i) => 818 - isRecordProducingAction(a.type) ? [`action${i}.uri`, `action${i}.cid`, `action${i}.rkey`] : [], 818 + isRecordProducingAction(a.type) 819 + ? [`action${i + 1}.uri`, `action${i + 1}.cid`, `action${i + 1}.rkey`] 820 + : [], 819 821 ); 820 822 821 823 const allPlaceholders = [ ··· 994 996 {actions.map((a, i) => 995 997 isRecordProducingAction(a.type) ? ( 996 998 <div key={i}> 997 - <CopyPlaceholder value={`action${i}.uri`}> 998 - <span class={s.placeholderDesc}>AT URI from action {i + 1}</span> 999 + <CopyPlaceholder value={`action${i + 1}.uri`}> 1000 + <span class={s.placeholderDesc}>AT URI from Action {i + 1}</span> 999 1001 </CopyPlaceholder> 1000 - <CopyPlaceholder value={`action${i}.cid`}> 1001 - <span class={s.placeholderDesc}>Content hash from action {i + 1}</span> 1002 + <CopyPlaceholder value={`action${i + 1}.cid`}> 1003 + <span class={s.placeholderDesc}>Content hash from Action {i + 1}</span> 1002 1004 </CopyPlaceholder> 1003 - <CopyPlaceholder value={`action${i}.rkey`}> 1004 - <span class={s.placeholderDesc}>Record key from action {i + 1}</span> 1005 + <CopyPlaceholder value={`action${i + 1}.rkey`}> 1006 + <span class={s.placeholderDesc}>Record key from Action {i + 1}</span> 1005 1007 </CopyPlaceholder> 1006 1008 </div> 1007 1009 ) : null,
+3 -3
app/routes/api/automations/[rkey].ts
··· 278 278 recordTemplate: input.recordTemplate, 279 279 ...(input.comment ? { comment: input.comment } : {}), 280 280 }); 281 - actionResultNames.push(`action${actionIndex}`); 281 + actionResultNames.push(`action${actionIndex + 1}`); 282 282 } else if (input.type === "bsky-post") { 283 283 if (!input.textTemplate || !input.textTemplate.trim()) { 284 284 return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); ··· 324 324 ...(labels && labels.length > 0 ? { labels } : {}), 325 325 ...(input.comment ? { comment: input.comment } : {}), 326 326 }); 327 - actionResultNames.push(`action${actionIndex}`); 327 + actionResultNames.push(`action${actionIndex + 1}`); 328 328 } else if (input.type === "patch-record") { 329 329 if (!input.targetCollection) { 330 330 return c.json({ error: "targetCollection is required for patch-record actions" }, 400); ··· 369 369 recordTemplate: input.recordTemplate, 370 370 ...(input.comment ? { comment: input.comment } : {}), 371 371 }); 372 - actionResultNames.push(`action${actionIndex}`); 372 + actionResultNames.push(`action${actionIndex + 1}`); 373 373 } else { 374 374 return c.json({ error: "Invalid action type" }, 400); 375 375 }
+3 -3
app/routes/api/automations/index.ts
··· 233 233 recordTemplate: input.recordTemplate, 234 234 ...(input.comment ? { comment: input.comment } : {}), 235 235 }); 236 - actionResultNames.push(`action${actionIndex}`); 236 + actionResultNames.push(`action${actionIndex + 1}`); 237 237 } else if (input.type === "bsky-post") { 238 238 if (!input.textTemplate || !input.textTemplate.trim()) { 239 239 return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); ··· 279 279 ...(labels && labels.length > 0 ? { labels } : {}), 280 280 ...(input.comment ? { comment: input.comment } : {}), 281 281 }); 282 - actionResultNames.push(`action${actionIndex}`); 282 + actionResultNames.push(`action${actionIndex + 1}`); 283 283 } else if (input.type === "patch-record") { 284 284 if (!input.targetCollection) { 285 285 return c.json({ error: "targetCollection is required for patch-record actions" }, 400); ··· 324 324 recordTemplate: input.recordTemplate, 325 325 ...(input.comment ? { comment: input.comment } : {}), 326 326 }); 327 - actionResultNames.push(`action${actionIndex}`); 327 + actionResultNames.push(`action${actionIndex + 1}`); 328 328 } else { 329 329 return c.json({ error: "Invalid action type" }, 400); 330 330 }
+3 -3
app/routes/dashboard/automations/[rkey].tsx
··· 62 62 description={auto.description ?? undefined} 63 63 actions={ 64 64 <div class={inlineCluster}> 65 + <Button href="/dashboard" variant="ghost" size="sm"> 66 + <ArrowLeft size={14} /> Back 67 + </Button> 65 68 <span data-automation-status> 66 69 <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 67 70 {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} ··· 75 78 </Button> 76 79 <Button href={`/u/${user.handle}/${rkey}`} variant="ghost" size="sm"> 77 80 <ExternalLink size={14} /> Public page 78 - </Button> 79 - <Button href="/dashboard" variant="ghost" size="sm"> 80 - <ArrowLeft size={14} /> Back 81 81 </Button> 82 82 </div> 83 83 }
+3 -3
app/routes/u/[handle]/[rkey].tsx
··· 96 96 description={auto.description ?? undefined} 97 97 actions={ 98 98 <div class={inlineCluster}> 99 + <Button href={`/u/${handle}`} variant="ghost" size="sm"> 100 + <ArrowLeft size={14} /> @{handle} 101 + </Button> 99 102 <span> 100 103 <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 101 104 {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} ··· 119 122 Sign in to use 120 123 </Button> 121 124 )} 122 - <Button href={`/u/${handle}`} variant="ghost" size="sm"> 123 - <ArrowLeft size={14} /> @{handle} 124 - </Button> 125 125 </div> 126 126 } 127 127 />
+29
lib/favicon.ts
··· 56 56 } 57 57 } 58 58 59 + /** Known favicon URLs for domains that don't serve at standard paths. */ 60 + const KNOWN_FAVICONS: Record<string, string> = { 61 + "bsky.app": "https://web-cdn.bsky.app/static/favicon-32x32.png", 62 + }; 63 + 64 + async function fetchKnownFavicon( 65 + domain: string, 66 + ): Promise<{ data: Buffer; contentType: string } | null> { 67 + const url = KNOWN_FAVICONS[domain]; 68 + if (!url) return null; 69 + try { 70 + const res = await fetch(url, { 71 + signal: AbortSignal.timeout(FETCH_TIMEOUT), 72 + redirect: "error", 73 + }); 74 + if (!res.ok) return null; 75 + const ct = res.headers.get("content-type") ?? ""; 76 + if (!ct.startsWith("image/")) return null; 77 + const buf = Buffer.from(await res.arrayBuffer()); 78 + if (buf.length === 0 || buf.length > MAX_SIZE) return null; 79 + return { data: buf, contentType: ct.split(";")[0]! }; 80 + } catch { 81 + return null; 82 + } 83 + } 84 + 59 85 async function fetchFavicon(domain: string): Promise<{ data: Buffer; contentType: string } | null> { 86 + const known = await fetchKnownFavicon(domain); 87 + if (known) return known; 88 + 60 89 const ip = await resolveSafeIP(domain); 61 90 if (!ip) return null; 62 91
+5 -5
lib/jetstream/handler.test.ts
··· 202 202 203 203 await handleMatchedEvent(match); 204 204 205 - // Second action should receive fetchContext with action0 result 205 + // Second action should receive fetchContext with action1 result 206 206 expect(mockExecuteBskyPost).toHaveBeenCalledWith( 207 207 match, 208 208 1, 209 209 expect.objectContaining({ 210 - action0: { 210 + action1: { 211 211 uri: okWithUri.uri, 212 212 cid: okWithUri.cid, 213 213 rkey: "abc123", ··· 229 229 230 230 expect(mockDispatch).toHaveBeenCalledOnce(); 231 231 expect(mockExecuteAction).toHaveBeenCalledOnce(); 232 - // fetchContext should NOT contain action0 (webhook has no uri/cid) 233 - // but it does contain action1 from the record action (mutated after call) 232 + // fetchContext should NOT contain action1 (webhook has no uri/cid) 233 + // but it does contain action2 from the record action (mutated after call) 234 234 // So we verify the record action result is present but webhook result is not 235 235 const finalCtx = mockExecuteAction.mock.calls[0]![2]!; 236 - expect(finalCtx).not.toHaveProperty("action0"); 236 + expect(finalCtx).not.toHaveProperty("action1"); 237 237 }); 238 238 });
+2 -2
lib/jetstream/handler.ts
··· 33 33 await logDryRun(match, i, action, fetchContext, fetchErrors); 34 34 // Inject synthetic action result so subsequent dry-run actions can reference {{actionN.*}} 35 35 if (isRecordProducingAction(action.$type)) { 36 - fetchContext[`action${i}`] = { 36 + fetchContext[`action${i + 1}`] = { 37 37 uri: `at://dry-run/${action.$type}/placeholder`, 38 38 cid: "dry-run-cid", 39 39 rkey: "placeholder", ··· 61 61 // Accumulate result into fetchContext for downstream actions 62 62 if (result.uri && result.cid) { 63 63 const rkey = result.uri.split("/").pop() ?? ""; 64 - fetchContext[`action${i}`] = { uri: result.uri, cid: result.cid, rkey, record: {} }; 64 + fetchContext[`action${i + 1}`] = { uri: result.uri, cid: result.cid, rkey, record: {} }; 65 65 } 66 66 67 67 // Fail-fast: stop chain on error
+1 -1
lib/test/fixtures.ts
··· 78 78 $type: "patch-record", 79 79 targetCollection: "site.standard.document", 80 80 baseRecordUri: "at://{{event.did}}/{{event.commit.collection}}/{{event.commit.rkey}}", 81 - recordTemplate: '{"bskyPostRef":"{{action0.uri}}","updatedAt":"{{now}}"}', 81 + recordTemplate: '{"bskyPostRef":"{{action1.uri}}","updatedAt":"{{now}}"}', 82 82 ...overrides, 83 83 }; 84 84 }