Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: simplify follow actions

Hugo 22a41a8d 49709cf3

+206 -242
+2 -4
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 + import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts"; 5 5 import { Button } from "../Button/index.tsx"; 6 6 import { Favicon, NsidCode } from "../NsidCode/index.tsx"; 7 7 import * as s from "./styles.css.ts"; ··· 24 24 if (pds?.$type === "bsky-post") return <Favicon domain="bsky.app" class={s.targetFavicon} />; 25 25 if (pds?.$type === "bookmark") return <Favicon domain="margin.at" class={s.targetFavicon} />; 26 26 if (pds?.$type === "follow") 27 - return ( 28 - <Favicon domain={FOLLOW_TARGET_META[pds.target].faviconDomain} class={s.targetFavicon} /> 29 - ); 27 + return <Favicon domain={FOLLOW_TARGETS[pds.target].faviconDomain} class={s.targetFavicon} />; 30 28 if (pds?.$type === "record" || pds?.$type === "patch-record") 31 29 return <Favicon domain={nsidToDomain(pds.targetCollection)} class={s.targetFavicon} />; 32 30 return <Webhook size={14} />;
+2 -2
app/islands/AutomationForm.tsx
··· 9 9 } from "../../lib/db/schema.js"; 10 10 import { 11 11 ACTION_CATALOGUE, 12 - FOLLOW_TARGET_META, 13 12 actionTypeKey, 14 13 type AddableActionId, 15 14 } from "../../lib/automations/action-catalogue.js"; 15 + import { FOLLOW_TARGETS } from "../../lib/automations/follow-targets.js"; 16 16 import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 17 17 import RecordFormBuilder from "./RecordFormBuilder.js"; 18 18 import { ActionHeader } from "../components/ActionHeader/index.js"; ··· 652 652 action: FollowDraft; 653 653 onChange: (a: FollowDraft) => void; 654 654 }) { 655 - const meta = FOLLOW_TARGET_META[action.target]; 655 + const meta = FOLLOW_TARGETS[action.target]; 656 656 return ( 657 657 <> 658 658 <div class={s.fieldGroup}>
+4 -9
app/routes/dashboard/automations/[rkey].tsx
··· 11 11 Activity, 12 12 } from "../../../icons.js"; 13 13 import { getRateLimitCounts } from "@/jetstream/rate-limit.js"; 14 - import { 15 - opLabels, 16 - actionTypeLabels, 17 - operationLabels, 18 - followTargetLabels, 19 - } from "@/automations/labels.js"; 14 + import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 20 15 import { actionTypeKey } from "@/automations/action-catalogue.js"; 21 - import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js"; 16 + import { FOLLOW_TARGETS } from "@/automations/follow-targets.js"; 22 17 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 23 18 import { db } from "@/db/index.js"; 24 19 import { automations, deliveryLogs } from "@/db/schema.js"; ··· 295 290 ) : action.$type === "follow" ? ( 296 291 <> 297 292 <dt>App</dt> 298 - <dd>{followTargetLabels[action.target] ?? action.target}</dd> 293 + <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd> 299 294 <dt>Collection</dt> 300 295 <dd> 301 - <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode> 296 + <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode> 302 297 </dd> 303 298 <dt>Subject DID</dt> 304 299 <dd>
+4 -4
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, followTargetLabels } from "@/automations/labels.js"; 6 + import { opLabels, operationLabels } from "@/automations/labels.js"; 7 7 import { actionTypeKey } from "@/automations/action-catalogue.js"; 8 - import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js"; 8 + import { FOLLOW_TARGETS } from "@/automations/follow-targets.js"; 9 9 import { db } from "@/db/index.js"; 10 10 import { users, automations } from "@/db/schema.js"; 11 11 import { sanitizeActions } from "@/automations/sanitize.js"; ··· 265 265 ) : action.$type === "follow" ? ( 266 266 <> 267 267 <dt>App</dt> 268 - <dd>{followTargetLabels[action.target] ?? action.target}</dd> 268 + <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd> 269 269 <dt>Collection</dt> 270 270 <dd> 271 - <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode> 271 + <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode> 272 272 </dd> 273 273 <dt>Subject DID</dt> 274 274 <dd>
+56 -90
docs/follow-actions.md
··· 25 25 2. **Profile pre-check** — does the subject have a profile record on the 26 26 target graph? If not, return 204 ("skip"). Implemented as a single 27 27 `fetchRecord` call against the target's profile NSID 28 - (see `FOLLOW_TARGET_PROFILE` in [lib/db/schema.ts](../lib/db/schema.ts)). 28 + (see `FOLLOW_TARGETS[target].profileCollection` / `profileRkey` in 29 + [lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts)). 29 30 3. **Already-follows pre-check** — does the automation owner already follow 30 31 the subject on the target graph? If so, return 204. Implemented by 31 32 constructing a synthetic `FetchStepSearch` and handing it to ··· 71 72 ### Profile check 72 73 73 74 ``` 74 - at://{subject}/{FOLLOW_TARGET_PROFILE[target].collection}/{FOLLOW_TARGET_PROFILE[target].rkey} 75 + at://{subject}/{FOLLOW_TARGETS[target].profileCollection}/{FOLLOW_TARGETS[target].profileRkey} 75 76 ``` 76 77 77 78 A `fetchRecord` call. `found: false` → skip with `"Skipped: no <AppName> ··· 88 89 kind: "search", 89 90 name: "__follow_preflight_already_follows", 90 91 repo: match.automation.did, 91 - collection: FOLLOW_TARGET_COLLECTION[action.target], 92 + collection: FOLLOW_TARGETS[action.target].collection, 92 93 where: [{ field: "subject", operator: "eq", value: subject }], 93 94 limit: 1, 94 95 }; ··· 176 177 177 178 # Adding a new follow target 178 179 179 - Walk-through for adding a hypothetical new social graph "Foo" living at 180 - `org.foo` with profile collection `org.foo.actor.profile/self` and follow 181 - collection `org.foo.graph.follow`. Three of the changes are caught by the 182 - parity test in [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts); 183 - the rest are user-visible enough that a missed touchpoint will surface in 184 - manual testing or a related test. 185 - 186 - ## 1. Lexicon 187 - 188 - [lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add 189 - `"foo"` to `followAction.properties.target.knownValues`. After editing, run: 190 - 191 - ```sh 192 - goat lex lint lexicons/ 193 - ``` 180 + Per-target metadata lives in a single registry — `FOLLOW_TARGETS` in 181 + [lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts). 182 + The `FollowTarget` union type, the runtime allow-list, the catalogue tiles, 183 + the executor's collection / profile NSID lookups, and the dashboard's "App" 184 + label all derive from it. Adding a target is one registry entry plus a 185 + matching push to the lexicon, plus the layered CSS / favicon work that's 186 + genuinely per-target. 194 187 195 - ## 2. Backend types and maps 188 + Walk-through for adding a hypothetical new social graph "Foo" with profile 189 + collection `org.foo.actor.profile/self` and follow collection 190 + `org.foo.graph.follow`. 196 191 197 - [lib/db/schema.ts](../lib/db/schema.ts) — three things: 198 - 199 - ```ts 200 - export type FollowTarget = "bluesky" | "tangled" | "sifa" | "foo"; 201 - 202 - export const FOLLOW_TARGET_COLLECTION: Record<FollowTarget, string> = { 203 - // ... 204 - foo: "org.foo.graph.follow", 205 - }; 206 - 207 - export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = { 208 - // ... 209 - foo: { collection: "org.foo.actor.profile", rkey: "self" }, 210 - }; 211 - ``` 212 - 213 - [lib/actions/validation.ts](../lib/actions/validation.ts): 214 - 215 - ```ts 216 - export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa", "foo"]); 217 - ``` 218 - 219 - …and update the `Invalid follow target` error string nearby to mention the 220 - new target. 221 - 222 - [lib/automations/pds.ts](../lib/automations/pds.ts) and 223 - [lib/automations/sanitize.ts](../lib/automations/sanitize.ts) — extend the 224 - inline `target: "bluesky" | "tangled" | "sifa"` literal type to include 225 - `"foo"`. (These can't reference `FollowTarget` because they're at the PDS 226 - serialization boundary, but a future refactor could collapse them.) 227 - 228 - ## 3. UI metadata 192 + ## 1. Registry entry 229 193 230 194 [lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts) — 231 - add an entry to `FOLLOW_TARGET_META`: 195 + add `"foo"` to the `ColorKey` union (so the theme token below is reachable), 196 + then add an entry to `FOLLOW_TARGETS`. Insertion order in the registry 197 + controls the order tiles appear within their category in the UI. 232 198 233 199 ```ts 234 200 foo: { 235 201 catId: "apps", // or "bluesky" if it belongs in the Bluesky tile group 236 - colorKey: "foo", // requires a matching theme color, see step 4 202 + colorKey: "foo", 237 203 appName: "Foo", 238 204 label: "Follow on Foo", 239 - faviconDomain: "foo.example", 240 205 description: "Follow someone on Foo", 206 + faviconDomain: "foo.example", 207 + collection: "org.foo.graph.follow", 208 + profileCollection: "org.foo.actor.profile", 209 + profileRkey: "self", 241 210 }, 242 211 ``` 243 212 244 - Add `"foo"` to the `ColorKey` union in the same file. 213 + That single entry feeds: 214 + 215 + - `FollowTarget` (the literal union — derived from `keyof typeof FOLLOW_TARGETS`) 216 + - `VALID_FOLLOW_TARGETS` (runtime allow-list, derived from `Object.keys`) 217 + - The catalogue tile (auto-generated under the right category in `action-catalogue.ts`) 218 + - The executor's profile pre-check URI and follow record collection 219 + - The dashboard / public-page "App" label and "Collection" NSID display 245 220 246 - [lib/automations/labels.ts](../lib/automations/labels.ts) — add 247 - `foo: "Foo"` to `followTargetLabels` (used by the dashboard log view). 221 + No other code-side maps to update. 248 222 249 - ## 4. Catalogue tile 223 + ## 2. Lexicon 250 224 251 - [lib/automations/action-catalogue.ts](../lib/automations/action-catalogue.ts) — 252 - add `"follow-foo"` to the `AddableActionId` union and an entry under the 253 - appropriate category: 225 + [lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add 226 + `"foo"` to `followAction.properties.target.knownValues`. After editing, run: 254 227 255 - ```ts 256 - { 257 - id: "follow-foo", 258 - label: FOLLOW_TARGET_META.foo.label, 259 - description: FOLLOW_TARGET_META.foo.description, 260 - icon: UserPlus, 261 - available: true, 262 - colorKey: FOLLOW_TARGET_META.foo.colorKey, 263 - faviconDomain: FOLLOW_TARGET_META.foo.faviconDomain, 264 - }, 228 + ```sh 229 + goat lex lint lexicons/ 265 230 ``` 266 231 267 - Update the corresponding tile-order test in 268 - [action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) so 269 - the new ordering is intentional and reviewable. 232 + The lexicon parity test in 233 + [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) 234 + asserts that `Object.keys(FOLLOW_TARGETS)` matches `knownValues` — drift in 235 + either direction fails the suite. 270 236 271 - ## 5. Theme color 237 + ## 3. Theme color 272 238 273 239 [app/styles/tokens/colors.ts](../app/styles/tokens/colors.ts) — add `foo` 274 240 and `fooSubtle` entries in both the dark and light palettes. ··· 277 243 tokens on `vars.color`. 278 244 279 245 [app/styles/action-header.css.ts](../app/styles/action-header.css.ts) — add 280 - the matching `&[data-cat="foo"]` selector. (A typo silently loses the accent.) 246 + the matching `&[data-cat="foo"]` selector. (A typo silently loses the 247 + accent — the token name has to match the registry's `colorKey`.) 281 248 282 - ## 6. Favicon 249 + vanilla-extract requires statically-declared CSS variables, so this layer 250 + can't be derived from the registry; the registry's `colorKey` field is the 251 + binding between a target and its theme tokens. 252 + 253 + ## 4. Favicon 283 254 284 255 [app/components/Favicon/index.tsx](../app/components/Favicon/index.tsx) — 285 256 register the local SVG so the tile renders the right brandmark instead of a 286 257 generic icon. Drop the SVG into `public/static/favicons/foo.example.<hash>.svg` 287 258 and add the entry to the favicon map. 288 259 289 - ## 7. Tests 260 + ## 5. Tests 290 261 291 - The parity test 292 - ([lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts)) 293 - already pins `FOLLOW_TARGET_COLLECTION` ↔ `VALID_FOLLOW_TARGETS` ↔ 294 - `FOLLOW_TARGET_META`, so it will fail until those three are in sync. Beyond 295 - that, add or update: 262 + The lexicon parity test already pins `FOLLOW_TARGETS` to the lexicon's 263 + `knownValues`, so it fails until those two are in sync. Beyond that: 296 264 265 + - Update the tile-order assertions in 266 + [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) 267 + if the new tile slots into an existing category — that order is 268 + product-visible and intentional. 297 269 - [lib/actions/follow.test.ts](../lib/actions/follow.test.ts) — extend 298 270 `"maps tangled and sifa targets to their collections"` to cover `foo`, 299 271 and `"uses the target-specific profile NSID for the profile pre-check"` 300 272 to assert the new profile URI shape. 301 273 302 - > **Drift caveat.** The parity test does **not** include 303 - > `FOLLOW_TARGET_PROFILE`. If you add a new target and forget the profile 304 - > entry, the executor will throw `Cannot read property 'collection' of 305 - undefined` at the first fire. Worth adding to the parity test next time 306 - > someone touches that file. 307 - 308 - ## 8. Manual verification 274 + ## 6. Manual verification 309 275 310 276 Run `vp check && vp test`, then in `vp dev`: 311 277
+10 -17
lib/actions/follow.ts
··· 1 - import { 2 - type FollowAction, 3 - type FetchStepSearch, 4 - FOLLOW_TARGET_COLLECTION, 5 - FOLLOW_TARGET_PROFILE, 6 - } from "../db/schema.js"; 1 + import { type FollowAction, type FetchStepSearch } from "../db/schema.js"; 7 2 import { createArbitraryRecord } from "../automations/pds.js"; 8 3 import { fetchRecord } from "../pds/resolver.js"; 9 4 import { executeSearch } from "./searcher.js"; 10 5 import { renderTextTemplate, type FetchContext } from "./template.js"; 11 6 import { RETRY_DELAYS, SKIP_STATUS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 12 7 import { DID_RE } from "./validation.js"; 13 - import { FOLLOW_TARGET_META } from "../automations/follow-targets.js"; 8 + import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 14 9 import type { ActionResult } from "./executor.js"; 15 10 import type { MatchedEvent } from "../jetstream/consumer.js"; 16 11 ··· 18 13 action: FollowAction, 19 14 subject: string, 20 15 ): Promise<ActionResult | null> { 21 - const profile = FOLLOW_TARGET_PROFILE[action.target]; 22 - const profileUri = `at://${subject}/${profile.collection}/${profile.rkey}`; 16 + const target = FOLLOW_TARGETS[action.target]; 17 + const profileUri = `at://${subject}/${target.profileCollection}/${target.profileRkey}`; 23 18 try { 24 19 const result = await fetchRecord(profileUri); 25 20 if (!result.found) { 26 - const appName = FOLLOW_TARGET_META[action.target].appName; 27 21 return { 28 22 statusCode: SKIP_STATUS, 29 - message: `Skipped: no ${appName} profile for ${subject}`, 23 + message: `Skipped: no ${target.appName} profile for ${subject}`, 30 24 }; 31 25 } 32 26 } catch (err) { ··· 63 57 action: FollowAction, 64 58 subject: string, 65 59 ): Promise<ActionResult | null> { 66 - const collection = FOLLOW_TARGET_COLLECTION[action.target]; 60 + const target = FOLLOW_TARGETS[action.target]; 67 61 const synthetic: FetchStepSearch = { 68 62 kind: "search", 69 63 name: "__follow_preflight_already_follows", 70 64 repo: match.automation.did, 71 - collection, 65 + collection: target.collection, 72 66 where: [{ field: "subject", operator: "eq", value: subject }], 73 67 limit: 1, 74 68 }; 75 69 try { 76 70 const entry = await executeSearch(synthetic, match.event, match.automation.did, {}); 77 71 if (entry.found) { 78 - const appName = FOLLOW_TARGET_META[action.target].appName; 79 72 return { 80 73 statusCode: SKIP_STATUS, 81 - message: `Skipped: already following ${subject} on ${appName}`, 74 + message: `Skipped: already following ${subject} on ${target.appName}`, 82 75 }; 83 76 } 84 77 } catch (err) { ··· 127 120 const dupSkip = await checkNotAlreadyFollowing(match, action, subject); 128 121 if (dupSkip) return dupSkip; 129 122 130 - const collection = FOLLOW_TARGET_COLLECTION[action.target]; 123 + const collection = FOLLOW_TARGETS[action.target].collection; 131 124 const record: Record<string, unknown> = { 132 125 subject, 133 126 createdAt: new Date().toISOString(), ··· 147 140 function actionPayload(action: FollowAction): string { 148 141 return JSON.stringify({ 149 142 target: action.target, 150 - collection: FOLLOW_TARGET_COLLECTION[action.target], 143 + collection: FOLLOW_TARGETS[action.target].collection, 151 144 subject: action.subject, 152 145 }); 153 146 }
+7 -4
lib/actions/validation.ts
··· 4 4 import { isValidNsid } from "../lexicons/resolver.js"; 5 5 import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js"; 6 6 import type { Condition, FetchStepSearch } from "../db/schema.js"; 7 + import { 8 + FOLLOW_TARGETS, 9 + VALID_FOLLOW_TARGETS, 10 + type FollowTarget, 11 + } from "../automations/follow-targets.js"; 7 12 8 13 export type ActionInput = 9 14 | { ··· 37 42 } 38 43 | { 39 44 type: "follow"; 40 - target: "bluesky" | "tangled" | "sifa"; 45 + target: FollowTarget; 41 46 subject: string; 42 47 comment?: string; 43 48 }; 44 - 45 - export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa"]); 46 49 47 50 export const VALID_OPERATORS = new Set([ 48 51 "eq", ··· 420 423 if (!VALID_FOLLOW_TARGETS.has(input.target)) { 421 424 return { 422 425 valid: false, 423 - error: `Invalid follow target "${input.target}". Must be one of: bluesky, tangled, sifa`, 426 + error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`, 424 427 }; 425 428 } 426 429 if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) {
+18 -18
lib/automations/action-catalogue.test.ts
··· 1 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"; 2 + import { readFileSync } from "node:fs"; 3 + import { fileURLToPath } from "node:url"; 4 + import { ACTION_CATALOGUE, ACTION_INFO_BY_TYPE, actionTypeKey } from "./action-catalogue.js"; 5 + import { FOLLOW_TARGETS } from "./follow-targets.js"; 10 6 11 7 // Tile order is a product-visible detail: reorders should be intentional and 12 8 // show up in review, not slip in silently with other catalogue edits. ··· 68 64 }); 69 65 }); 70 66 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); 67 + // `FOLLOW_TARGETS` is the single source of truth for code-side per-target 68 + // metadata; the only data-layer drift surface left is the lexicon JSON, which 69 + // declares the wire format. Pin them together: any new target must show up in 70 + // both, or this fails. 71 + describe("follow target lexicon parity", () => { 72 + it("FOLLOW_TARGETS keys match the lexicon's followAction.target.knownValues", () => { 73 + const lexiconPath = fileURLToPath( 74 + new URL("../../lexicons/run/airglow/automation.json", import.meta.url), 75 + ); 76 + const lexicon = JSON.parse(readFileSync(lexiconPath, "utf8")) as { 77 + defs: { followAction: { properties: { target: { knownValues: string[] } } } }; 78 + }; 79 + const known = lexicon.defs.followAction.properties.target.knownValues; 80 + expect([...known].sort()).toEqual(Object.keys(FOLLOW_TARGETS).sort()); 81 81 }); 82 82 });
+27 -36
lib/automations/action-catalogue.ts
··· 8 8 UserPlus, 9 9 Webhook, 10 10 } from "../../app/icons.js"; 11 - import { FOLLOW_TARGET_META, type ColorKey } from "./follow-targets.js"; 12 - 13 - // Re-exported so existing consumers can keep importing from action-catalogue. 14 - // The data itself lives in follow-targets.ts (no icon / JSX dependencies) so 15 - // the backend action pipeline can read `appName` without pulling in UI code. 16 - export { FOLLOW_TARGET_META, type ColorKey }; 11 + import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js"; 17 12 18 13 export type AddableActionId = 19 14 | "webhook" ··· 21 16 | "record" 22 17 | "patch-record" 23 18 | "bookmark" 24 - | "follow-bluesky" 25 - | "follow-tangled" 26 - | "follow-sifa"; 19 + | `follow-${FollowTarget}`; 27 20 28 21 type ActionInfo = { 29 22 label: string; ··· 37 30 faviconDomain?: string; 38 31 }; 39 32 33 + /** Build the catalogue tile for a given follow target. Insertion order in 34 + * `FOLLOW_TARGETS` controls the order tiles appear within their category. */ 35 + function followTileFor(target: FollowTarget) { 36 + const t = FOLLOW_TARGETS[target]; 37 + return { 38 + id: `follow-${target}` as const, 39 + label: t.label, 40 + description: t.description, 41 + icon: UserPlus, 42 + available: true, 43 + colorKey: t.colorKey, 44 + faviconDomain: t.faviconDomain, 45 + }; 46 + } 47 + 48 + const followTilesByCat: Record<"bluesky" | "apps", ReturnType<typeof followTileFor>[]> = { 49 + bluesky: [], 50 + apps: [], 51 + }; 52 + for (const target of Object.keys(FOLLOW_TARGETS) as FollowTarget[]) { 53 + followTilesByCat[FOLLOW_TARGETS[target].catId].push(followTileFor(target)); 54 + } 55 + 40 56 export const ACTION_CATALOGUE = [ 41 57 { 42 58 id: "webhook", ··· 64 80 icon: MessageSquare, 65 81 available: true, 66 82 }, 67 - { 68 - id: "follow-bluesky", 69 - label: FOLLOW_TARGET_META.bluesky.label, 70 - description: FOLLOW_TARGET_META.bluesky.description, 71 - icon: UserPlus, 72 - available: true, 73 - colorKey: FOLLOW_TARGET_META.bluesky.colorKey, 74 - faviconDomain: FOLLOW_TARGET_META.bluesky.faviconDomain, 75 - }, 83 + ...followTilesByCat.bluesky, 76 84 { 77 85 id: "bsky-like", 78 86 label: "Like a post", ··· 95 103 available: true, 96 104 faviconDomain: "margin.at", 97 105 }, 98 - { 99 - id: "follow-sifa", 100 - label: FOLLOW_TARGET_META.sifa.label, 101 - description: FOLLOW_TARGET_META.sifa.description, 102 - icon: UserPlus, 103 - available: true, 104 - colorKey: FOLLOW_TARGET_META.sifa.colorKey, 105 - faviconDomain: FOLLOW_TARGET_META.sifa.faviconDomain, 106 - }, 107 - { 108 - id: "follow-tangled", 109 - label: FOLLOW_TARGET_META.tangled.label, 110 - description: FOLLOW_TARGET_META.tangled.description, 111 - icon: UserPlus, 112 - available: true, 113 - colorKey: FOLLOW_TARGET_META.tangled.colorKey, 114 - faviconDomain: FOLLOW_TARGET_META.tangled.faviconDomain, 115 - }, 106 + ...followTilesByCat.apps, 116 107 ], 117 108 }, 118 109 {
+65 -28
lib/automations/follow-targets.ts
··· 1 - import type { FollowTarget } from "../db/schema.js"; 2 - 3 1 /** Keys that map to a CSS selector in action-header.css.ts. Keep in sync 4 2 * with the `&[data-cat="..."]` selectors there; a typo silently loses the 5 3 * accent otherwise. */ 6 4 export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled"; 7 5 8 - /** Per-target metadata for follow actions. Lives in a pure-data module (no JSX 9 - * / icon imports) so backend code paths like `executeFollow` and the dry-run 10 - * logger can read `appName` without pulling in UI components. The action 11 - * catalogue in `action-catalogue.ts` re-exports this for UI consumers. */ 12 - export const FOLLOW_TARGET_META: Record< 13 - FollowTarget, 14 - { 15 - catId: "bluesky" | "apps"; 16 - colorKey: ColorKey; 17 - appName: string; 18 - label: string; 19 - faviconDomain: string; 20 - description: string; 21 - } 22 - > = { 6 + type FollowTargetEntry = { 7 + /** Catalogue category the tile lives under. */ 8 + catId: "bluesky" | "apps"; 9 + /** Theme color token (`data-cat` selector + vars.color.<key>). */ 10 + colorKey: ColorKey; 11 + /** Display name of the target app. */ 12 + appName: string; 13 + /** Catalogue tile label. */ 14 + label: string; 15 + /** Catalogue tile description. */ 16 + description: string; 17 + /** Domain used to render the per-app favicon. */ 18 + faviconDomain: string; 19 + /** NSID where the follow record is written. */ 20 + collection: string; 21 + /** NSID of the profile record we pre-flight check the subject for. */ 22 + profileCollection: string; 23 + /** Rkey of that profile record (effectively always "self" today, but kept 24 + * per-target for the rare case a future graph diverges). */ 25 + profileRkey: string; 26 + }; 27 + 28 + /** Single source of truth for every code-side follow-target detail. Adding a 29 + * fourth social graph is one entry here + a matching push to the lexicon's 30 + * `followAction.target.knownValues`; the lexicon parity test in 31 + * `action-catalogue.test.ts` enforces the latter. 32 + * 33 + * Insertion order doubles as the catalogue tile order within each category 34 + * (Bluesky tile group: bsky-post → follow-bluesky → ...; Apps tile group: 35 + * bookmark → follow-sifa → follow-tangled). Reorder here to reorder the UI. 36 + * 37 + * Pure-data module: no JSX / icon imports, so backend code paths can read 38 + * `appName` etc. without pulling in UI components. */ 39 + export const FOLLOW_TARGETS = { 23 40 bluesky: { 24 41 catId: "bluesky", 25 42 colorKey: "bluesky", 26 43 appName: "Bluesky", 27 44 label: "Follow on Bluesky", 28 - faviconDomain: "bsky.app", 29 45 description: "Follow someone on Bluesky", 30 - }, 31 - tangled: { 32 - catId: "apps", 33 - colorKey: "tangled", 34 - appName: "Tangled", 35 - label: "Follow on Tangled", 36 - faviconDomain: "tangled.sh", 37 - description: "Follow someone on Tangled", 46 + faviconDomain: "bsky.app", 47 + collection: "app.bsky.graph.follow", 48 + profileCollection: "app.bsky.actor.profile", 49 + profileRkey: "self", 38 50 }, 39 51 sifa: { 40 52 catId: "apps", 41 53 colorKey: "sifa", 42 54 appName: "Sifa", 43 55 label: "Follow on Sifa", 56 + description: "Follow someone on Sifa", 44 57 faviconDomain: "sifa.id", 45 - description: "Follow someone on Sifa", 58 + collection: "id.sifa.graph.follow", 59 + // Sifa's profile lexicon NSID literally ends in `.self` (see 60 + // at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.self), 61 + // and the rkey is also `self`, so the URI is `at://{did}/id.sifa.profile.self/self`. 62 + profileCollection: "id.sifa.profile.self", 63 + profileRkey: "self", 46 64 }, 47 - }; 65 + tangled: { 66 + catId: "apps", 67 + colorKey: "tangled", 68 + appName: "Tangled", 69 + label: "Follow on Tangled", 70 + description: "Follow someone on Tangled", 71 + faviconDomain: "tangled.sh", 72 + collection: "sh.tangled.graph.follow", 73 + profileCollection: "sh.tangled.actor.profile", 74 + profileRkey: "self", 75 + }, 76 + } as const satisfies Record<string, FollowTargetEntry>; 77 + 78 + /** Derived from the registry keys — the only place the union lives. */ 79 + export type FollowTarget = keyof typeof FOLLOW_TARGETS; 80 + 81 + /** Runtime allow-list for incoming follow-target strings (validation, public 82 + * API normalization). `string` so it can compare against unvalidated inputs 83 + * without a cast. */ 84 + export const VALID_FOLLOW_TARGETS: ReadonlySet<string> = new Set(Object.keys(FOLLOW_TARGETS));
-6
lib/automations/labels.ts
··· 16 16 follow: "Follow", 17 17 }; 18 18 19 - export const followTargetLabels: Record<string, string> = { 20 - bluesky: "Bluesky", 21 - tangled: "Tangled", 22 - sifa: "Sifa", 23 - }; 24 - 25 19 export const operationLabels: Record<string, string> = { 26 20 create: "Record created", 27 21 update: "Record updated",
+2 -1
lib/automations/pds.ts
··· 1 1 import { getOAuthClient } from "../auth/client.js"; 2 2 import { config } from "../config.js"; 3 + import type { FollowTarget } from "./follow-targets.js"; 3 4 4 5 const COLLECTION = "run.airglow.automation"; 5 6 ··· 71 72 72 73 type PdsFollowAction = { 73 74 $type: "run.airglow.automation#followAction"; 74 - target: "bluesky" | "tangled" | "sifa"; 75 + target: FollowTarget; 75 76 subject: string; 76 77 comment?: string; 77 78 };
+2 -1
lib/automations/sanitize.ts
··· 1 1 import type { Action } from "../db/schema.ts"; 2 + import type { FollowTarget } from "./follow-targets.js"; 2 3 3 4 export type PublicAction = 4 5 | { ··· 33 34 } 34 35 | { 35 36 $type: "follow"; 36 - target: "bluesky" | "tangled" | "sifa"; 37 + target: FollowTarget; 37 38 subject: string; 38 39 comment?: string; 39 40 };
+3 -18
lib/db/schema.ts
··· 1 1 import { sqliteTable, text, integer, blob, index, uniqueIndex } from "drizzle-orm/sqlite-core"; 2 + import type { FollowTarget } from "../automations/follow-targets.js"; 3 + 4 + export type { FollowTarget }; 2 5 3 6 export const users = sqliteTable("users", { 4 7 id: integer("id").primaryKey({ autoIncrement: true }), ··· 50 53 comment?: string; 51 54 }; 52 55 53 - export type FollowTarget = "bluesky" | "tangled" | "sifa"; 54 - 55 56 export type FollowAction = { 56 57 $type: "follow"; 57 58 target: FollowTarget; ··· 66 67 | PatchRecordAction 67 68 | BookmarkAction 68 69 | 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 - }; 76 - 77 - /** Profile record each follow target expects to exist before a follow is 78 - * meaningful. Used by the follow action's built-in "profile exists" pre-flight 79 - * check so users don't have to hand-write a record fetch + condition for it. */ 80 - export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = { 81 - bluesky: { collection: "app.bsky.actor.profile", rkey: "self" }, 82 - tangled: { collection: "sh.tangled.actor.profile", rkey: "self" }, 83 - sifa: { collection: "id.sifa.profile.self", rkey: "self" }, 84 - }; 85 70 86 71 /** Action types that produce a record result (uri, cid, rkey) for chaining. */ 87 72 const RECORD_PRODUCING_TYPES = new Set([
+4 -4
lib/jetstream/handler.ts
··· 6 6 import { executePatchRecord } from "../actions/patch-record.js"; 7 7 import { executeBookmark } from "../actions/bookmark.js"; 8 8 import { executeFollow } from "../actions/follow.js"; 9 - import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 10 - import { FOLLOW_TARGET_META } from "../automations/follow-targets.js"; 9 + import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 11 10 import { resolveFetches } from "../actions/fetcher.js"; 12 11 import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 13 12 import { parseAtUri } from "../pds/resolver.js"; ··· 162 161 const subject = ( 163 162 await renderTextTemplate(action.subject, match.event, fetchContext, match.automation) 164 163 ).trim(); 165 - const collection = FOLLOW_TARGET_COLLECTION[action.target]; 166 - const appName = FOLLOW_TARGET_META[action.target].appName; 164 + const target = FOLLOW_TARGETS[action.target]; 165 + const collection = target.collection; 166 + const appName = target.appName; 167 167 // The built-in safety checks live inside executeFollow and aren't run in 168 168 // dry-run (keeps the preview cheap). Advertise their presence in the 169 169 // message so authors know the real run will skip cleanly on both edges.