notification manager for bsky
noti.waow.tech
1import type {CallResult} from './types'
2
3export const METHOD_DEFS = [
4 {
5 fq: 'agent.app.bsky.graph.muteActor',
6 doc: 'Mute an account by DID.',
7 inputType: 'MuteActorInput',
8 input: `interface MuteActorInput {\n actor: Did\n}`,
9 signature: `muteActor(input: MuteActorInput): Promise<void>`,
10 },
11 {
12 fq: 'agent.app.bsky.graph.unmuteActor',
13 doc: 'Undo a previous account mute by DID.',
14 inputType: 'UnmuteActorInput',
15 input: `interface UnmuteActorInput {\n actor: Did\n}`,
16 signature: `unmuteActor(input: UnmuteActorInput): Promise<void>`,
17 },
18 {
19 fq: 'agent.app.bsky.graph.blockActor',
20 doc: 'Block an account by DID. Blocks are public atproto records.',
21 inputType: 'BlockActorInput',
22 input: `interface BlockActorInput {\n actor: Did\n}`,
23 signature: `blockActor(input: BlockActorInput): Promise<void>`,
24 },
25 {
26 fq: 'agent.app.bsky.graph.unblockActor',
27 doc: 'Undo a previous account block by DID.',
28 inputType: 'UnblockActorInput',
29 input: `interface UnblockActorInput {\n actor: Did\n}`,
30 signature: `unblockActor(input: UnblockActorInput): Promise<void>`,
31 },
32 {
33 fq: 'agent.app.bsky.notification.putActivitySubscription',
34 doc: 'Change activity-subscription settings for one actor DID.',
35 inputType: 'PutActivitySubscriptionInput',
36 input: [
37 'interface PutActivitySubscriptionInput {',
38 ' subject: Did',
39 ' activitySubscription: {',
40 ' post: boolean',
41 ' reply: boolean',
42 ' }',
43 '}',
44 ].join('\n'),
45 signature: `putActivitySubscription(input: PutActivitySubscriptionInput): Promise<void>`,
46 },
47 {
48 fq: 'agent.app.bsky.notification.updateSeen',
49 doc: 'Advance the global notification seen cursor.',
50 inputType: 'UpdateSeenInput',
51 input: `interface UpdateSeenInput {\n seenAt: string\n}`,
52 signature: `updateSeen(input: UpdateSeenInput): Promise<void>`,
53 },
54] as const
55
56const ALLOWED_METHODS = METHOD_DEFS.map(def => def.fq)
57
58export class CodeValidationError extends Error {}
59
60export const SDK_SURFACE = [
61 'type Did = string',
62 '',
63 'interface EvidenceNotification {',
64 ' uri: string',
65 ' reason: string',
66 ' authorDid?: Did',
67 ' authorHandle: string',
68 ' authorName: string',
69 ' authorAvatar?: string',
70 ' indexedAt: string',
71 ' text?: string',
72 ' subjectText?: string',
73 ' isReply: boolean',
74 '}',
75 '',
76 'interface Ctx {',
77 ' sourceUris: string[]',
78 ' notifications: EvidenceNotification[]',
79 '}',
80 '',
81 ...METHOD_DEFS.flatMap(def => ['', `/** ${def.doc} */`, def.input]),
82 '',
83 'interface BlueskyAgentSurface {',
84 ' app: {',
85 ' bsky: {',
86 ' graph: {',
87 ...METHOD_DEFS.filter(def => def.fq.includes('.graph.')).map(def => ` ${def.signature}`),
88 ' }',
89 ' notification: {',
90 ...METHOD_DEFS.filter(def => def.fq.includes('.notification.')).map(def => ` ${def.signature}`),
91 ' }',
92 ' }',
93 ' }',
94 '}',
95 '',
96 'Write exactly (plain JavaScript, no type annotations):',
97 'async function run(agent, ctx) {',
98 ' // your code',
99 '}',
100].join('\n')
101
102export function validateCode(code: string) {
103 const trimmed = code.trim()
104 if (!trimmed) throw new CodeValidationError('empty code proposal')
105 if (!trimmed.includes('async function run(')) {
106 throw new CodeValidationError('proposal must define async function run(agent, ctx)')
107 }
108
109 const blocked = [
110 'import ',
111 'require(',
112 'process.',
113 'Bun.',
114 'fetch(',
115 'XMLHttpRequest',
116 'WebSocket',
117 'setTimeout(',
118 'setInterval(',
119 ]
120 for (const token of blocked) {
121 if (trimmed.includes(token)) {
122 throw new CodeValidationError(`blocked token: ${token}`)
123 }
124 }
125
126 const matches = [...trimmed.matchAll(/agent(?:\.[A-Za-z_$][\w$]*)+/g)].map(m => m[0])
127 for (const match of matches) {
128 const isAllowed = ALLOWED_METHODS.some(method => match.startsWith(method))
129 if (!isAllowed) {
130 throw new CodeValidationError(`blocked agent method: ${match}`)
131 }
132 }
133}
134
135export function parseFq(fq: string) {
136 const parts = fq.replace(/^agent\./, '').split('.')
137 return {name: parts[parts.length - 1], path: parts.slice(0, -1)}
138}
139
140// biome-ignore lint/suspicious/noExplicitAny: agent is structurally typed from METHOD_DEFS
141export type AgentSurface = Record<string, any>
142
143export type QueuedCall = {method: string; input: unknown}
144
145function actorInput(input: unknown) {
146 if (!input || typeof input !== 'object' || typeof (input as {actor?: unknown}).actor !== 'string') {
147 throw new Error('expected input.actor DID')
148 }
149 return (input as {actor: string}).actor
150}
151
152async function findBlockUri(agent: AgentSurface, actor: string) {
153 let cursor: string | undefined
154 do {
155 const res = await agent.app.bsky.graph.getBlocks({cursor, limit: 100})
156 const profile = res.data.blocks.find((row: {did?: string}) => row.did === actor)
157 const uri = profile?.viewer?.blocking
158 if (typeof uri === 'string' && uri) return uri
159 cursor = res.data.cursor
160 } while (cursor)
161 return ''
162}
163
164const METHOD_DISPATCH_OVERRIDES: Record<string, (agent: AgentSurface, input: unknown) => Promise<unknown>> = {
165 blockActor: async (agent, input) => {
166 const actor = actorInput(input)
167 return agent.app.bsky.graph.block.create(
168 {repo: agent.assertDid},
169 {subject: actor, createdAt: new Date().toISOString()},
170 )
171 },
172 unblockActor: async (agent, input) => {
173 const actor = actorInput(input)
174 const uri = await findBlockUri(agent, actor)
175 if (!uri) return undefined
176 const rkey = uri.split('/').filter(Boolean).pop()
177 if (!rkey) throw new Error(`could not parse block record URI: ${uri}`)
178 return agent.app.bsky.graph.block.delete({repo: agent.assertDid, rkey})
179 },
180}
181
182export const METHOD_DISPATCH: Record<string, (agent: AgentSurface, input: unknown) => Promise<unknown>> =
183 Object.fromEntries(
184 METHOD_DEFS.map(def => {
185 const {name, path} = parseFq(def.fq)
186 return [name, METHOD_DISPATCH_OVERRIDES[name] || ((agent: AgentSurface, input: unknown) => {
187 let target = agent
188 for (const segment of path) target = target[segment]
189 return target[name](input)
190 })]
191 }),
192 )
193
194export function replayQueuedCalls(
195 agent: AgentSurface,
196 callQueue: QueuedCall[],
197): Promise<CallResult[]> {
198 return Promise.all(
199 callQueue.map(async call => {
200 const dispatch = METHOD_DISPATCH[call.method]
201 if (!dispatch) {
202 return {method: call.method, input: call.input, status: 'failed', error: 'unknown method in queue'} satisfies CallResult
203 }
204 try {
205 await dispatch(agent, call.input)
206 return {method: call.method, input: call.input, status: 'ok'} satisfies CallResult
207 } catch (err) {
208 return {
209 method: call.method,
210 input: call.input,
211 status: 'failed',
212 error: err instanceof Error ? err.message : String(err),
213 } satisfies CallResult
214 }
215 }),
216 )
217}
218
219export const SANDBOX_PREAMBLE = (() => {
220 const grouped = new Map<string, string[]>()
221 for (const def of METHOD_DEFS) {
222 const {name, path} = parseFq(def.fq)
223 const key = path.join('.')
224 const existing = grouped.get(key) || []
225 existing.push(` ${name}: function(input) { __calls.push({ method: "${name}", input: input }); return Promise.resolve(); }`)
226 grouped.set(key, existing)
227 }
228 const namespaces = new Map<string, string[]>()
229 for (const [ns, methods] of grouped) {
230 const parts = ns.split('.')
231 const leaf = parts[parts.length - 1]
232 namespaces.set(leaf, methods)
233 }
234 const nsBlocks = [...namespaces.entries()]
235 .map(([leaf, methods]) => ` ${leaf}: {\n${methods.join(',\n')}\n }`)
236 .join(',\n')
237 return `\nvar __calls = [];\nvar agent = {\n app: {\n bsky: {\n${nsBlocks}\n }\n }\n};\n`
238})()