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.

fix: make it easier to patch records

Hugo be458794 50665b7d

+225 -39
+8
app/islands/AutomationForm.css.ts
··· 308 308 export const actionTitle = style({ 309 309 fontSize: fontSize.sm, 310 310 fontWeight: fontWeight.medium, 311 + fontFamily: "monospace", 311 312 color: vars.color.text, 313 + }); 314 + 315 + export const actionSubtitle = style({ 316 + fontFamily: "inherit", 317 + fontWeight: fontWeight.normal, 318 + color: vars.color.textMuted, 319 + marginInlineStart: space[2], 312 320 }); 313 321 314 322 export const addActionsRow = style({
+12 -8
app/islands/AutomationForm.tsx
··· 426 426 placeholders={placeholders} 427 427 initialTemplate={action.recordTemplate || undefined} 428 428 onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 429 + patchMode 429 430 /> 430 431 )} 431 432 ··· 1201 1202 <div key={i} class={s.actionCard}> 1202 1203 <div class={s.actionHeader}> 1203 1204 <span class={s.actionTitle}> 1204 - {action.type === "webhook" 1205 - ? "Webhook" 1206 - : action.type === "bsky-post" 1207 - ? "Bluesky Post" 1208 - : action.type === "patch-record" 1209 - ? "Patch Record" 1210 - : "Record"}{" "} 1211 - {actions.filter((a, j) => a.type === action.type && j <= i).length} 1205 + action{i} 1206 + <span class={s.actionSubtitle}> 1207 + {action.type === "webhook" 1208 + ? "webhook" 1209 + : action.type === "bsky-post" 1210 + ? "bluesky post" 1211 + : action.type === "patch-record" 1212 + ? "patch record" 1213 + : "record"}{" "} 1214 + {actions.filter((a, j) => a.type === action.type && j <= i).length} 1215 + </span> 1212 1216 </span> 1213 1217 <button type="button" class={s.removeBtn} onClick={() => removeAction(i)}> 1214 1218 Remove
+43
app/islands/RecordFormBuilder.css.ts
··· 141 141 }, 142 142 }); 143 143 144 + export const patchFieldToggle = style({ 145 + display: "flex", 146 + alignItems: "center", 147 + gap: space[2], 148 + fontSize: fontSize.sm, 149 + color: vars.color.text, 150 + cursor: "pointer", 151 + }); 152 + 153 + export const patchCheckbox = style({ 154 + appearance: "none", 155 + width: "16px", 156 + height: "16px", 157 + borderRadius: radii.sm, 158 + border: `1.5px solid ${vars.color.border}`, 159 + backgroundColor: "transparent", 160 + cursor: "pointer", 161 + position: "relative", 162 + flexShrink: 0, 163 + transition: "background-color 0.15s, border-color 0.15s", 164 + selectors: { 165 + "&:checked": { 166 + backgroundColor: vars.color.accent, 167 + borderColor: vars.color.accent, 168 + }, 169 + "&:checked::after": { 170 + content: '""', 171 + position: "absolute", 172 + insetBlockStart: "50%", 173 + insetInlineStart: "50%", 174 + width: "4px", 175 + height: "8px", 176 + border: `2px solid ${vars.color.accentText}`, 177 + borderBlockStart: "none", 178 + borderInlineStart: "none", 179 + transform: "translate(-50%, -60%) rotate(45deg)", 180 + }, 181 + "&:hover:not(:checked)": { 182 + borderColor: vars.color.text, 183 + }, 184 + }, 185 + }); 186 + 144 187 export const textarea = style({ 145 188 width: "100%", 146 189 minBlockSize: "80px",
+107 -16
app/islands/RecordFormBuilder.tsx
··· 16 16 placeholders: string[]; 17 17 initialTemplate?: string; 18 18 onChange: (jsonTemplate: string) => void; 19 + /** When true, fields are opt-in via checkboxes (for patch-record actions). */ 20 + patchMode?: boolean; 19 21 }; 20 22 21 23 // --------------------------------------------------------------------------- ··· 585 587 placeholders, 586 588 initialTemplate, 587 589 onChange, 590 + patchMode, 588 591 }: Props) { 589 592 const [state, setState] = useState<FormState>({}); 593 + const [enabledFields, setEnabledFields] = useState<Set<string>>(new Set()); 590 594 591 595 useEffect(() => { 592 596 if (schema) { 593 597 const restored = 594 598 initialTemplate != null ? deserializeState(initialTemplate, schema.properties) : null; 595 - const formState = restored ?? initState(schema.properties); 596 - setState(formState); 597 - onChange(serialize(schema, formState)); 599 + if (patchMode) { 600 + // In patch mode, start with all fields disabled unless restored from template 601 + const formState = restored ?? {}; 602 + setState(formState); 603 + setEnabledFields(new Set(Object.keys(formState))); 604 + onChange(serializePatch(schema, formState, new Set(Object.keys(formState)))); 605 + } else { 606 + const formState = restored ?? initState(schema.properties); 607 + setState(formState); 608 + onChange(serialize(schema, formState)); 609 + } 598 610 } 599 - }, [schema]); 611 + }, [schema, patchMode]); 600 612 601 613 const handleFieldChange = useCallback( 602 614 (key: string, value: unknown) => { 603 615 if (!schema) return; 604 616 setState((prev) => { 605 617 const next = { ...prev, [key]: value }; 606 - onChange(serialize(schema, next)); 618 + if (patchMode) { 619 + onChange(serializePatch(schema, next, enabledFields)); 620 + } else { 621 + onChange(serialize(schema, next)); 622 + } 607 623 return next; 608 624 }); 609 625 }, 626 + [schema, onChange, patchMode, enabledFields], 627 + ); 628 + 629 + const toggleField = useCallback( 630 + (key: string) => { 631 + if (!schema) return; 632 + setEnabledFields((prevEnabled) => { 633 + const nextEnabled = new Set(prevEnabled); 634 + if (nextEnabled.has(key)) { 635 + nextEnabled.delete(key); 636 + } else { 637 + nextEnabled.add(key); 638 + } 639 + // Compute new state synchronously so onChange gets the correct value 640 + setState((prevState) => { 641 + let nextState = prevState; 642 + if (nextEnabled.has(key) && prevState[key] == null) { 643 + const node = schema.properties[key]; 644 + if (node) { 645 + nextState = { ...prevState, ...initState({ [key]: node }) }; 646 + } 647 + } 648 + onChange(serializePatch(schema, nextState, nextEnabled)); 649 + return nextState; 650 + }); 651 + return nextEnabled; 652 + }); 653 + }, 610 654 [schema, onChange], 611 655 ); 612 656 ··· 622 666 623 667 return ( 624 668 <div class={s.root}> 625 - {Object.entries(schema.properties).map(([key, node]) => ( 626 - <FieldRenderer 627 - key={key} 628 - name={key} 629 - node={node} 630 - value={state[key]} 631 - required={schema.required.includes(key)} 632 - onValueChange={(v) => handleFieldChange(key, v)} 633 - placeholders={placeholders} 634 - /> 635 - ))} 669 + {patchMode && <span class={s.hint}>Select the fields you want to update.</span>} 670 + {Object.entries(schema.properties).map(([key, node]) => { 671 + if (patchMode) { 672 + const enabled = enabledFields.has(key); 673 + return ( 674 + <div key={key}> 675 + <label class={s.patchFieldToggle}> 676 + <input 677 + type="checkbox" 678 + class={s.patchCheckbox} 679 + checked={enabled} 680 + onChange={() => toggleField(key)} 681 + /> 682 + {key} 683 + {node.description && <span class={s.hint}> — {node.description}</span>} 684 + </label> 685 + {enabled && ( 686 + <FieldRenderer 687 + name={key} 688 + node={node} 689 + value={state[key]} 690 + required={false} 691 + onValueChange={(v) => handleFieldChange(key, v)} 692 + placeholders={placeholders} 693 + /> 694 + )} 695 + </div> 696 + ); 697 + } 698 + return ( 699 + <FieldRenderer 700 + key={key} 701 + name={key} 702 + node={node} 703 + value={state[key]} 704 + required={schema.required.includes(key)} 705 + onValueChange={(v) => handleFieldChange(key, v)} 706 + placeholders={placeholders} 707 + /> 708 + ); 709 + })} 636 710 </div> 637 711 ); 638 712 } 713 + 714 + /** Serialize only the enabled fields (for patch mode). */ 715 + function serializePatch( 716 + schema: RecordSchema, 717 + state: FormState, 718 + enabledFields: Set<string>, 719 + ): string { 720 + const obj: Record<string, unknown> = {}; 721 + for (const [key, node] of Object.entries(schema.properties)) { 722 + if (!enabledFields.has(key)) continue; 723 + const v = serializeNode(node, state[key]); 724 + if (v !== undefined && v !== "") { 725 + obj[key] = v; 726 + } 727 + } 728 + return JSON.stringify(obj, null, 2); 729 + }
+27 -7
app/routes/dashboard/automations/[rkey].tsx
··· 164 164 </h3> 165 165 {auto.actions.map((action, i) => ( 166 166 <Card key={i} variant="flat"> 167 - <Stack gap={2}> 167 + <Stack gap={3}> 168 168 <h4> 169 - {action.$type === "webhook" 170 - ? "Webhook" 171 - : action.$type === "bsky-post" 172 - ? "Bluesky Post" 173 - : "Record"}{" "} 174 - {i + 1} 169 + <code>action{i}</code>{" "} 170 + <span class={textMuted}> 171 + {action.$type === "webhook" 172 + ? "webhook" 173 + : action.$type === "bsky-post" 174 + ? "bluesky post" 175 + : action.$type === "patch-record" 176 + ? "patch record" 177 + : "record"}{" "} 178 + {auto.actions.filter((a, j) => a.$type === action.$type && j <= i).length} 179 + </span> 175 180 {action.$type === "webhook" && ( 176 181 <> 177 182 {" "} ··· 212 217 <dd>{action.labels.join(", ")}</dd> 213 218 </> 214 219 )} 220 + </> 221 + ) : action.$type === "patch-record" ? ( 222 + <> 223 + <dt>Target Collection</dt> 224 + <dd> 225 + <InlineCode>{action.targetCollection}</InlineCode> 226 + </dd> 227 + <dt>Base Record URI</dt> 228 + <dd> 229 + <InlineCode>{action.baseRecordUri}</InlineCode> 230 + </dd> 231 + <dt>Patch Template</dt> 232 + <dd> 233 + <CodeBlock>{action.recordTemplate}</CodeBlock> 234 + </dd> 215 235 </> 216 236 ) : ( 217 237 <>
+1 -1
app/routes/index.tsx
··· 156 156 { 157 157 title: "Airglow — Webhooks & Automations for the AT Protocol", 158 158 description: 159 - "Automate Bluesky and the AT Protocol. Set up webhooks, create records, and filter Jetstream events by lexicon — no code required.", 159 + "Automate Bluesky and the AT Protocol. Set up webhooks, create records, and filter Jetstream events by lexicon.", 160 160 }, 161 161 ); 162 162 });
+27 -7
app/routes/u/[handle]/[rkey].tsx
··· 201 201 </h3> 202 202 {publicActions.map((action, i) => ( 203 203 <Card key={i} variant="flat"> 204 - <Stack gap={2}> 204 + <Stack gap={3}> 205 205 <h4> 206 - {action.$type === "webhook" 207 - ? "Webhook" 208 - : action.$type === "bsky-post" 209 - ? "Bluesky Post" 210 - : "Record"}{" "} 211 - {i + 1} 206 + <code>action{i}</code>{" "} 207 + <span class={textMuted}> 208 + {action.$type === "webhook" 209 + ? "webhook" 210 + : action.$type === "bsky-post" 211 + ? "bluesky post" 212 + : action.$type === "patch-record" 213 + ? "patch record" 214 + : "record"}{" "} 215 + {auto.actions.filter((a, j) => a.$type === action.$type && j <= i).length} 216 + </span> 212 217 {action.$type === "webhook" && ( 213 218 <> 214 219 {" "} ··· 239 244 <dd>{action.langs.join(", ")}</dd> 240 245 </> 241 246 )} 247 + </> 248 + ) : action.$type === "patch-record" ? ( 249 + <> 250 + <dt>Target Collection</dt> 251 + <dd> 252 + <InlineCode>{action.targetCollection}</InlineCode> 253 + </dd> 254 + <dt>Base Record URI</dt> 255 + <dd> 256 + <InlineCode>{action.baseRecordUri}</InlineCode> 257 + </dd> 258 + <dt>Patch Template</dt> 259 + <dd> 260 + <CodeBlock>{action.recordTemplate}</CodeBlock> 261 + </dd> 242 262 </> 243 263 ) : ( 244 264 <>