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: automation metadata in templates

Hugo 192ebf93 6e8d1fc0

+149 -30
+16
app/islands/AutomationForm.tsx
··· 75 75 "event.commit.rkey", 76 76 "event.commit.cid", 77 77 "event.commit.operation", 78 + "automation.id", 79 + "automation.name", 80 + "automation.url", 78 81 "now", 79 82 ]; 80 83 ··· 1112 1115 )} 1113 1116 </div> 1114 1117 )} 1118 + 1119 + <div class={s.placeholderGroup}> 1120 + <div class={s.placeholderGroupTitle}>Automation</div> 1121 + <CopyPlaceholder value="automation.id"> 1122 + <span class={s.placeholderDesc}>Record key of this automation</span> 1123 + </CopyPlaceholder> 1124 + <CopyPlaceholder value="automation.name"> 1125 + <span class={s.placeholderDesc}>Name of this automation</span> 1126 + </CopyPlaceholder> 1127 + <CopyPlaceholder value="automation.url"> 1128 + <span class={s.placeholderDesc}>Public URL of this automation</span> 1129 + </CopyPlaceholder> 1130 + </div> 1115 1131 1116 1132 <div class={s.placeholderGroup}> 1117 1133 <div class={s.placeholderGroupTitle}>Helpers</div>
+1 -1
lib/actions/bsky-post.ts
··· 17 17 18 18 let text: string; 19 19 try { 20 - text = await renderTextTemplate(action.textTemplate, event, fetchContext, automation.did); 20 + text = await renderTextTemplate(action.textTemplate, event, fetchContext, automation); 21 21 } catch (err) { 22 22 return { 23 23 statusCode: 0,
+1 -1
lib/actions/executor.ts
··· 23 23 24 24 let record: Record<string, unknown>; 25 25 try { 26 - record = await renderTemplate(action.recordTemplate, event, fetchContext, automation.did); 26 + record = await renderTemplate(action.recordTemplate, event, fetchContext, automation); 27 27 } catch (err) { 28 28 return { 29 29 statusCode: 0,
+1 -1
lib/actions/patch-record.ts
··· 24 24 // Fetch base record and render patch template in parallel (they're independent) 25 25 const [fetchResult, templateResult] = await Promise.allSettled([ 26 26 fetchRecord(resolvedUri), 27 - renderTemplate(action.recordTemplate, event, fetchContext, automation.did), 27 + renderTemplate(action.recordTemplate, event, fetchContext, automation), 28 28 ]); 29 29 30 30 if (fetchResult.status === "rejected") {
+56 -10
lib/actions/template.test.ts
··· 83 83 error: "Invalid placeholder: {{didToHandle(unknown.field)}}", 84 84 }); 85 85 }); 86 + 87 + it("accepts automation.id, automation.name, automation.url", () => { 88 + const result = validateTemplate( 89 + '{"id":"{{automation.id}}","n":"{{automation.name}}","u":"{{automation.url}}"}', 90 + ); 91 + expect(result.valid).toBe(true); 92 + }); 93 + 94 + it("rejects unknown automation fields", () => { 95 + const result = validateTemplate('{"x":"{{automation.unknown}}"}'); 96 + expect(result).toEqual({ 97 + valid: false, 98 + error: "Invalid placeholder: {{automation.unknown}}", 99 + }); 100 + }); 86 101 }); 87 102 88 103 describe("validateFetchStep", () => { ··· 212 227 expect(result).toEqual({ ts: "2024-06-15T12:00:00.000Z" }); 213 228 }); 214 229 215 - it("renders {{self}} as ownerDid", async () => { 216 - const result = await renderTemplate('{"owner":"{{self}}"}', event, undefined, "did:plc:owner"); 230 + it("renders {{self}} as automation DID", async () => { 231 + const result = await renderTemplate('{"owner":"{{self}}"}', event, undefined, { 232 + did: "did:plc:owner", 233 + rkey: "rk", 234 + name: "Auto", 235 + }); 217 236 expect(result).toEqual({ owner: "did:plc:owner" }); 218 237 }); 219 238 ··· 283 302 const result = await renderTemplate('{"handle":"{{didToHandle(event.did)}}"}', event); 284 303 expect(result).toEqual({ handle: "@user1.test" }); 285 304 }); 305 + 306 + it("renders automation.id and automation.name from automation context", async () => { 307 + const result = await renderTemplate( 308 + '{"id":"{{automation.id}}","name":"{{automation.name}}"}', 309 + event, 310 + undefined, 311 + { did: "did:plc:owner", rkey: "abc123", name: "My Automation" }, 312 + ); 313 + expect(result).toEqual({ id: "abc123", name: "My Automation" }); 314 + }); 315 + 316 + it("renders automation.url by resolving the owner handle at run time", async () => { 317 + const result = await renderTemplate('{"url":"{{automation.url}}"}', event, undefined, { 318 + did: "did:plc:owner", 319 + rkey: "abc123", 320 + name: "My Automation", 321 + }); 322 + expect(result.url).toMatch(/\/u\/owner\.test\/abc123$/); 323 + }); 324 + 325 + it("does not resolve automation.* when automation context is missing", async () => { 326 + const result = await renderTemplate('{"id":"{{automation.id}}"}', event); 327 + expect(result).toEqual({ id: "" }); 328 + }); 286 329 }); 287 330 288 331 describe("resolveEventPlaceholder", () => { ··· 386 429 expect(result).toBe("Created at 2024-06-15T12:00:00.000Z"); 387 430 }); 388 431 389 - it("renders {{self}} as ownerDid", async () => { 390 - const result = await renderTextTemplate("By {{self}}", event, undefined, "did:plc:owner"); 432 + it("renders {{self}} as automation DID", async () => { 433 + const result = await renderTextTemplate("By {{self}}", event, undefined, { 434 + did: "did:plc:owner", 435 + rkey: "rk", 436 + name: "Auto", 437 + }); 391 438 expect(result).toBe("By did:plc:owner"); 392 439 }); 393 440 ··· 415 462 }); 416 463 417 464 it("resolves didToHandle(self) to owner handle", async () => { 418 - const result = await renderTextTemplate( 419 - "By {{didToHandle(self)}}", 420 - event, 421 - undefined, 422 - "did:plc:owner", 423 - ); 465 + const result = await renderTextTemplate("By {{didToHandle(self)}}", event, undefined, { 466 + did: "did:plc:owner", 467 + rkey: "rk", 468 + name: "Auto", 469 + }); 424 470 expect(result).toBe("By @owner.test"); 425 471 }); 426 472
+72 -15
lib/actions/template.ts
··· 1 1 import type { JetstreamEvent } from "../jetstream/matcher.js"; 2 2 import { resolveDidToHandle } from "../auth/client.js"; 3 + import { config } from "../config.js"; 3 4 4 5 export const PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g; 5 6 const FUNCTION_CALL_RE = /^(\w+)\((.+)\)$/; 6 7 const KNOWN_FUNCTIONS = new Set(["didToHandle"]); 8 + const AUTOMATION_FIELDS = new Set(["id", "name", "url"]); 7 9 8 10 export type FetchContext = Record< 9 11 string, ··· 16 18 record: Record<string, unknown>; 17 19 } 18 20 >; 21 + 22 + /** Subset of an automation row needed for template resolution. */ 23 + export type AutomationContext = { 24 + did: string; 25 + rkey: string; 26 + name: string; 27 + /** Pre-resolved URL — populated by render* when `{{automation.url}}` appears. */ 28 + url?: string; 29 + }; 19 30 20 31 /** 21 32 * Resolve a placeholder path against a Jetstream event and optional fetch context. 22 33 * Supports: 23 34 * - "now" → current ISO datetime 35 + * - "self" → automation owner DID 36 + * - "automation.id" / "automation.name" / "automation.url" 24 37 * - "event.did", "event.time_us", "event.kind" 25 38 * - "event.commit.operation", "event.commit.collection", "event.commit.rkey", "event.commit.cid" 26 39 * - "event.commit.record.<dotted.path>" → nested record field ··· 30 43 path: string, 31 44 event: JetstreamEvent, 32 45 fetchContext?: FetchContext, 33 - ownerDid?: string, 46 + automation?: AutomationContext, 34 47 ): unknown { 35 48 if (path === "now") return new Date().toISOString(); 36 - if (path === "self" && ownerDid) return ownerDid; 49 + if (path === "self") return automation?.did; 50 + 51 + if (path.startsWith("automation.")) { 52 + if (!automation) return undefined; 53 + const field = path.slice("automation.".length); 54 + if (field === "id") return automation.rkey; 55 + if (field === "name") return automation.name; 56 + if (field === "url") return automation.url; 57 + return undefined; 58 + } 37 59 38 60 // Check fetch context: {{fetchName.record.field}} or {{fetchName.uri}} etc. 39 61 if (fetchContext) { ··· 78 100 template: string, 79 101 event: JetstreamEvent, 80 102 fetchContext?: FetchContext, 81 - ownerDid?: string, 103 + automation?: AutomationContext, 82 104 ): Promise<Map<string, string>> { 83 105 const dids = new Set<string>(); 84 106 85 107 for (const [, raw] of template.matchAll(PLACEHOLDER_RE)) { 86 108 const call = parseFunctionCall(raw!.trim()); 87 109 if (call?.fn === "didToHandle") { 88 - const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 110 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, automation); 89 111 if (typeof resolved === "string" && resolved) { 90 112 dids.add(resolved); 91 113 } ··· 100 122 return new Map(entries); 101 123 } 102 124 125 + const AUTOMATION_URL_RE = /\{\{\s*automation\.url\s*\}\}/; 126 + 127 + /** 128 + * If `{{automation.url}}` appears in the template, resolve the owner's handle 129 + * and return an augmented automation context with a `url` field. Otherwise 130 + * return the input unchanged. 131 + */ 132 + async function resolveAutomationUrl( 133 + template: string, 134 + automation?: AutomationContext, 135 + ): Promise<AutomationContext | undefined> { 136 + if (!automation) return undefined; 137 + if (!AUTOMATION_URL_RE.test(template)) return automation; 138 + const handle = await resolveDidToHandle(automation.did); 139 + return { ...automation, url: `${config.publicUrl}/u/${handle}/${automation.rkey}` }; 140 + } 141 + 103 142 /** 104 143 * Resolve a placeholder path against only the event (for fetch URI templates). 105 144 * Supports "now", "self", and "event.*" paths. ··· 109 148 event: JetstreamEvent, 110 149 ownerDid?: string, 111 150 ): unknown { 112 - return resolvePlaceholder(path, event, undefined, ownerDid); 151 + const automation = ownerDid ? { did: ownerDid, rkey: "", name: "" } : undefined; 152 + return resolvePlaceholder(path, event, undefined, automation); 113 153 } 114 154 115 155 /** Resolve a URI template against event data and full fetch/action context. */ ··· 119 159 fetchContext?: FetchContext, 120 160 ownerDid?: string, 121 161 ): string { 162 + const automation = ownerDid ? { did: ownerDid, rkey: "", name: "" } : undefined; 122 163 return uriTemplate.replace(PLACEHOLDER_RE, (_, path: string) => { 123 - const value = resolvePlaceholder(path.trim(), event, fetchContext, ownerDid); 164 + const value = resolvePlaceholder(path.trim(), event, fetchContext, automation); 124 165 if (typeof value === "string") return value; 125 166 return ""; 126 167 }); ··· 165 206 const call = parseFunctionCall(p); 166 207 const toValidate = call ? call.arg : p; 167 208 if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue; 209 + if (toValidate.startsWith("automation.")) { 210 + const field = toValidate.slice("automation.".length); 211 + if (AUTOMATION_FIELDS.has(field)) continue; 212 + return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 213 + } 168 214 const root = toValidate.split(".")[0]!; 169 215 if (fetchSet.has(root)) continue; 170 216 if (actionSet.has(root)) continue; ··· 176 222 177 223 const FETCH_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; 178 224 const ACTION_NAME_RE = /^action\d+$/; 179 - const RESERVED_FETCH_NAMES = new Set(["event", "now", "self", "secret"]); 225 + const RESERVED_FETCH_NAMES = new Set(["event", "now", "self", "secret", "automation"]); 180 226 181 227 /** Validate a single fetch step name + URI. */ 182 228 export function validateFetchStep( ··· 294 340 const call = parseFunctionCall(p); 295 341 const toValidate = call ? call.arg : p; 296 342 if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue; 343 + if (toValidate.startsWith("automation.")) { 344 + const field = toValidate.slice("automation.".length); 345 + if (AUTOMATION_FIELDS.has(field)) continue; 346 + return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 347 + } 297 348 const root = toValidate.split(".")[0]!; 298 349 if (fetchSet.has(root)) continue; 299 350 if (actionSet.has(root)) continue; ··· 308 359 template: string, 309 360 event: JetstreamEvent, 310 361 fetchContext?: FetchContext, 311 - ownerDid?: string, 362 + automation?: AutomationContext, 312 363 ): Promise<string> { 313 - const handleMap = await resolveHandlePlaceholders(template, event, fetchContext, ownerDid); 364 + const [handleMap, resolvedAutomation] = await Promise.all([ 365 + resolveHandlePlaceholders(template, event, fetchContext, automation), 366 + resolveAutomationUrl(template, automation), 367 + ]); 314 368 315 369 return template.replace(PLACEHOLDER_RE, (_match, raw: string) => { 316 370 const path = raw.trim(); 317 371 const call = parseFunctionCall(path); 318 372 if (call?.fn === "didToHandle") { 319 - const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 373 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, resolvedAutomation); 320 374 if (typeof resolved === "string" && resolved) { 321 375 return `@${handleMap.get(resolved) ?? resolved}`; 322 376 } 323 377 return ""; 324 378 } 325 - const value = resolvePlaceholder(path, event, fetchContext, ownerDid); 379 + const value = resolvePlaceholder(path, event, fetchContext, resolvedAutomation); 326 380 if (value === undefined) return ""; 327 381 if (typeof value === "string") return value; 328 382 if (typeof value === "number" || typeof value === "boolean") return String(value); ··· 335 389 template: string, 336 390 event: JetstreamEvent, 337 391 fetchContext?: FetchContext, 338 - ownerDid?: string, 392 + automation?: AutomationContext, 339 393 ): Promise<Record<string, unknown>> { 340 - const handleMap = await resolveHandlePlaceholders(template, event, fetchContext, ownerDid); 394 + const [handleMap, resolvedAutomation] = await Promise.all([ 395 + resolveHandlePlaceholders(template, event, fetchContext, automation), 396 + resolveAutomationUrl(template, automation), 397 + ]); 341 398 342 399 const rendered = template.replace(PLACEHOLDER_RE, (_match, raw: string) => { 343 400 const path = raw.trim(); 344 401 const call = parseFunctionCall(path); 345 402 if (call?.fn === "didToHandle") { 346 - const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 403 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, resolvedAutomation); 347 404 if (typeof resolved === "string" && resolved) { 348 405 const handle = handleMap.get(resolved) ?? resolved; 349 406 return JSON.stringify(`@${handle}`).slice(1, -1); ··· 351 408 return ""; 352 409 } 353 410 354 - const value = resolvePlaceholder(path, event, fetchContext, ownerDid); 411 + const value = resolvePlaceholder(path, event, fetchContext, resolvedAutomation); 355 412 if (value === undefined) return ""; 356 413 357 414 // If the placeholder is the entire JSON value (between quotes), return raw
+2 -2
lib/jetstream/handler.ts
··· 116 116 action.textTemplate, 117 117 match.event, 118 118 fetchContext, 119 - match.automation.did, 119 + match.automation, 120 120 ); 121 121 message = `Would post to Bluesky`; 122 122 payload = JSON.stringify({ text, langs: action.langs, labels: action.labels }); ··· 129 129 action.recordTemplate, 130 130 match.event, 131 131 fetchContext, 132 - match.automation.did, 132 + match.automation, 133 133 ); 134 134 message = 135 135 action.$type === "patch-record"