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: resovle dids in templates

Hugo 9236d4cc b6108c7e

+218 -51
+1 -1
lib/actions/bsky-post.ts
··· 17 17 18 18 let text: string; 19 19 try { 20 - text = renderTextTemplate(action.textTemplate, event, fetchContext, automation.did); 20 + text = await renderTextTemplate(action.textTemplate, event, fetchContext, automation.did); 21 21 } catch (err) { 22 22 return { 23 23 statusCode: 0,
+1 -1
lib/actions/executor.ts
··· 15 15 16 16 let record: Record<string, unknown>; 17 17 try { 18 - record = renderTemplate(action.recordTemplate, event, fetchContext, automation.did); 18 + record = await renderTemplate(action.recordTemplate, event, fetchContext, automation.did); 19 19 } catch (err) { 20 20 return { 21 21 statusCode: 0,
+130 -33
lib/actions/template.test.ts
··· 10 10 } from "./template.js"; 11 11 import { makeEvent } from "../test/fixtures.js"; 12 12 13 + vi.mock("@/auth/client.js", () => ({ 14 + resolveDidToHandle: vi.fn((did: string) => 15 + Promise.resolve(did.startsWith("did:") ? did.replace("did:plc:", "") + ".test" : did), 16 + ), 17 + })); 18 + 13 19 describe("validateTemplate", () => { 14 20 it("returns valid for a JSON object with placeholders", () => { 15 21 const result = validateTemplate('{"text":"{{event.did}}"}'); ··· 56 62 const result = validateTemplate('{"data":{{event.commit.record}}}'); 57 63 expect(result).toEqual({ valid: true, placeholders: ["event.commit.record"] }); 58 64 }); 65 + 66 + it("accepts didToHandle(event.did) as valid", () => { 67 + const result = validateTemplate('{"h":"{{didToHandle(event.did)}}"}'); 68 + expect(result.valid).toBe(true); 69 + }); 70 + 71 + it("rejects unknown function names", () => { 72 + const result = validateTemplate('{"h":"{{unknownFn(event.did)}}"}'); 73 + expect(result).toEqual({ 74 + valid: false, 75 + error: "Invalid placeholder: {{unknownFn(event.did)}}", 76 + }); 77 + }); 78 + 79 + it("rejects didToHandle with invalid inner arg", () => { 80 + const result = validateTemplate('{"h":"{{didToHandle(unknown.field)}}"}'); 81 + expect(result).toEqual({ 82 + valid: false, 83 + error: "Invalid placeholder: {{didToHandle(unknown.field)}}", 84 + }); 85 + }); 59 86 }); 60 87 61 88 describe("validateFetchStep", () => { ··· 132 159 expect(result.valid).toBe(false); 133 160 expect((result as any).error).toContain("Invalid placeholder"); 134 161 }); 162 + 163 + it("rejects function calls in URI", () => { 164 + const result = validateFetchUri("at://{{didToHandle(event.did)}}/col/rkey"); 165 + expect(result.valid).toBe(false); 166 + expect((result as any).error).toContain("Function calls not allowed"); 167 + }); 135 168 }); 136 169 137 170 describe("renderTemplate", () => { ··· 156 189 }, 157 190 }); 158 191 159 - it("renders event.did", () => { 160 - const result = renderTemplate('{"author":"{{event.did}}"}', event); 192 + it("renders event.did", async () => { 193 + const result = await renderTemplate('{"author":"{{event.did}}"}', event); 161 194 expect(result).toEqual({ author: "did:plc:user1" }); 162 195 }); 163 196 164 - it("renders event.commit fields", () => { 165 - const result = renderTemplate( 197 + it("renders event.commit fields", async () => { 198 + const result = await renderTemplate( 166 199 '{"op":"{{event.commit.operation}}","rkey":"{{event.commit.rkey}}"}', 167 200 event, 168 201 ); 169 202 expect(result).toEqual({ op: "create", rkey: "rk1" }); 170 203 }); 171 204 172 - it("renders record fields", () => { 173 - const result = renderTemplate('{"msg":"{{event.commit.record.text}}"}', event); 205 + it("renders record fields", async () => { 206 + const result = await renderTemplate('{"msg":"{{event.commit.record.text}}"}', event); 174 207 expect(result).toEqual({ msg: "hello" }); 175 208 }); 176 209 177 - it("renders {{now}} as ISO datetime", () => { 178 - const result = renderTemplate('{"ts":"{{now}}"}', event); 210 + it("renders {{now}} as ISO datetime", async () => { 211 + const result = await renderTemplate('{"ts":"{{now}}"}', event); 179 212 expect(result).toEqual({ ts: "2024-06-15T12:00:00.000Z" }); 180 213 }); 181 214 182 - it("renders {{self}} as ownerDid", () => { 183 - const result = renderTemplate('{"owner":"{{self}}"}', event, undefined, "did:plc:owner"); 215 + it("renders {{self}} as ownerDid", async () => { 216 + const result = await renderTemplate('{"owner":"{{self}}"}', event, undefined, "did:plc:owner"); 184 217 expect(result).toEqual({ owner: "did:plc:owner" }); 185 218 }); 186 219 187 - it("renders fetch context placeholders", () => { 220 + it("renders fetch context placeholders", async () => { 188 221 const fetchContext = { 189 222 profile: { 190 223 uri: "at://did/col/rkey", ··· 192 225 record: { displayName: "Alice" }, 193 226 }, 194 227 }; 195 - const result = renderTemplate('{"name":"{{profile.record.displayName}}"}', event, fetchContext); 228 + const result = await renderTemplate( 229 + '{"name":"{{profile.record.displayName}}"}', 230 + event, 231 + fetchContext, 232 + ); 196 233 expect(result).toEqual({ name: "Alice" }); 197 234 }); 198 235 199 - it("resolves undefined placeholders to empty string", () => { 200 - const result = renderTemplate('{"val":"{{event.commit.record.missing}}"}', event); 236 + it("resolves undefined placeholders to empty string", async () => { 237 + const result = await renderTemplate('{"val":"{{event.commit.record.missing}}"}', event); 201 238 expect(result).toEqual({ val: "" }); 202 239 }); 203 240 204 - it("JSON-stringifies objects in placeholders", () => { 241 + it("JSON-stringifies objects in placeholders", async () => { 205 242 const fetchContext = { 206 243 data: { 207 244 uri: "at://x", ··· 209 246 record: { nested: { a: 1 } }, 210 247 }, 211 248 }; 212 - const result = renderTemplate('{"obj":{{data.record.nested}}}', event, fetchContext); 249 + const result = await renderTemplate('{"obj":{{data.record.nested}}}', event, fetchContext); 213 250 expect(result).toEqual({ obj: { a: 1 } }); 214 251 }); 215 252 216 - it("throws on invalid rendered JSON", () => { 217 - // A template that might produce invalid JSON after rendering 218 - expect(() => { 219 - renderTemplate('{"key":{{event.commit.record.missing}}}', event); 220 - }).toThrow(); 253 + it("throws on invalid rendered JSON", async () => { 254 + await expect( 255 + renderTemplate('{"key":{{event.commit.record.missing}}}', event), 256 + ).rejects.toThrow(); 257 + }); 258 + 259 + it("resolves didToHandle in JSON template", async () => { 260 + const result = await renderTemplate('{"handle":"{{didToHandle(event.did)}}"}', event); 261 + expect(result).toEqual({ handle: "user1.test" }); 221 262 }); 222 263 }); 223 264 ··· 278 319 const result = validateTextTemplate("Name: {{profile.record.displayName}}", ["profile"]); 279 320 expect(result).toEqual({ valid: true, placeholders: ["profile.record.displayName"] }); 280 321 }); 322 + 323 + it("accepts didToHandle(self) as valid", () => { 324 + const result = validateTextTemplate("By {{didToHandle(self)}}"); 325 + expect(result.valid).toBe(true); 326 + }); 327 + 328 + it("accepts didToHandle with fetch name", () => { 329 + const result = validateTextTemplate("{{didToHandle(profile.record.did)}}", ["profile"]); 330 + expect(result.valid).toBe(true); 331 + }); 281 332 }); 282 333 283 334 describe("renderTextTemplate", () => { ··· 302 353 }, 303 354 }); 304 355 305 - it("renders placeholders as plain text", () => { 306 - const result = renderTextTemplate("Post by {{event.did}}", event); 356 + it("renders placeholders as plain text", async () => { 357 + const result = await renderTextTemplate("Post by {{event.did}}", event); 307 358 expect(result).toBe("Post by did:plc:user1"); 308 359 }); 309 360 310 - it("renders {{now}} as ISO datetime", () => { 311 - const result = renderTextTemplate("Created at {{now}}", event); 361 + it("renders {{now}} as ISO datetime", async () => { 362 + const result = await renderTextTemplate("Created at {{now}}", event); 312 363 expect(result).toBe("Created at 2024-06-15T12:00:00.000Z"); 313 364 }); 314 365 315 - it("renders {{self}} as ownerDid", () => { 316 - const result = renderTextTemplate("By {{self}}", event, undefined, "did:plc:owner"); 366 + it("renders {{self}} as ownerDid", async () => { 367 + const result = await renderTextTemplate("By {{self}}", event, undefined, "did:plc:owner"); 317 368 expect(result).toBe("By did:plc:owner"); 318 369 }); 319 370 320 - it("renders undefined placeholders as empty string", () => { 321 - const result = renderTextTemplate("Value: {{event.commit.record.missing}}", event); 371 + it("renders undefined placeholders as empty string", async () => { 372 + const result = await renderTextTemplate("Value: {{event.commit.record.missing}}", event); 322 373 expect(result).toBe("Value: "); 323 374 }); 324 375 325 - it("does not JSON-escape text (no backslash or quote escaping)", () => { 376 + it("does not JSON-escape text (no backslash or quote escaping)", async () => { 326 377 const fetchContext = { 327 378 data: { uri: "at://x", cid: "c", record: { name: 'He said "hi"' } }, 328 379 }; 329 - const result = renderTextTemplate("{{data.record.name}}", event, fetchContext); 380 + const result = await renderTextTemplate("{{data.record.name}}", event, fetchContext); 330 381 expect(result).toBe('He said "hi"'); 331 382 }); 332 383 333 - it("renders text with no placeholders as-is", () => { 334 - const result = renderTextTemplate("Just a static post", event); 384 + it("renders text with no placeholders as-is", async () => { 385 + const result = await renderTextTemplate("Just a static post", event); 335 386 expect(result).toBe("Just a static post"); 387 + }); 388 + 389 + it("resolves didToHandle(event.did) to a handle", async () => { 390 + const result = await renderTextTemplate("Post by {{didToHandle(event.did)}}", event); 391 + expect(result).toBe("Post by user1.test"); 392 + }); 393 + 394 + it("resolves didToHandle(self) to owner handle", async () => { 395 + const result = await renderTextTemplate( 396 + "By {{didToHandle(self)}}", 397 + event, 398 + undefined, 399 + "did:plc:owner", 400 + ); 401 + expect(result).toBe("By owner.test"); 402 + }); 403 + 404 + it("resolves didToHandle with fetch context", async () => { 405 + const fetchContext = { 406 + profile: { uri: "at://x", cid: "c", record: { authorDid: "did:plc:author" } }, 407 + }; 408 + const result = await renderTextTemplate( 409 + "Author: {{didToHandle(profile.record.authorDid)}}", 410 + event, 411 + fetchContext, 412 + ); 413 + expect(result).toBe("Author: author.test"); 414 + }); 415 + 416 + it("passes non-DID values through didToHandle as-is", async () => { 417 + const result = await renderTextTemplate("{{didToHandle(event.commit.record.text)}}", event); 418 + expect(result).toBe("hello"); 419 + }); 420 + 421 + it("returns empty string when didToHandle inner arg is undefined", async () => { 422 + const result = await renderTextTemplate("{{didToHandle(event.commit.record.missing)}}", event); 423 + expect(result).toBe(""); 424 + }); 425 + 426 + it("deduplicates DID resolution for same DID", async () => { 427 + const { resolveDidToHandle } = await import("@/auth/client.js"); 428 + const mockResolve = vi.mocked(resolveDidToHandle); 429 + mockResolve.mockClear(); 430 + 431 + await renderTextTemplate("{{didToHandle(event.did)}} and {{didToHandle(event.did)}}", event); 432 + expect(mockResolve).toHaveBeenCalledTimes(1); 336 433 }); 337 434 });
+84 -14
lib/actions/template.ts
··· 1 1 import type { JetstreamEvent } from "../jetstream/matcher.js"; 2 + import { resolveDidToHandle } from "../auth/client.js"; 2 3 3 4 export const PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g; 5 + const FUNCTION_CALL_RE = /^(\w+)\((.+)\)$/; 6 + const KNOWN_FUNCTIONS = new Set(["didToHandle"]); 4 7 5 8 export type FetchContext = Record< 6 9 string, ··· 54 57 return value; 55 58 } 56 59 60 + function parseFunctionCall(placeholder: string): { fn: string; arg: string } | null { 61 + const match = FUNCTION_CALL_RE.exec(placeholder); 62 + if (!match || !KNOWN_FUNCTIONS.has(match[1]!)) return null; 63 + return { fn: match[1]!, arg: match[2]!.trim() }; 64 + } 65 + 66 + /** 67 + * Scan template for didToHandle(...) calls, resolve inner args to DIDs, 68 + * then batch-resolve all unique DIDs to handles in parallel. 69 + */ 70 + async function resolveHandlePlaceholders( 71 + template: string, 72 + event: JetstreamEvent, 73 + fetchContext?: FetchContext, 74 + ownerDid?: string, 75 + ): Promise<Map<string, string>> { 76 + const dids = new Set<string>(); 77 + 78 + for (const [, raw] of template.matchAll(PLACEHOLDER_RE)) { 79 + const call = parseFunctionCall(raw!.trim()); 80 + if (call?.fn === "didToHandle") { 81 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 82 + if (typeof resolved === "string" && resolved) { 83 + dids.add(resolved); 84 + } 85 + } 86 + } 87 + 88 + if (dids.size === 0) return new Map(); 89 + 90 + const entries = await Promise.all( 91 + [...dids].map(async (did) => [did, await resolveDidToHandle(did)] as const), 92 + ); 93 + return new Map(entries); 94 + } 95 + 57 96 /** 58 97 * Resolve a placeholder path against only the event (for fetch URI templates). 59 98 * Supports "now", "self", and "event.*" paths. ··· 100 139 101 140 const fetchSet = new Set(fetchNames ?? []); 102 141 for (const p of placeholders) { 103 - if (p === "now" || p === "self" || p.startsWith("event.")) continue; 104 - const root = p.split(".")[0]!; 142 + const call = parseFunctionCall(p); 143 + const toValidate = call ? call.arg : p; 144 + if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue; 145 + const root = toValidate.split(".")[0]!; 105 146 if (fetchSet.has(root)) continue; 106 147 return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 107 148 } ··· 155 196 } 156 197 157 198 for (const p of placeholders) { 199 + if (parseFunctionCall(p)) { 200 + return { valid: false, error: `Function calls not allowed in fetch URI: {{${p}}}` }; 201 + } 158 202 if (p !== "now" && p !== "self" && !p.startsWith("event.")) { 159 203 return { valid: false, error: `Invalid placeholder in fetch URI: {{${p}}}` }; 160 204 } ··· 180 224 181 225 const fetchSet = new Set(fetchNames ?? []); 182 226 for (const p of placeholders) { 183 - if (p === "now" || p === "self" || p.startsWith("event.")) continue; 184 - const root = p.split(".")[0]!; 227 + const call = parseFunctionCall(p); 228 + const toValidate = call ? call.arg : p; 229 + if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue; 230 + const root = toValidate.split(".")[0]!; 185 231 if (fetchSet.has(root)) continue; 186 232 return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 187 233 } ··· 190 236 } 191 237 192 238 /** Render a plain-text template (for bsky-post actions). */ 193 - export function renderTextTemplate( 239 + export async function renderTextTemplate( 194 240 template: string, 195 241 event: JetstreamEvent, 196 242 fetchContext?: FetchContext, 197 243 ownerDid?: string, 198 - ): string { 199 - return template.replace(PLACEHOLDER_RE, (_match, path: string) => { 200 - const value = resolvePlaceholder(path.trim(), event, fetchContext, ownerDid); 244 + ): Promise<string> { 245 + const handleMap = await resolveHandlePlaceholders(template, event, fetchContext, ownerDid); 246 + 247 + return template.replace(PLACEHOLDER_RE, (_match, raw: string) => { 248 + const path = raw.trim(); 249 + const call = parseFunctionCall(path); 250 + if (call?.fn === "didToHandle") { 251 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 252 + if (typeof resolved === "string" && resolved) { 253 + return handleMap.get(resolved) ?? resolved; 254 + } 255 + return ""; 256 + } 257 + const value = resolvePlaceholder(path, event, fetchContext, ownerDid); 201 258 if (value === undefined) return ""; 202 259 if (typeof value === "string") return value; 203 260 if (typeof value === "number" || typeof value === "boolean") return String(value); ··· 206 263 } 207 264 208 265 /** Render a template by resolving all {{placeholder}} expressions against event data. */ 209 - export function renderTemplate( 266 + export async function renderTemplate( 210 267 template: string, 211 268 event: JetstreamEvent, 212 269 fetchContext?: FetchContext, 213 270 ownerDid?: string, 214 - ): Record<string, unknown> { 215 - const rendered = template.replace(PLACEHOLDER_RE, (match, path: string) => { 216 - const value = resolvePlaceholder(path.trim(), event, fetchContext, ownerDid); 271 + ): Promise<Record<string, unknown>> { 272 + const handleMap = await resolveHandlePlaceholders(template, event, fetchContext, ownerDid); 273 + 274 + const rendered = template.replace(PLACEHOLDER_RE, (_match, raw: string) => { 275 + const path = raw.trim(); 276 + const call = parseFunctionCall(path); 277 + if (call?.fn === "didToHandle") { 278 + const resolved = resolvePlaceholder(call.arg, event, fetchContext, ownerDid); 279 + if (typeof resolved === "string" && resolved) { 280 + const handle = handleMap.get(resolved) ?? resolved; 281 + return JSON.stringify(handle).slice(1, -1); 282 + } 283 + return ""; 284 + } 285 + 286 + const value = resolvePlaceholder(path, event, fetchContext, ownerDid); 217 287 if (value === undefined) return ""; 218 288 219 289 // If the placeholder is the entire JSON value (between quotes), return raw 220 290 // Otherwise return as string for interpolation within a larger string 221 291 if (typeof value === "string") { 222 - // Escape for JSON string context 223 - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 292 + // Escape for JSON string context (handles quotes, backslashes, newlines, control chars) 293 + return JSON.stringify(value).slice(1, -1); 224 294 } 225 295 if (typeof value === "number" || typeof value === "boolean") { 226 296 return String(value);
+2 -2
lib/jetstream/handler.ts
··· 69 69 payload = JSON.stringify(buildPayload(match, fetchContext)); 70 70 } else if (action.$type === "bsky-post") { 71 71 try { 72 - const text = renderTextTemplate( 72 + const text = await renderTextTemplate( 73 73 action.textTemplate, 74 74 match.event, 75 75 fetchContext, ··· 82 82 } 83 83 } else { 84 84 try { 85 - const rendered = renderTemplate( 85 + const rendered = await renderTemplate( 86 86 action.recordTemplate, 87 87 match.event, 88 88 fetchContext,