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: new follow quick actions

Hugo 397e39ec f93c4a2a

+1018 -124
-1
.tangled/workflows/deploy.yaml
··· 10 10 dependencies: 11 11 nixpkgs/nixpkgs-unstable: 12 12 - bun 13 - - nodejs 14 13 15 14 environment: 16 15 NODE_ENV: "production"
+4 -1
app/components/ActionHeader/index.tsx
··· 1 1 import type { Child } from "hono/jsx"; 2 2 import { ACTION_INFO_BY_TYPE } from "../../../lib/automations/action-catalogue.js"; 3 + import { Favicon } from "../Favicon/index.tsx"; 3 4 import { 4 5 actionHeaderEyebrow, 5 6 actionHeaderLabel, 6 7 actionHeaderRow, 7 8 actionHeaderSubtitle, 8 9 actionIcon, 10 + actionIconFavicon, 9 11 } from "../../styles/action-header.css.ts"; 10 12 11 13 type Props = { ··· 30 32 return ( 31 33 <Tag class={actionHeaderRow}> 32 34 {Icon && ( 33 - <span class={actionIcon} data-cat={info.catId} aria-hidden="true"> 35 + <span class={actionIcon} data-cat={info.colorKey ?? info.catId} aria-hidden="true"> 34 36 <Icon size={18} /> 37 + {info.faviconDomain && <Favicon domain={info.faviconDomain} class={actionIconFavicon} />} 35 38 </span> 36 39 )} 37 40 <span class={actionHeaderLabel}>{info?.label ?? type}</span>
+5
app/components/AutomationCard/index.tsx
··· 1 1 import { ArrowRight, Copy, Webhook } from "../../icons.ts"; 2 2 import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 3 import { isRecordProducingAction, type Action } from "../../../lib/db/schema.ts"; 4 + import { FOLLOW_TARGET_META } from "../../../lib/automations/action-catalogue.ts"; 4 5 import { Button } from "../Button/index.tsx"; 5 6 import { Favicon, NsidCode } from "../NsidCode/index.tsx"; 6 7 import * as s from "./styles.css.ts"; ··· 22 23 const pds = actions.find((a) => isRecordProducingAction(a.$type)); 23 24 if (pds?.$type === "bsky-post") return <Favicon domain="bsky.app" class={s.targetFavicon} />; 24 25 if (pds?.$type === "bookmark") return <Favicon domain="margin.at" class={s.targetFavicon} />; 26 + if (pds?.$type === "follow") 27 + return ( 28 + <Favicon domain={FOLLOW_TARGET_META[pds.target].faviconDomain} class={s.targetFavicon} /> 29 + ); 25 30 if (pds?.$type === "record" || pds?.$type === "patch-record") 26 31 return <Favicon domain={nsidToDomain(pds.targetCollection)} class={s.targetFavicon} />; 27 32 return <Webhook size={14} />;
+25
app/components/Favicon/index.tsx
··· 1 + import * as s from "./styles.css.ts"; 2 + 3 + const STATIC_FAVICONS: Record<string, string> = { 4 + "bsky.app": "/static/favicons/bsky.app.cf36edf8.png", 5 + "standard.site": "/static/favicons/standard.site.cf36edf8.ico", 6 + "npmx.dev": "/static/favicons/npmx.dev.cf36edf8.ico", 7 + "tangled.sh": "/static/favicons/tangled.sh.9d70b730.svg", 8 + "airglow.run": "/static/favicons/airglow.run.cf36edf8.svg", 9 + "exosphere.site": "/static/favicons/exosphere.site.cf36edf8.svg", 10 + "margin.at": "/static/favicons/margin.at.cf36edf8.svg", 11 + "sifa.id": "/static/favicons/sifa.id.f49fecdf.svg", 12 + }; 13 + 14 + export function Favicon({ domain, class: className }: { domain: string; class?: string }) { 15 + const src = STATIC_FAVICONS[domain] ?? `/api/favicon/${domain}`; 16 + return ( 17 + <img 18 + src={src} 19 + alt="" 20 + class={className ?? s.favicon} 21 + loading="lazy" 22 + onerror="this.style.display='none'" 23 + /> 24 + ); 25 + }
+8
app/components/Favicon/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + 3 + export const favicon = style({ 4 + inlineSize: "16px", 5 + blockSize: "16px", 6 + flexShrink: 0, 7 + borderRadius: "2px", 8 + });
+1 -1
app/components/Layout/Header/index.tsx
··· 19 19 </a> 20 20 {user ? ( 21 21 <> 22 - <span class={s.userInfo}>@{user.handle}</span> 23 22 <a href="/dashboard" class={s.navLink}> 24 23 Dashboard 25 24 </a> ··· 28 27 Sign out 29 28 </button> 30 29 </form> 30 + <span class={s.userInfo}>@{user.handle}</span> 31 31 </> 32 32 ) : ( 33 33 <a href="/auth/login" class={s.navLink}>
+6 -22
app/components/NsidCode/index.tsx
··· 1 1 import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 2 2 import { InlineCode } from "../CodeBlock/index.tsx"; 3 + import { Favicon } from "../Favicon/index.tsx"; 3 4 import * as s from "./styles.css.ts"; 4 5 5 - const STATIC_FAVICONS: Record<string, string> = { 6 - "bsky.app": "/static/favicons/bsky.app.cf36edf8.png", 7 - "standard.site": "/static/favicons/standard.site.cf36edf8.ico", 8 - "npmx.dev": "/static/favicons/npmx.dev.cf36edf8.ico", 9 - "tangled.sh": "/static/favicons/tangled.sh.9d70b730.svg", 10 - "airglow.run": "/static/favicons/airglow.run.cf36edf8.svg", 11 - "exosphere.site": "/static/favicons/exosphere.site.cf36edf8.svg", 12 - "margin.at": "/static/favicons/margin.at.cf36edf8.svg", 13 - }; 14 - 15 - export function Favicon({ domain, class: className }: { domain: string; class?: string }) { 16 - const src = STATIC_FAVICONS[domain] ?? `/api/favicon/${domain}`; 17 - return ( 18 - <img 19 - src={src} 20 - alt="" 21 - class={className ?? s.favicon} 22 - loading="lazy" 23 - onerror="this.style.display='none'" 24 - /> 25 - ); 26 - } 6 + // Re-export so existing callers keep working. New code should import from 7 + // @/components/Favicon directly: this module's nsidToDomain import pulls in 8 + // node-only code transitively (lib/config.ts uses createRequire), which makes 9 + // it unsafe inside a client island. 10 + export { Favicon }; 27 11 28 12 export function NsidCode({ children }: { children: string }) { 29 13 return (
+99 -9
app/islands/AutomationForm.tsx
··· 1 1 import { useState, useCallback, useRef, useMemo, useEffect } from "hono/jsx"; 2 2 import type { RecordSchema } from "../../lib/lexicons/schema-types.js"; 3 3 import { nsidRequiresWantedDids } from "../../lib/lexicons/match.js"; 4 - import { isRecordProducingAction, type Action, type FetchStep } from "../../lib/db/schema.js"; 5 - import { ACTION_CATALOGUE, type AddableActionId } from "../../lib/automations/action-catalogue.js"; 4 + import { 5 + isRecordProducingAction, 6 + type Action, 7 + type FetchStep, 8 + type FollowTarget, 9 + } from "../../lib/db/schema.js"; 10 + import { 11 + ACTION_CATALOGUE, 12 + FOLLOW_TARGET_META, 13 + actionTypeKey, 14 + type AddableActionId, 15 + } from "../../lib/automations/action-catalogue.js"; 6 16 import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 7 17 import RecordFormBuilder from "./RecordFormBuilder.js"; 8 18 import { ActionHeader } from "../components/ActionHeader/index.js"; ··· 69 79 tagsText: string; 70 80 comment: string; 71 81 }; 72 - type ActionDraft = WebhookDraft | RecordDraft | BskyPostDraft | PatchRecordDraft | BookmarkDraft; 82 + type FollowDraft = { 83 + type: "follow"; 84 + target: FollowTarget; 85 + subject: string; 86 + comment: string; 87 + }; 88 + type ActionDraft = 89 + | WebhookDraft 90 + | RecordDraft 91 + | BskyPostDraft 92 + | PatchRecordDraft 93 + | BookmarkDraft 94 + | FollowDraft; 73 95 74 96 export type AutomationInitial = { 75 97 rkey?: string; ··· 620 642 } 621 643 622 644 // --------------------------------------------------------------------------- 645 + // Follow (social graph) action editor, shared across bluesky / tangled / sifa 646 + // --------------------------------------------------------------------------- 647 + 648 + function FollowActionEditor({ 649 + action, 650 + onChange, 651 + }: { 652 + action: FollowDraft; 653 + onChange: (a: FollowDraft) => void; 654 + }) { 655 + const meta = FOLLOW_TARGET_META[action.target]; 656 + return ( 657 + <> 658 + <div class={s.fieldGroup}> 659 + <label class={s.label}>Subject DID</label> 660 + <input 661 + class={s.input} 662 + type="text" 663 + placeholder="did:plc:... or {{event.did}}" 664 + value={action.subject} 665 + onInput={(e: Event) => 666 + onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 667 + } 668 + required 669 + /> 670 + <span class={s.hint}> 671 + DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 672 + {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 673 + </span> 674 + </div> 675 + </> 676 + ); 677 + } 678 + 679 + // --------------------------------------------------------------------------- 623 680 // Copy-to-clipboard placeholder 624 681 // --------------------------------------------------------------------------- 625 682 ··· 687 744 targetTitle: a.targetTitle ?? "", 688 745 bodyValue: a.bodyValue ?? "", 689 746 tagsText: (a.tags ?? []).join(", "), 747 + comment: a.comment ?? "", 748 + }; 749 + } 750 + if (a.$type === "follow") { 751 + return { 752 + type: "follow", 753 + target: a.target, 754 + subject: a.subject, 690 755 comment: a.comment ?? "", 691 756 }; 692 757 } ··· 1132 1197 comment: "", 1133 1198 }, 1134 1199 ]); 1200 + } else if (type.startsWith("follow-")) { 1201 + const target = type.slice("follow-".length) as FollowTarget; 1202 + setActions((prev) => [ 1203 + ...prev, 1204 + { type: "follow", target, subject: "{{event.did}}", comment: "" }, 1205 + ]); 1135 1206 } else { 1136 1207 setActions((prev) => [ 1137 1208 ...prev, ··· 1217 1288 ...comment, 1218 1289 }; 1219 1290 } 1291 + if (a.type === "follow") { 1292 + return { 1293 + type: "follow", 1294 + target: a.target, 1295 + subject: a.subject, 1296 + ...comment, 1297 + }; 1298 + } 1220 1299 return { 1221 1300 type: "record", 1222 1301 targetCollection: a.targetCollection, ··· 1270 1349 `action${i + 1}.rkey`, 1271 1350 ] 1272 1351 : [], 1352 + ); 1353 + 1354 + // Catalogue-tile identity: follows split into follow-<target> so per-tile 1355 + // "#N" counters don't collapse the three tiles into one group. 1356 + const actionTypeKeys = actions.map((a) => 1357 + actionTypeKey({ 1358 + $type: a.type, 1359 + target: a.type === "follow" ? a.target : undefined, 1360 + }), 1273 1361 ); 1274 1362 1275 1363 const allPlaceholders = [ ··· 2006 2094 </div> 2007 2095 2008 2096 {actions.map((action, i) => { 2009 - const sameTypeIndex = actions.filter( 2010 - (a, j) => a.type === action.type && j <= i, 2011 - ).length; 2012 - const totalOfType = actions.filter((a) => a.type === action.type).length; 2097 + const typeKey = actionTypeKeys[i]!; 2098 + const sameTypeIndex = actionTypeKeys.filter((k, j) => k === typeKey && j <= i).length; 2099 + const totalOfType = actionTypeKeys.filter((k) => k === typeKey).length; 2013 2100 return ( 2014 2101 <div key={i} class={s.actionCard}> 2015 2102 <div class={s.actionHeader}> 2016 2103 <ActionHeader 2017 - type={action.type} 2104 + type={typeKey} 2018 2105 index={i} 2019 2106 sameTypeIndex={sameTypeIndex} 2020 2107 totalOfType={totalOfType} ··· 2035 2122 /> 2036 2123 ) : action.type === "bookmark" ? ( 2037 2124 <BookmarkActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2125 + ) : action.type === "follow" ? ( 2126 + <FollowActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2038 2127 ) : ( 2039 2128 <RecordActionEditor 2040 2129 action={action} ··· 2072 2161 <div class={s.catTileRow}> 2073 2162 {cat.actions.map((a) => { 2074 2163 const Icon = a.icon; 2164 + const colorKey = "colorKey" in a && a.colorKey ? a.colorKey : cat.id; 2075 2165 return ( 2076 2166 <button 2077 2167 key={a.id} ··· 2080 2170 disabled={!a.available} 2081 2171 onClick={() => addAction(a.id as AddableActionId)} 2082 2172 > 2083 - <span class={actionIcon} data-cat={cat.id} aria-hidden="true"> 2173 + <span class={actionIcon} data-cat={colorKey} aria-hidden="true"> 2084 2174 <Icon size={20} /> 2085 2175 </span> 2086 2176 <span class={s.catTileMeta}>
+23 -44
app/routes/api/automations/[rkey].ts
··· 11 11 type BskyPostAction, 12 12 type PatchRecordAction, 13 13 type BookmarkAction, 14 + type FollowAction, 14 15 } from "@/db/schema.js"; 15 16 import { config } from "@/config.js"; 16 17 import { isValidNsid } from "@/lexicons/resolver.js"; 17 18 import { getRecord, putRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; 19 + import { toPdsAction } from "@/automations/pds-serialize.js"; 18 20 import { verifyCallback } from "@/automations/verify.js"; 19 21 import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 20 22 import { ··· 29 31 BCP47_RE, 30 32 validateWebhookHeaders, 31 33 validateBookmarkInput, 34 + validateFollowInput, 32 35 resolveWantedDids, 33 36 } from "@/actions/validation.js"; 34 37 import { ··· 419 422 ...(input.comment ? { comment: input.comment } : {}), 420 423 }); 421 424 actionResultNames.push(`action${actionIndex + 1}`); 425 + } else if (input.type === "follow") { 426 + const followValidation = validateFollowInput(input, fetchNames, actionResultNames); 427 + if (!followValidation.valid) { 428 + return c.json({ error: followValidation.error }, 400); 429 + } 430 + 431 + newLocalActions.push({ 432 + $type: "follow", 433 + target: input.target, 434 + subject: input.subject, 435 + ...(input.comment ? { comment: input.comment } : {}), 436 + } satisfies FollowAction); 437 + newPdsActions.push({ 438 + $type: "run.airglow.automation#followAction", 439 + target: input.target, 440 + subject: input.subject, 441 + ...(input.comment ? { comment: input.comment } : {}), 442 + }); 443 + actionResultNames.push(`action${actionIndex + 1}`); 422 444 } else { 423 445 return c.json({ error: "Invalid action type" }, 400); 424 446 } ··· 448 470 const existing = await getRecord(user.did, rkey); 449 471 const createdAt = (existing?.createdAt as string) || auto.indexedAt.toISOString(); 450 472 451 - // Build PDS actions from local actions if not already built 452 473 if (!pdsActions) { 453 - pdsActions = localActions.map((a): PdsAction => { 454 - if (a.$type === "webhook") { 455 - return { 456 - $type: "run.airglow.automation#webhookAction", 457 - callbackUrl: a.callbackUrl, 458 - ...(a.comment ? { comment: a.comment } : {}), 459 - }; 460 - } 461 - if (a.$type === "bsky-post") { 462 - return { 463 - $type: "run.airglow.automation#bskyPostAction", 464 - textTemplate: a.textTemplate, 465 - ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}), 466 - ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}), 467 - ...(a.comment ? { comment: a.comment } : {}), 468 - }; 469 - } 470 - if (a.$type === "patch-record") { 471 - return { 472 - $type: "run.airglow.automation#patchRecordAction", 473 - targetCollection: a.targetCollection, 474 - baseRecordUri: a.baseRecordUri, 475 - recordTemplate: a.recordTemplate, 476 - ...(a.comment ? { comment: a.comment } : {}), 477 - }; 478 - } 479 - if (a.$type === "bookmark") { 480 - return { 481 - $type: "run.airglow.automation#bookmarkAction", 482 - targetSource: a.targetSource, 483 - ...(a.targetTitle ? { targetTitle: a.targetTitle } : {}), 484 - ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 485 - ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 486 - ...(a.comment ? { comment: a.comment } : {}), 487 - }; 488 - } 489 - return { 490 - $type: "run.airglow.automation#recordAction", 491 - targetCollection: a.targetCollection, 492 - recordTemplate: a.recordTemplate, 493 - ...(a.comment ? { comment: a.comment } : {}), 494 - }; 495 - }); 474 + pdsActions = localActions.map(toPdsAction); 496 475 } 497 476 498 477 // Block disabling dry run when scope doesn't cover action collections
+21
app/routes/api/automations/index.ts
··· 10 10 type BskyPostAction, 11 11 type PatchRecordAction, 12 12 type BookmarkAction, 13 + type FollowAction, 13 14 } from "@/db/schema.js"; 14 15 import { config } from "@/config.js"; 15 16 import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; ··· 28 29 BCP47_RE, 29 30 validateWebhookHeaders, 30 31 validateBookmarkInput, 32 + validateFollowInput, 31 33 resolveWantedDids, 32 34 } from "@/actions/validation.js"; 33 35 import { ··· 340 342 ...(targetTitle ? { targetTitle } : {}), 341 343 ...(bodyValue ? { bodyValue } : {}), 342 344 ...(tags ? { tags } : {}), 345 + ...(input.comment ? { comment: input.comment } : {}), 346 + }); 347 + actionResultNames.push(`action${actionIndex + 1}`); 348 + } else if (input.type === "follow") { 349 + const followValidation = validateFollowInput(input, fetchNames, actionResultNames); 350 + if (!followValidation.valid) { 351 + return c.json({ error: followValidation.error }, 400); 352 + } 353 + 354 + localActions.push({ 355 + $type: "follow", 356 + target: input.target, 357 + subject: input.subject, 358 + ...(input.comment ? { comment: input.comment } : {}), 359 + } satisfies FollowAction); 360 + pdsActions.push({ 361 + $type: "run.airglow.automation#followAction", 362 + target: input.target, 363 + subject: input.subject, 343 364 ...(input.comment ? { comment: input.comment } : {}), 344 365 }); 345 366 actionResultNames.push(`action${actionIndex + 1}`);
+39 -21
app/routes/dashboard/automations/[rkey].tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq, and, desc } from "drizzle-orm"; 3 3 import { ArrowLeft, Copy, ExternalLink, Pencil, Filter, Database, Zap } from "../../../icons.js"; 4 - import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 4 + import { 5 + opLabels, 6 + actionTypeLabels, 7 + operationLabels, 8 + followTargetLabels, 9 + } from "@/automations/labels.js"; 10 + import { actionTypeKey } from "@/automations/action-catalogue.js"; 11 + import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js"; 5 12 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 6 13 import { db } from "@/db/index.js"; 7 14 import { automations, deliveryLogs } from "@/db/schema.js"; ··· 120 127 </> 121 128 ))} 122 129 </dd> 130 + {auto.wantedDids.length > 0 && ( 131 + <> 132 + <dt>Watched repos</dt> 133 + <dd> 134 + <ul class={plainList}> 135 + {auto.wantedDids.map((did, i) => ( 136 + <li key={i}> 137 + <InlineCode>{did}</InlineCode> 138 + </li> 139 + ))} 140 + </ul> 141 + </dd> 142 + </> 143 + )} 123 144 <dt>Status</dt> 124 145 <dd> 125 146 <span data-automation-status> ··· 140 161 </details> 141 162 </Card> 142 163 143 - {auto.wantedDids.length > 0 && ( 144 - <Card variant="flat"> 145 - <Stack gap={3}> 146 - <h3 class={inlineCluster}> 147 - <Filter size={18} /> Watched repos 148 - </h3> 149 - <ul class={plainList}> 150 - {auto.wantedDids.map((did, i) => ( 151 - <li key={i}> 152 - <InlineCode>{did}</InlineCode> 153 - </li> 154 - ))} 155 - </ul> 156 - </Stack> 157 - </Card> 158 - )} 159 - 160 164 {auto.conditions.length > 0 && ( 161 165 <Card variant="flat"> 162 166 <Stack gap={3}> ··· 193 197 <Zap size={18} /> Actions ({auto.actions.length}) 194 198 </h3> 195 199 {auto.actions.map((action, i) => { 200 + const typeKey = actionTypeKey(action); 196 201 const sameTypeIndex = auto.actions.filter( 197 - (a, j) => a.$type === action.$type && j <= i, 202 + (a, j) => actionTypeKey(a) === typeKey && j <= i, 198 203 ).length; 199 - const totalOfType = auto.actions.filter((a) => a.$type === action.$type).length; 204 + const totalOfType = auto.actions.filter((a) => actionTypeKey(a) === typeKey).length; 200 205 return ( 201 206 <Card key={i} variant="flat"> 202 207 <Stack gap={3}> 203 208 <ActionHeader 204 - type={action.$type} 209 + type={typeKey} 205 210 index={i} 206 211 sameTypeIndex={sameTypeIndex} 207 212 totalOfType={totalOfType} ··· 260 265 <dt>Patch Template</dt> 261 266 <dd> 262 267 <CodeBlock>{action.recordTemplate}</CodeBlock> 268 + </dd> 269 + </> 270 + ) : action.$type === "follow" ? ( 271 + <> 272 + <dt>App</dt> 273 + <dd>{followTargetLabels[action.target] ?? action.target}</dd> 274 + <dt>Collection</dt> 275 + <dd> 276 + <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode> 277 + </dd> 278 + <dt>Subject DID</dt> 279 + <dd> 280 + <InlineCode>{action.subject}</InlineCode> 263 281 </dd> 264 282 </> 265 283 ) : action.$type === "bookmark" ? (
+34 -8
app/routes/u/[handle]/[rkey].tsx
··· 3 3 import { ArrowLeft, Copy, Filter, Database, Zap } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; 6 - import { opLabels, operationLabels } from "@/automations/labels.js"; 6 + import { opLabels, operationLabels, followTargetLabels } from "@/automations/labels.js"; 7 + import { actionTypeKey } from "@/automations/action-catalogue.js"; 8 + import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js"; 7 9 import { db } from "@/db/index.js"; 8 10 import { users, automations } from "@/db/schema.js"; 9 11 import { sanitizeActions } from "@/automations/sanitize.js"; ··· 147 149 </> 148 150 ))} 149 151 </dd> 150 - <dt>By</dt> 151 - <dd> 152 - <a href={`/u/${handle}`}>@{handle}</a> 153 - </dd> 152 + {auto.wantedDids.length > 0 && ( 153 + <> 154 + <dt>Watched repos</dt> 155 + <dd> 156 + <ul class={plainList}> 157 + {auto.wantedDids.map((did, i) => ( 158 + <li key={i}> 159 + <InlineCode>{did}</InlineCode> 160 + </li> 161 + ))} 162 + </ul> 163 + </dd> 164 + </> 165 + )} 154 166 </DescriptionList> 155 167 </Card> 156 168 ··· 190 202 <Zap size={18} /> Actions ({publicActions.length}) 191 203 </h3> 192 204 {publicActions.map((action, i) => { 205 + const typeKey = actionTypeKey(action); 193 206 const sameTypeIndex = publicActions.filter( 194 - (a, j) => a.$type === action.$type && j <= i, 207 + (a, j) => actionTypeKey(a) === typeKey && j <= i, 195 208 ).length; 196 - const totalOfType = publicActions.filter((a) => a.$type === action.$type).length; 209 + const totalOfType = publicActions.filter((a) => actionTypeKey(a) === typeKey).length; 197 210 return ( 198 211 <Card key={i} variant="flat"> 199 212 <Stack gap={3}> 200 213 <ActionHeader 201 - type={action.$type} 214 + type={typeKey} 202 215 index={i} 203 216 sameTypeIndex={sameTypeIndex} 204 217 totalOfType={totalOfType} ··· 247 260 <dt>Patch Template</dt> 248 261 <dd> 249 262 <CodeBlock>{action.recordTemplate}</CodeBlock> 263 + </dd> 264 + </> 265 + ) : action.$type === "follow" ? ( 266 + <> 267 + <dt>App</dt> 268 + <dd>{followTargetLabels[action.target] ?? action.target}</dd> 269 + <dt>Collection</dt> 270 + <dd> 271 + <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode> 272 + </dd> 273 + <dt>Subject DID</dt> 274 + <dd> 275 + <InlineCode>{action.subject}</InlineCode> 250 276 </dd> 251 277 </> 252 278 ) : action.$type === "bookmark" ? (
+24
app/styles/action-header.css.ts
··· 13 13 }); 14 14 15 15 export const actionIcon = style({ 16 + position: "relative", 16 17 display: "inline-flex", 17 18 alignItems: "center", 18 19 justifyContent: "center", ··· 37 38 backgroundColor: vars.color.appsSubtle, 38 39 color: vars.color.apps, 39 40 }, 41 + '&[data-cat="sifa"]': { 42 + backgroundColor: vars.color.sifaSubtle, 43 + color: vars.color.sifa, 44 + }, 45 + '&[data-cat="tangled"]': { 46 + backgroundColor: vars.color.tangledSubtle, 47 + color: vars.color.tangled, 48 + }, 40 49 }, 41 50 }); 42 51 ··· 59 68 fontWeight: fontWeight.normal, 60 69 color: vars.color.textMuted, 61 70 }); 71 + 72 + /** Small app favicon tucked into the corner of the action tile/header icon. 73 + * Lets us visually brand follow/bookmark tiles by the destination app while 74 + * still reusing a single lucide glyph for the action type. */ 75 + export const actionIconFavicon = style({ 76 + position: "absolute", 77 + right: "-4px", 78 + bottom: "-4px", 79 + width: "16px", 80 + height: "16px", 81 + borderRadius: radii.sm, 82 + backgroundColor: vars.color.surface, 83 + padding: "1px", 84 + boxShadow: `0 0 0 1px ${vars.color.border}`, 85 + });
+8
app/styles/theme.css.ts
··· 34 34 pdsSubtle: "color-pds-subtle", 35 35 apps: "color-apps", 36 36 appsSubtle: "color-apps-subtle", 37 + sifa: "color-sifa", 38 + sifaSubtle: "color-sifa-subtle", 39 + tangled: "color-tangled", 40 + tangledSubtle: "color-tangled-subtle", 37 41 code: "color-code", 38 42 }, 39 43 shadow: { ··· 100 104 [vars.color.pdsSubtle]: darkColors.pdsSubtle, 101 105 [vars.color.apps]: darkColors.apps, 102 106 [vars.color.appsSubtle]: darkColors.appsSubtle, 107 + [vars.color.sifa]: darkColors.sifa, 108 + [vars.color.sifaSubtle]: darkColors.sifaSubtle, 109 + [vars.color.tangled]: darkColors.tangled, 110 + [vars.color.tangledSubtle]: darkColors.tangledSubtle, 103 111 [vars.color.code]: darkColors.code, 104 112 [vars.shadow.highlight]: darkShadows.highlight, 105 113 [vars.shadow.sm]: darkShadows.sm,
+10
app/styles/tokens/colors.ts
··· 34 34 pdsSubtle: "oklch(0.22 0.04 300)", 35 35 apps: "oklch(0.65 0.22 262)", 36 36 appsSubtle: "oklch(0.22 0.05 262)", 37 + // Hue shifted toward 220 and chroma nudged so dark-mode Sifa reads distinctly 38 + // from Bluesky (245) when both tiles appear together. 39 + sifa: "oklch(0.68 0.14 220)", 40 + sifaSubtle: "oklch(0.22 0.04 220)", 41 + tangled: "oklch(0.82 0 0)", 42 + tangledSubtle: "oklch(0.25 0 0)", 37 43 38 44 code: "oklch(0.20 0 0)", 39 45 } as const; ··· 72 78 pdsSubtle: "oklch(0.96 0.03 300)", 73 79 apps: "#2563EB", 74 80 appsSubtle: "oklch(0.95 0.04 262)", 81 + sifa: "#4385BE", 82 + sifaSubtle: "oklch(0.96 0.03 220)", 83 + tangled: "oklch(0.30 0 0)", 84 + tangledSubtle: "oklch(0.93 0 0)", 75 85 76 86 code: "oklch(0.95 0.005 90)", 77 87 } as const;
+25 -1
lexicons/run/airglow/automation.json
··· 47 47 "#recordAction", 48 48 "#bskyPostAction", 49 49 "#patchRecordAction", 50 - "#bookmarkAction" 50 + "#bookmarkAction", 51 + "#followAction" 51 52 ] 52 53 } 53 54 }, ··· 333 334 "type": "string", 334 335 "maxLength": 64 335 336 } 337 + }, 338 + "comment": { 339 + "type": "string", 340 + "description": "Optional user note about this action.", 341 + "maxLength": 512 342 + } 343 + } 344 + }, 345 + "followAction": { 346 + "type": "object", 347 + "description": "Follow a subject DID on an AT Protocol social graph (Bluesky, Tangled, or Sifa). All three produce records with the same shape; only the collection NSID differs.", 348 + "required": ["target", "subject"], 349 + "properties": { 350 + "target": { 351 + "type": "string", 352 + "description": "Which social graph to follow on.", 353 + "knownValues": ["bluesky", "tangled", "sifa"], 354 + "maxLength": 32 355 + }, 356 + "subject": { 357 + "type": "string", 358 + "description": "DID of the account to follow. Supports {{placeholders}}; the runtime enforces the rendered value matches the DID format before writing the record.", 359 + "maxLength": 512 336 360 }, 337 361 "comment": { 338 362 "type": "string",
+144
lib/actions/follow.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("../auth/client.js", () => ({ 13 + resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 14 + })); 15 + 16 + import { executeFollow } from "./follow.js"; 17 + import { createArbitraryRecord } from "../automations/pds.js"; 18 + import { db } from "../db/index.js"; 19 + import { automations, deliveryLogs } from "../db/schema.js"; 20 + import { makeMatch, makeFollowAction, makeAutomation } from "../test/fixtures.js"; 21 + 22 + const mockCreateRecord = vi.mocked(createArbitraryRecord); 23 + 24 + const EVENT_DID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; // 24-char valid did:plc 25 + 26 + describe("executeFollow", () => { 27 + beforeEach(async () => { 28 + vi.useFakeTimers(); 29 + vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 30 + mockCreateRecord.mockReset(); 31 + 32 + await db.delete(deliveryLogs); 33 + await db.delete(automations); 34 + await db.insert(automations).values(makeAutomation()); 35 + }); 36 + 37 + afterEach(() => { 38 + vi.useRealTimers(); 39 + }); 40 + 41 + it("writes to the bluesky collection with {subject, createdAt}", async () => { 42 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/app.bsky.graph.follow/rk", cid: "c" }); 43 + 44 + const action = makeFollowAction({ target: "bluesky", subject: "{{event.did}}" }); 45 + const match = makeMatch({ 46 + automation: { actions: [action] }, 47 + event: { did: EVENT_DID }, 48 + }); 49 + await executeFollow(match, 0); 50 + 51 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 52 + const [did, collection, record] = mockCreateRecord.mock.calls[0]!; 53 + expect(did).toBe(match.automation.did); 54 + expect(collection).toBe("app.bsky.graph.follow"); 55 + expect(record).toEqual({ 56 + subject: EVENT_DID, 57 + createdAt: "2024-06-15T12:00:00.000Z", 58 + }); 59 + 60 + const logs = await db.query.deliveryLogs.findMany(); 61 + expect(logs[0]!.statusCode).toBe(200); 62 + }); 63 + 64 + it("maps tangled and sifa targets to their collections", async () => { 65 + mockCreateRecord.mockResolvedValue({ uri: "at://x/_/rk", cid: "c" }); 66 + 67 + const tangled = makeFollowAction({ 68 + target: "tangled", 69 + subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", 70 + }); 71 + await executeFollow(makeMatch({ automation: { actions: [tangled] } }), 0); 72 + expect(mockCreateRecord.mock.calls[0]![1]).toBe("sh.tangled.graph.follow"); 73 + 74 + const sifa = makeFollowAction({ target: "sifa", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }); 75 + await executeFollow(makeMatch({ automation: { actions: [sifa] } }), 0); 76 + expect(mockCreateRecord.mock.calls[1]![1]).toBe("id.sifa.graph.follow"); 77 + }); 78 + 79 + it("fails without retry when subject renders to an empty string", async () => { 80 + const action = makeFollowAction({ subject: "{{event.missing}}" }); 81 + const match = makeMatch({ automation: { actions: [action] } }); 82 + await executeFollow(match, 0); 83 + 84 + expect(mockCreateRecord).not.toHaveBeenCalled(); 85 + expect(vi.getTimerCount()).toBe(0); 86 + const logs = await db.query.deliveryLogs.findMany(); 87 + expect(logs[0]!.statusCode).toBe(400); 88 + expect(logs[0]!.error).toContain("empty"); 89 + }); 90 + 91 + it("fails without retry when the rendered subject is not a valid DID", async () => { 92 + const action = makeFollowAction({ subject: "not-a-did" }); 93 + const match = makeMatch({ automation: { actions: [action] } }); 94 + await executeFollow(match, 0); 95 + 96 + expect(mockCreateRecord).not.toHaveBeenCalled(); 97 + expect(vi.getTimerCount()).toBe(0); 98 + const logs = await db.query.deliveryLogs.findMany(); 99 + expect(logs[0]!.statusCode).toBe(400); 100 + expect(logs[0]!.error).toContain("not a valid DID"); 101 + }); 102 + 103 + it("extracts status code from PDS error message", async () => { 104 + mockCreateRecord.mockRejectedValueOnce( 105 + new Error("PDS com.atproto.repo.createRecord failed (400): bad request"), 106 + ); 107 + 108 + const action = makeFollowAction({ subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }); 109 + const match = makeMatch({ automation: { actions: [action] } }); 110 + await executeFollow(match, 0); 111 + 112 + const logs = await db.query.deliveryLogs.findMany(); 113 + expect(logs[0]!.statusCode).toBe(400); 114 + }); 115 + 116 + it("retries on 5xx PDS errors", async () => { 117 + mockCreateRecord 118 + .mockRejectedValueOnce(new Error("PDS failed (500): internal")) 119 + .mockResolvedValueOnce({ uri: "at://x/app.bsky.graph.follow/rk", cid: "c" }); 120 + 121 + const action = makeFollowAction({ subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }); 122 + const match = makeMatch({ automation: { actions: [action] } }); 123 + await executeFollow(match, 0); 124 + 125 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 126 + await vi.advanceTimersByTimeAsync(5_000); 127 + expect(mockCreateRecord).toHaveBeenCalledTimes(2); 128 + 129 + const logs = await db.query.deliveryLogs.findMany(); 130 + expect(logs).toHaveLength(2); 131 + }); 132 + 133 + it("does not retry on 4xx PDS errors", async () => { 134 + mockCreateRecord.mockRejectedValueOnce(new Error("PDS failed (400): bad request")); 135 + 136 + const action = makeFollowAction({ subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }); 137 + const match = makeMatch({ automation: { actions: [action] } }); 138 + await executeFollow(match, 0); 139 + 140 + expect(vi.getTimerCount()).toBe(0); 141 + await vi.advanceTimersByTimeAsync(60_000); 142 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 143 + }); 144 + });
+117
lib/actions/follow.ts
··· 1 + import { type FollowAction, FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 2 + import { createArbitraryRecord } from "../automations/pds.js"; 3 + import { renderTextTemplate, type FetchContext } from "./template.js"; 4 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 5 + import { DID_RE } from "./validation.js"; 6 + import type { ActionResult } from "./executor.js"; 7 + import type { MatchedEvent } from "../jetstream/consumer.js"; 8 + 9 + async function execute( 10 + match: MatchedEvent, 11 + action: FollowAction, 12 + fetchContext?: FetchContext, 13 + ): Promise<ActionResult> { 14 + const { automation, event } = match; 15 + 16 + let subject: string; 17 + try { 18 + subject = (await renderTextTemplate(action.subject, event, fetchContext, automation)).trim(); 19 + } catch (err) { 20 + // 400: deterministic failure. Retrying won't re-render differently. 21 + return { 22 + statusCode: 400, 23 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 24 + }; 25 + } 26 + 27 + if (!subject) { 28 + return { statusCode: 400, error: "subject rendered to an empty string" }; 29 + } 30 + if (!DID_RE.test(subject)) { 31 + return { statusCode: 400, error: `subject is not a valid DID: "${subject}"` }; 32 + } 33 + 34 + const collection = FOLLOW_TARGET_COLLECTION[action.target]; 35 + const record: Record<string, unknown> = { 36 + subject, 37 + createdAt: new Date().toISOString(), 38 + }; 39 + 40 + try { 41 + const created = await createArbitraryRecord(automation.did, collection, record); 42 + return { statusCode: 200, uri: created.uri, cid: created.cid }; 43 + } catch (err) { 44 + const message = err instanceof Error ? err.message : String(err); 45 + const statusMatch = message.match(/\((\d{3})\)/); 46 + const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 47 + return { statusCode, error: message }; 48 + } 49 + } 50 + 51 + function actionPayload(action: FollowAction): string { 52 + return JSON.stringify({ 53 + target: action.target, 54 + collection: FOLLOW_TARGET_COLLECTION[action.target], 55 + subject: action.subject, 56 + }); 57 + } 58 + 59 + function scheduleRetry( 60 + match: MatchedEvent, 61 + actionIndex: number, 62 + retryIndex: number, 63 + fetchContext?: FetchContext, 64 + ) { 65 + if (retryIndex >= RETRY_DELAYS.length) return; 66 + 67 + setTimeout(async () => { 68 + try { 69 + const action = match.automation.actions[actionIndex] as FollowAction; 70 + const result = await execute(match, action, fetchContext); 71 + const body = actionPayload(action); 72 + 73 + await logDelivery( 74 + match.automation.uri, 75 + actionIndex, 76 + match.event.time_us, 77 + isSuccess(result.statusCode) ? null : body, 78 + result.statusCode, 79 + result.error ?? null, 80 + retryIndex + 2, 81 + ); 82 + 83 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 84 + scheduleRetry(match, actionIndex, retryIndex + 1, fetchContext); 85 + } 86 + } catch (err) { 87 + console.error("Follow retry error:", err); 88 + } 89 + }, RETRY_DELAYS[retryIndex]); 90 + } 91 + 92 + /** Execute a follow action for a matched event. */ 93 + export async function executeFollow( 94 + match: MatchedEvent, 95 + actionIndex: number, 96 + fetchContext?: FetchContext, 97 + ): Promise<ActionResult> { 98 + const action = match.automation.actions[actionIndex] as FollowAction; 99 + const result = await execute(match, action, fetchContext); 100 + const body = actionPayload(action); 101 + 102 + await logDelivery( 103 + match.automation.uri, 104 + actionIndex, 105 + match.event.time_us, 106 + isSuccess(result.statusCode) ? null : body, 107 + result.statusCode, 108 + result.error ?? null, 109 + 1, 110 + ); 111 + 112 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 113 + scheduleRetry(match, actionIndex, 0, fetchContext); 114 + } 115 + 116 + return result; 117 + }
+40
lib/actions/validation.test.ts
··· 3 3 validateWantedDids, 4 4 validateFetchConditionInputs, 5 5 validateFetchSearchStep, 6 + validateFollowInput, 6 7 type FetchConditionInput, 7 8 type FetchSearchInput, 8 9 } from "./validation.js"; ··· 269 270 expect(res.valid).toBe(false); 270 271 }); 271 272 }); 273 + 274 + describe("validateFollowInput", () => { 275 + it("accepts a literal DID subject for each target", () => { 276 + for (const target of ["bluesky", "tangled", "sifa"] as const) { 277 + const res = validateFollowInput( 278 + { target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 279 + [], 280 + [], 281 + ); 282 + expect(res.valid).toBe(true); 283 + } 284 + }); 285 + 286 + it("accepts placeholders in subject (validated at render time)", () => { 287 + const res = validateFollowInput({ target: "bluesky", subject: "{{event.did}}" }, [], []); 288 + expect(res.valid).toBe(true); 289 + }); 290 + 291 + it("rejects an unknown target", () => { 292 + const res = validateFollowInput( 293 + { target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 294 + [], 295 + [], 296 + ); 297 + expect(res.valid).toBe(false); 298 + if (!res.valid) expect(res.error).toMatch(/target/); 299 + }); 300 + 301 + it("rejects empty subject", () => { 302 + const res = validateFollowInput({ target: "bluesky", subject: "" }, [], []); 303 + expect(res.valid).toBe(false); 304 + }); 305 + 306 + it("rejects unknown placeholders in subject", () => { 307 + const res = validateFollowInput({ target: "bluesky", subject: "{{mystery.field}}" }, [], []); 308 + expect(res.valid).toBe(false); 309 + if (!res.valid) expect(res.error).toMatch(/subject/); 310 + }); 311 + });
+51 -2
lib/actions/validation.ts
··· 34 34 bodyValue?: string; 35 35 tags?: string[]; 36 36 comment?: string; 37 + } 38 + | { 39 + type: "follow"; 40 + target: "bluesky" | "tangled" | "sifa"; 41 + subject: string; 42 + comment?: string; 37 43 }; 44 + 45 + export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa"]); 38 46 39 47 export const VALID_OPERATORS = new Set([ 40 48 "eq", ··· 52 60 export const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 53 61 export const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 54 62 55 - // did:plc:<24 base32 chars> or did:web:<domain> 56 - const DID_RE = /^did:(plc:[a-z2-7]{24}|web:[a-z0-9.-]+(?::\d+)?(?:\/[^\s]*)?)$/; 63 + /** did:plc:<24 base32 chars> or did:web:<domain>. Shared with runtime 64 + * executors (e.g. follow.ts) that re-validate post-template-render. */ 65 + export const DID_RE = /^did:(plc:[a-z2-7]{24}|web:[a-z0-9.-]+(?::\d+)?(?:\/[^\s]*)?)$/; 57 66 58 67 /** 59 68 * Normalize and validate a wantedDids list. Accepts the literal `{{self}}` ··· 388 397 ...(step.comment ? { comment: step.comment } : {}), 389 398 }, 390 399 }; 400 + } 401 + 402 + type FollowInput = { 403 + target: string; 404 + subject: string; 405 + }; 406 + 407 + const FOLLOW_SUBJECT_MAX = 512; 408 + 409 + /** Validate a follow action input. The subject supports `{{placeholders}}` 410 + * which are resolved at execution time, so we validate it as a text template, 411 + * not as a literal DID. */ 412 + export function validateFollowInput( 413 + input: FollowInput, 414 + fetchNames: string[], 415 + actionNames: string[], 416 + ): { valid: true } | { valid: false; error: string } { 417 + if (!input.target || typeof input.target !== "string") { 418 + return { valid: false, error: "target is required for follow actions" }; 419 + } 420 + if (!VALID_FOLLOW_TARGETS.has(input.target)) { 421 + return { 422 + valid: false, 423 + error: `Invalid follow target "${input.target}". Must be one of: bluesky, tangled, sifa`, 424 + }; 425 + } 426 + if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) { 427 + return { valid: false, error: "subject is required for follow actions" }; 428 + } 429 + if (input.subject.length > FOLLOW_SUBJECT_MAX) { 430 + return { 431 + valid: false, 432 + error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less`, 433 + }; 434 + } 435 + const templateCheck = validateTextTemplate(input.subject, fetchNames, actionNames); 436 + if (!templateCheck.valid) { 437 + return { valid: false, error: `subject: ${templateCheck.error}` }; 438 + } 439 + return { valid: true }; 391 440 } 392 441 393 442 type BookmarkInput = {
+2 -1
lib/auth/client.ts
··· 36 36 a.$type === "bsky-post" || 37 37 a.$type === "record" || 38 38 a.$type === "patch-record" || 39 - a.$type === "bookmark", 39 + a.$type === "bookmark" || 40 + a.$type === "follow", 40 41 ); 41 42 } 42 43
+82
lib/automations/action-catalogue.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + ACTION_CATALOGUE, 4 + ACTION_INFO_BY_TYPE, 5 + FOLLOW_TARGET_META, 6 + actionTypeKey, 7 + } from "./action-catalogue.js"; 8 + import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 9 + import { VALID_FOLLOW_TARGETS } from "../actions/validation.js"; 10 + 11 + // Tile order is a product-visible detail: reorders should be intentional and 12 + // show up in review, not slip in silently with other catalogue edits. 13 + describe("ACTION_CATALOGUE", () => { 14 + it("orders Bluesky tiles: Post → Follow → Like", () => { 15 + const bsky = ACTION_CATALOGUE.find((c) => c.id === "bluesky")!; 16 + expect(bsky.actions.map((a) => a.id)).toEqual(["bsky-post", "follow-bluesky", "bsky-like"]); 17 + }); 18 + 19 + it("orders Apps tiles: Bookmark → Follow Sifa → Follow Tangled", () => { 20 + const apps = ACTION_CATALOGUE.find((c) => c.id === "apps")!; 21 + expect(apps.actions.map((a) => a.id)).toEqual(["bookmark", "follow-sifa", "follow-tangled"]); 22 + }); 23 + 24 + it("groups follow tiles under their parent category (not a dedicated follow group)", () => { 25 + const categoryOf = (id: string) => 26 + ACTION_CATALOGUE.find((c) => c.actions.some((a) => a.id === id))?.id; 27 + expect(categoryOf("follow-bluesky")).toBe("bluesky"); 28 + expect(categoryOf("follow-sifa")).toBe("apps"); 29 + expect(categoryOf("follow-tangled")).toBe("apps"); 30 + }); 31 + }); 32 + 33 + describe("ACTION_INFO_BY_TYPE", () => { 34 + it("propagates per-tile colorKey so accents override the category color", () => { 35 + expect(ACTION_INFO_BY_TYPE["follow-sifa"]!.colorKey).toBe("sifa"); 36 + expect(ACTION_INFO_BY_TYPE["follow-tangled"]!.colorKey).toBe("tangled"); 37 + expect(ACTION_INFO_BY_TYPE["follow-bluesky"]!.colorKey).toBe("bluesky"); 38 + }); 39 + 40 + it("leaves colorKey undefined for tiles that use their category's color", () => { 41 + expect(ACTION_INFO_BY_TYPE["bookmark"]!.colorKey).toBeUndefined(); 42 + expect(ACTION_INFO_BY_TYPE["bsky-post"]!.colorKey).toBeUndefined(); 43 + expect(ACTION_INFO_BY_TYPE["webhook"]!.colorKey).toBeUndefined(); 44 + }); 45 + 46 + it("sets faviconDomain for branded tiles", () => { 47 + expect(ACTION_INFO_BY_TYPE["follow-bluesky"]!.faviconDomain).toBe("bsky.app"); 48 + expect(ACTION_INFO_BY_TYPE["follow-sifa"]!.faviconDomain).toBe("sifa.id"); 49 + expect(ACTION_INFO_BY_TYPE["follow-tangled"]!.faviconDomain).toBe("tangled.sh"); 50 + expect(ACTION_INFO_BY_TYPE["bookmark"]!.faviconDomain).toBe("margin.at"); 51 + }); 52 + }); 53 + 54 + describe("actionTypeKey", () => { 55 + it("maps follow actions to follow-<target> tiles", () => { 56 + expect(actionTypeKey({ $type: "follow", target: "bluesky" })).toBe("follow-bluesky"); 57 + expect(actionTypeKey({ $type: "follow", target: "sifa" })).toBe("follow-sifa"); 58 + expect(actionTypeKey({ $type: "follow", target: "tangled" })).toBe("follow-tangled"); 59 + }); 60 + 61 + it("passes through non-follow types unchanged", () => { 62 + expect(actionTypeKey({ $type: "bookmark" })).toBe("bookmark"); 63 + expect(actionTypeKey({ $type: "webhook" })).toBe("webhook"); 64 + }); 65 + 66 + it("falls back to $type when a follow action is missing its target", () => { 67 + expect(actionTypeKey({ $type: "follow" })).toBe("follow"); 68 + }); 69 + }); 70 + 71 + // Adding a new follow target means touching three maps (schema collection, 72 + // validation whitelist, catalogue metadata). Drift means silent breakage, 73 + // so pin them together. 74 + describe("follow target key parity", () => { 75 + const expected = ["bluesky", "tangled", "sifa"].sort(); 76 + 77 + it("FOLLOW_TARGET_COLLECTION, VALID_FOLLOW_TARGETS, and FOLLOW_TARGET_META agree on targets", () => { 78 + expect(Object.keys(FOLLOW_TARGET_COLLECTION).sort()).toEqual(expected); 79 + expect([...VALID_FOLLOW_TARGETS].sort()).toEqual(expected); 80 + expect(Object.keys(FOLLOW_TARGET_META).sort()).toEqual(expected); 81 + }); 82 + });
+108 -9
lib/automations/action-catalogue.ts
··· 8 8 UserPlus, 9 9 Webhook, 10 10 } from "../../app/icons.js"; 11 + import type { FollowTarget } from "../db/schema.js"; 11 12 12 - export type AddableActionId = "webhook" | "bsky-post" | "record" | "patch-record" | "bookmark"; 13 + export type AddableActionId = 14 + | "webhook" 15 + | "bsky-post" 16 + | "record" 17 + | "patch-record" 18 + | "bookmark" 19 + | "follow-bluesky" 20 + | "follow-tangled" 21 + | "follow-sifa"; 22 + 23 + /** Keys that map to a CSS selector in action-header.css.ts. Keep in sync 24 + * with the `&[data-cat="..."]` selectors there; a typo silently loses the 25 + * accent otherwise. */ 26 + export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled"; 13 27 14 28 type ActionInfo = { 15 29 label: string; 16 30 icon: (typeof ACTION_CATALOGUE)[number]["actions"][number]["icon"]; 17 31 catId: (typeof ACTION_CATALOGUE)[number]["id"]; 32 + /** Optional override for the icon-tile color key (data-cat). Lets follow-sifa 33 + * use a sifa-blue and follow-tangled a grey while still grouping under the 34 + * Bluesky/Apps categories. */ 35 + colorKey?: ColorKey; 36 + /** Domain used to render the per-app favicon next to the icon (bookmark, follow). */ 37 + faviconDomain?: string; 38 + }; 39 + 40 + /** Per-target metadata for follow actions. Shared between catalogue, editor, 41 + * card target-icon, and ActionHeader. */ 42 + export const FOLLOW_TARGET_META: Record< 43 + FollowTarget, 44 + { 45 + catId: "bluesky" | "apps"; 46 + colorKey: ColorKey; 47 + appName: string; 48 + label: string; 49 + faviconDomain: string; 50 + description: string; 51 + } 52 + > = { 53 + bluesky: { 54 + catId: "bluesky", 55 + colorKey: "bluesky", 56 + appName: "Bluesky", 57 + label: "Follow on Bluesky", 58 + faviconDomain: "bsky.app", 59 + description: "Follow someone on Bluesky", 60 + }, 61 + tangled: { 62 + catId: "apps", 63 + colorKey: "tangled", 64 + appName: "Tangled", 65 + label: "Follow on Tangled", 66 + faviconDomain: "tangled.sh", 67 + description: "Follow someone on Tangled", 68 + }, 69 + sifa: { 70 + catId: "apps", 71 + colorKey: "sifa", 72 + appName: "Sifa", 73 + label: "Follow on Sifa", 74 + faviconDomain: "sifa.id", 75 + description: "Follow someone on Sifa", 76 + }, 18 77 }; 19 78 20 79 export const ACTION_CATALOGUE = [ ··· 45 104 available: true, 46 105 }, 47 106 { 107 + id: "follow-bluesky", 108 + label: FOLLOW_TARGET_META.bluesky.label, 109 + description: FOLLOW_TARGET_META.bluesky.description, 110 + icon: UserPlus, 111 + available: true, 112 + colorKey: FOLLOW_TARGET_META.bluesky.colorKey, 113 + faviconDomain: FOLLOW_TARGET_META.bluesky.faviconDomain, 114 + }, 115 + { 48 116 id: "bsky-like", 49 117 label: "Like a post", 50 118 description: "Like a Bluesky post on your behalf", 51 119 icon: Heart, 52 120 available: false, 53 121 }, 54 - { 55 - id: "bsky-follow", 56 - label: "Follow an account", 57 - description: "Follow another Bluesky user", 58 - icon: UserPlus, 59 - available: false, 60 - }, 61 122 ], 62 123 }, 63 124 { ··· 71 132 description: "Create a bookmark note in Margin.at", 72 133 icon: Bookmark, 73 134 available: true, 135 + faviconDomain: "margin.at", 136 + }, 137 + { 138 + id: "follow-sifa", 139 + label: FOLLOW_TARGET_META.sifa.label, 140 + description: FOLLOW_TARGET_META.sifa.description, 141 + icon: UserPlus, 142 + available: true, 143 + colorKey: FOLLOW_TARGET_META.sifa.colorKey, 144 + faviconDomain: FOLLOW_TARGET_META.sifa.faviconDomain, 145 + }, 146 + { 147 + id: "follow-tangled", 148 + label: FOLLOW_TARGET_META.tangled.label, 149 + description: FOLLOW_TARGET_META.tangled.description, 150 + icon: UserPlus, 151 + available: true, 152 + colorKey: FOLLOW_TARGET_META.tangled.colorKey, 153 + faviconDomain: FOLLOW_TARGET_META.tangled.faviconDomain, 74 154 }, 75 155 ], 76 156 }, ··· 106 186 107 187 export const ACTION_INFO_BY_TYPE: Record<string, ActionInfo> = Object.fromEntries( 108 188 ACTION_CATALOGUE.flatMap((cat) => 109 - cat.actions.map((a) => [a.id, { label: a.label, icon: a.icon, catId: cat.id }] as const), 189 + cat.actions.map( 190 + (a) => 191 + [ 192 + a.id, 193 + { 194 + label: a.label, 195 + icon: a.icon, 196 + catId: cat.id, 197 + ...("colorKey" in a && a.colorKey ? { colorKey: a.colorKey } : {}), 198 + ...("faviconDomain" in a && a.faviconDomain ? { faviconDomain: a.faviconDomain } : {}), 199 + }, 200 + ] as const, 201 + ), 110 202 ), 111 203 ); 204 + 205 + /** Follow actions split into three UI tiles keyed by target; everything else 206 + * uses its $type. Used for icon lookup, per-tile "#N" counters, and headers. */ 207 + export function actionTypeKey(action: { $type: string; target?: string }): string { 208 + if (action.$type === "follow" && action.target) return `follow-${action.target}`; 209 + return action.$type; 210 + }
+8
lib/automations/labels.ts
··· 12 12 record: "Create Record", 13 13 "bsky-post": "Bluesky Post", 14 14 "patch-record": "Update Record", 15 + bookmark: "Bookmark", 16 + follow: "Follow", 17 + }; 18 + 19 + export const followTargetLabels: Record<string, string> = { 20 + bluesky: "Bluesky", 21 + tangled: "Tangled", 22 + sifa: "Sifa", 15 23 }; 16 24 17 25 export const operationLabels: Record<string, string> = {
+56
lib/automations/pds-serialize.ts
··· 1 + import type { Action } from "../db/schema.js"; 2 + import type { PdsAction } from "./pds.js"; 3 + 4 + /** Serialize a stored Action into its PDS-record shape. Split from pds.ts so 5 + * tests that mock the OAuth-backed PDS client don't need to re-stub this. */ 6 + export function toPdsAction(a: Action): PdsAction { 7 + if (a.$type === "webhook") { 8 + return { 9 + $type: "run.airglow.automation#webhookAction", 10 + callbackUrl: a.callbackUrl, 11 + ...(a.comment ? { comment: a.comment } : {}), 12 + }; 13 + } 14 + if (a.$type === "bsky-post") { 15 + return { 16 + $type: "run.airglow.automation#bskyPostAction", 17 + textTemplate: a.textTemplate, 18 + ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}), 19 + ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}), 20 + ...(a.comment ? { comment: a.comment } : {}), 21 + }; 22 + } 23 + if (a.$type === "patch-record") { 24 + return { 25 + $type: "run.airglow.automation#patchRecordAction", 26 + targetCollection: a.targetCollection, 27 + baseRecordUri: a.baseRecordUri, 28 + recordTemplate: a.recordTemplate, 29 + ...(a.comment ? { comment: a.comment } : {}), 30 + }; 31 + } 32 + if (a.$type === "bookmark") { 33 + return { 34 + $type: "run.airglow.automation#bookmarkAction", 35 + targetSource: a.targetSource, 36 + ...(a.targetTitle ? { targetTitle: a.targetTitle } : {}), 37 + ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 38 + ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 39 + ...(a.comment ? { comment: a.comment } : {}), 40 + }; 41 + } 42 + if (a.$type === "follow") { 43 + return { 44 + $type: "run.airglow.automation#followAction", 45 + target: a.target, 46 + subject: a.subject, 47 + ...(a.comment ? { comment: a.comment } : {}), 48 + }; 49 + } 50 + return { 51 + $type: "run.airglow.automation#recordAction", 52 + targetCollection: a.targetCollection, 53 + recordTemplate: a.recordTemplate, 54 + ...(a.comment ? { comment: a.comment } : {}), 55 + }; 56 + }
+9 -1
lib/automations/pds.ts
··· 69 69 comment?: string; 70 70 }; 71 71 72 + type PdsFollowAction = { 73 + $type: "run.airglow.automation#followAction"; 74 + target: "bluesky" | "tangled" | "sifa"; 75 + subject: string; 76 + comment?: string; 77 + }; 78 + 72 79 export type PdsAction = 73 80 | PdsWebhookAction 74 81 | PdsRecordAction 75 82 | PdsBskyPostAction 76 83 | PdsPatchRecordAction 77 - | PdsBookmarkAction; 84 + | PdsBookmarkAction 85 + | PdsFollowAction; 78 86 79 87 type PdsCondition = { 80 88 field: string;
+6
lib/automations/sanitize.ts
··· 30 30 bodyValue?: string; 31 31 tags?: string[]; 32 32 comment?: string; 33 + } 34 + | { 35 + $type: "follow"; 36 + target: "bluesky" | "tangled" | "sifa"; 37 + subject: string; 38 + comment?: string; 33 39 }; 34 40 35 41 /** Strip instance-local secrets and truncate webhook URLs to domain-only. */
+25 -2
lib/db/schema.ts
··· 50 50 comment?: string; 51 51 }; 52 52 53 + export type FollowTarget = "bluesky" | "tangled" | "sifa"; 54 + 55 + export type FollowAction = { 56 + $type: "follow"; 57 + target: FollowTarget; 58 + subject: string; 59 + comment?: string; 60 + }; 61 + 53 62 export type Action = 54 63 | WebhookAction 55 64 | RecordAction 56 65 | BskyPostAction 57 66 | PatchRecordAction 58 - | BookmarkAction; 67 + | BookmarkAction 68 + | FollowAction; 69 + 70 + /** Map a follow target to the collection NSID where the record is written. */ 71 + export const FOLLOW_TARGET_COLLECTION: Record<FollowTarget, string> = { 72 + bluesky: "app.bsky.graph.follow", 73 + tangled: "sh.tangled.graph.follow", 74 + sifa: "id.sifa.graph.follow", 75 + }; 59 76 60 77 /** Action types that produce a record result (uri, cid, rkey) for chaining. */ 61 - const RECORD_PRODUCING_TYPES = new Set(["record", "bsky-post", "patch-record", "bookmark"]); 78 + const RECORD_PRODUCING_TYPES = new Set([ 79 + "record", 80 + "bsky-post", 81 + "patch-record", 82 + "bookmark", 83 + "follow", 84 + ]); 62 85 export function isRecordProducingAction(type: string): boolean { 63 86 return RECORD_PRODUCING_TYPES.has(type); 64 87 }
+16 -1
lib/jetstream/handler.ts
··· 5 5 import { executeBskyPost } from "../actions/bsky-post.js"; 6 6 import { executePatchRecord } from "../actions/patch-record.js"; 7 7 import { executeBookmark } from "../actions/bookmark.js"; 8 + import { executeFollow } from "../actions/follow.js"; 9 + import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 8 10 import { resolveFetches } from "../actions/fetcher.js"; 9 11 import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 10 12 import { parseAtUri } from "../pds/resolver.js"; ··· 70 72 ? executePatchRecord 71 73 : action.$type === "bookmark" 72 74 ? executeBookmark 73 - : dispatch; 75 + : action.$type === "follow" 76 + ? executeFollow 77 + : dispatch; 74 78 75 79 try { 76 80 const result: ActionResult = await handler(match, i, fetchContext); ··· 135 139 ); 136 140 message = `Would post to Bluesky`; 137 141 payload = JSON.stringify({ text, langs: action.langs, labels: action.labels }); 142 + } catch (err) { 143 + error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 144 + } 145 + } else if (action.$type === "follow") { 146 + try { 147 + const subject = ( 148 + await renderTextTemplate(action.subject, match.event, fetchContext, match.automation) 149 + ).trim(); 150 + const collection = FOLLOW_TARGET_COLLECTION[action.target]; 151 + message = `Would follow ${subject || "(empty)"} on ${action.target}`; 152 + payload = JSON.stringify({ collection, subject }); 138 153 } catch (err) { 139 154 error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 140 155 }
+10
lib/test/fixtures.ts
··· 6 6 BskyPostAction, 7 7 PatchRecordAction, 8 8 BookmarkAction, 9 + FollowAction, 9 10 FetchStep, 10 11 } from "../db/schema.js"; 11 12 import type { MatchedEvent } from "../jetstream/consumer.js"; ··· 80 81 return { 81 82 $type: "bookmark", 82 83 targetSource: "https://example.com/{{event.commit.rkey}}", 84 + ...overrides, 85 + }; 86 + } 87 + 88 + export function makeFollowAction(overrides?: Partial<FollowAction>): FollowAction { 89 + return { 90 + $type: "follow", 91 + target: "bluesky", 92 + subject: "{{event.did}}", 83 93 ...overrides, 84 94 }; 85 95 }
+12
public/static/favicons/sifa.id.f49fecdf.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;"> 3 + <g transform="matrix(0.333333,0,0,0.333333,37.583333,37.083333)"> 4 + <path d="M128,71.5C159.183,71.5 184.5,96.817 184.5,128C184.5,159.183 159.183,184.5 128,184.5C96.817,184.5 71.5,159.183 71.5,128C71.5,96.817 96.817,71.5 128,71.5ZM128,104.5C115.03,104.5 104.5,115.03 104.5,128C104.5,140.97 115.03,151.5 128,151.5C140.97,151.5 151.5,140.97 151.5,128C151.5,115.03 140.97,104.5 128,104.5Z" style="fill:#205EA6;"/> 5 + </g> 6 + <g transform="matrix(0.333333,0,0,0.333333,37.583333,37.083333)"> 7 + <path d="M174.866,194.259C182.45,189.218 192.7,191.282 197.741,198.866C202.782,206.45 200.718,216.7 193.134,221.741C175.432,233.507 150.846,240.5 128,240.5C66.284,240.5 15.5,189.716 15.5,128C15.5,66.284 66.284,15.5 128,15.5C189.716,15.5 240.5,66.284 240.5,128C240.5,160.538 225.46,184.5 196,184.5C166.54,184.5 151.5,160.538 151.5,128L151.5,88C151.5,78.893 158.893,71.5 168,71.5C177.107,71.5 184.5,78.893 184.5,88L184.5,128C184.5,134.408 185.237,140.363 187.279,145.164C188.851,148.858 191.536,151.5 196,151.5C200.464,151.5 203.149,148.858 204.721,145.164C206.763,140.363 207.5,134.408 207.5,128C207.5,84.388 171.612,48.5 128,48.5C84.388,48.5 48.5,84.388 48.5,128C48.5,171.612 84.388,207.5 128,207.5C144.415,207.5 162.148,202.713 174.866,194.259Z" style="fill:#205EA6;"/> 8 + </g> 9 + <path d="M176,47.75 L208,79.75 L176,111.75 L144,79.75 Z" style="fill:none;stroke:#100F0F;stroke-width:12px;"/> 10 + <path d="M80,144 L112,176 L80,208 L48,176 Z" style="fill:none;stroke:#100F0F;stroke-width:12px;"/> 11 + <path d="M152,192 L176,160 L200,192" style="fill:none;stroke:#5E409D;stroke-width:11px;"/> 12 + </svg>