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: show disabled automation on profile pages

Hugo ad4fc420 63e2fb6e

+100 -17
+26
app/components/AutomationCard/index.tsx
··· 1 1 import { Copy } from "../../icons.ts"; 2 2 import type { Action } from "../../../lib/db/schema.ts"; 3 + import { Badge } from "../Badge/index.tsx"; 3 4 import { Button } from "../Button/index.tsx"; 4 5 import { LexiconFlow } from "../LexiconFlow/index.tsx"; 5 6 import * as s from "./styles.css.ts"; ··· 15 16 viewerAuthenticated: boolean; 16 17 isOwner?: boolean; 17 18 featured?: boolean; 19 + active?: boolean; 20 + dryRun?: boolean; 21 + disabledReason?: string | null; 18 22 }; 19 23 24 + function renderStatusBadge( 25 + active: boolean | undefined, 26 + dryRun: boolean | undefined, 27 + disabledReason: string | null | undefined, 28 + ) { 29 + if (active === false && disabledReason?.startsWith("rate_limit")) { 30 + return <Badge variant="error">Rate limited</Badge>; 31 + } 32 + if (active === false) { 33 + return <Badge variant="neutral">Inactive</Badge>; 34 + } 35 + if (dryRun) { 36 + return <Badge variant="warning">Dry Run</Badge>; 37 + } 38 + return null; 39 + } 40 + 20 41 export function AutomationCard({ 21 42 handle, 22 43 did, ··· 28 49 viewerAuthenticated, 29 50 isOwner, 30 51 featured, 52 + active, 53 + dryRun, 54 + disabledReason, 31 55 }: AutomationCardProps) { 32 56 const detailHref = `/u/${handle}/${rkey}`; 33 57 const useHref = isOwner 34 58 ? `/dashboard/automations/${rkey}` 35 59 : `/dashboard/automations/new?from=${encodeURIComponent(did)}&rkey=${encodeURIComponent(rkey)}`; 60 + const badge = renderStatusBadge(active, dryRun, disabledReason); 36 61 37 62 return ( 38 63 <article class={featured ? `${s.card} ${s.cardFeatured}` : s.card}> 64 + {badge && <div class={s.statusRow}>{badge}</div>} 39 65 <LexiconFlow lexicon={lexicon} actions={actions} /> 40 66 <h3 class={s.title} title={name}> 41 67 <a href={detailHref} class={s.titleLink}>
+8
app/components/AutomationCard/styles.css.ts
··· 87 87 position: "relative", 88 88 zIndex: 1, 89 89 }); 90 + 91 + export const statusRow = style({ 92 + position: "relative", 93 + zIndex: 1, 94 + display: "flex", 95 + justifyContent: "flex-end", 96 + marginBlockEnd: `calc(${space[2]} * -1)`, 97 + });
+6
app/components/AutomationGallery/index.tsx
··· 40 40 actions={a.actions} 41 41 viewerAuthenticated={viewerAuthenticated} 42 42 isOwner={viewerDid === a.did} 43 + active={a.active} 44 + dryRun={a.dryRun} 45 + disabledReason={a.disabledReason} 43 46 featured 44 47 /> 45 48 ))} ··· 62 65 actions={a.actions} 63 66 viewerAuthenticated={viewerAuthenticated} 64 67 isOwner={viewerDid === a.did} 68 + active={a.active} 69 + dryRun={a.dryRun} 70 + disabledReason={a.disabledReason} 65 71 /> 66 72 ))} 67 73 </div>
+6 -4
app/routes/u/[handle]/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { and, eq, inArray, like, count } from "drizzle-orm"; 2 + import { eq, inArray, like, count } from "drizzle-orm"; 3 3 import { Zap, Globe } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; ··· 51 51 } 52 52 } 53 53 54 - // Count active automations only — matches what the gallery renders, so the header 55 - // count and the visible cards agree. Inactive ones are surfaced in /dashboard. 54 + // Count all automations on this profile, including inactive ones. Inactive automations 55 + // are shown publicly so accounts can publish them as templates that visitors duplicate 56 + // via "Use this automation". 56 57 const totalAutos = profileUser 57 58 ? (( 58 59 await db 59 60 .select({ count: count() }) 60 61 .from(automations) 61 - .where(and(eq(automations.did, profileUser.did), eq(automations.active, true))) 62 + .where(eq(automations.did, profileUser.did)) 62 63 )[0]?.count ?? 0) 63 64 : 0; 64 65 ··· 67 68 q, 68 69 limit: limit + 1, 69 70 did: profileUser.did, 71 + includeInactive: true, 70 72 }) 71 73 : []; 72 74 const hasMore = rows.length > limit;
+6 -11
lib/automations/featured.ts
··· 1 1 import { eq, inArray } from "drizzle-orm"; 2 2 import { config } from "../config.js"; 3 3 import { db } from "../db/index.js"; 4 - import { automations, users, type Action } from "../db/schema.js"; 4 + import { automations, users } from "../db/schema.js"; 5 + import type { AutomationSearchResult } from "./search.js"; 5 6 6 - export type FeaturedAutomation = { 7 - uri: string; 8 - did: string; 9 - rkey: string; 10 - name: string; 11 - description: string | null; 12 - lexicon: string; 13 - handle: string; 14 - actions: Action[]; 15 - }; 7 + export type FeaturedAutomation = AutomationSearchResult; 16 8 17 9 export function getFeaturedUris(): string[] { 18 10 return config.featuredAutomations; ··· 32 24 lexicon: automations.lexicon, 33 25 actions: automations.actions, 34 26 handle: users.handle, 27 + active: automations.active, 28 + dryRun: automations.dryRun, 29 + disabledReason: automations.disabledReason, 35 30 }) 36 31 .from(automations) 37 32 .innerJoin(users, eq(users.did, automations.did))
+33
lib/automations/search.test.ts
··· 100 100 expect(await searchAutomations(db, { q: "airglow", limit: 10 })).toEqual([]); 101 101 }); 102 102 103 + it("includes inactive automations when includeInactive is true", async () => { 104 + await seedAutomation(db, { 105 + uri: "at://did:plc:alice/run.airglow.automation/a1", 106 + did: "did:plc:alice", 107 + rkey: "a1", 108 + name: "Active one", 109 + lexicon: "run.airglow.automation", 110 + active: true, 111 + }); 112 + await seedAutomation(db, { 113 + uri: "at://did:plc:alice/run.airglow.automation/a2", 114 + did: "did:plc:alice", 115 + rkey: "a2", 116 + name: "Inactive one", 117 + lexicon: "run.airglow.automation", 118 + active: false, 119 + disabledReason: "rate_limit:hour", 120 + }); 121 + 122 + const rows = await searchAutomations(db, { 123 + limit: 10, 124 + did: "did:plc:alice", 125 + includeInactive: true, 126 + }); 127 + expect(rows.map((r) => r.rkey).sort()).toEqual(["a1", "a2"]); 128 + const inactive = rows.find((r) => r.rkey === "a2"); 129 + expect(inactive?.active).toBe(false); 130 + expect(inactive?.disabledReason).toBe("rate_limit:hour"); 131 + const active = rows.find((r) => r.rkey === "a1"); 132 + expect(active?.active).toBe(true); 133 + expect(active?.dryRun).toBe(false); 134 + }); 135 + 103 136 it("excludes URIs listed in excludeUris", async () => { 104 137 await seedAutomation(db, { 105 138 uri: "at://did:plc:alice/run.airglow.automation/a1",
+14 -2
lib/automations/search.ts
··· 16 16 lexicon: string; 17 17 handle: string; 18 18 actions: Action[]; 19 + active: boolean; 20 + dryRun: boolean; 21 + disabledReason: string | null; 19 22 }; 20 23 21 24 export type AutomationSearchParams = { ··· 23 26 limit: number; 24 27 excludeUris?: string[]; 25 28 did?: string; 29 + includeInactive?: boolean; 26 30 }; 27 31 28 32 export async function searchAutomations( 29 33 db: Db, 30 34 params: AutomationSearchParams, 31 35 ): Promise<AutomationSearchResult[]> { 32 - const conditions = [eq(automations.active, true)]; 36 + const conditions = params.includeInactive ? [] : [eq(automations.active, true)]; 33 37 34 38 if (params.did) { 35 39 conditions.push(eq(automations.did, params.did)); ··· 53 57 conditions.push(notInArray(automations.uri, params.excludeUris)); 54 58 } 55 59 56 - const whereClause = conditions.length === 1 ? conditions[0] : and(...conditions); 60 + const whereClause = 61 + conditions.length === 0 62 + ? undefined 63 + : conditions.length === 1 64 + ? conditions[0] 65 + : and(...conditions); 57 66 58 67 return db 59 68 .select({ ··· 65 74 lexicon: automations.lexicon, 66 75 actions: automations.actions, 67 76 handle: users.handle, 77 + active: automations.active, 78 + dryRun: automations.dryRun, 79 + disabledReason: automations.disabledReason, 68 80 }) 69 81 .from(automations) 70 82 .innerJoin(users, eq(users.did, automations.did))
+1
lib/test/fixtures.ts
··· 26 26 wantedDids: string[]; 27 27 active: boolean; 28 28 dryRun: boolean; 29 + disabledReason?: string | null; 29 30 indexedAt: Date; 30 31 }; 31 32