@wzfukui/ani 2026.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ import { format } from "node:util";
2
+
3
+ import type { RuntimeEnv } from "../sdk-compat.js";
4
+
5
+ import type { CoreConfig } from "../types.js";
6
+ import { getAniRuntime } from "../runtime.js";
7
+ import { verifyAniConnection } from "./send.js";
8
+ import { createAniMessageHandler } from "./handler.js";
9
+ import { normalizeAniServerUrl } from "../utils.js";
10
+
11
+ export type MonitorAniOpts = {
12
+ runtime?: RuntimeEnv;
13
+ abortSignal?: AbortSignal;
14
+ accountId?: string | null;
15
+ };
16
+
17
+ /**
18
+ * Gateway entry point: connects to ANI via WebSocket, listens for messages,
19
+ * routes them through the OpenClaw AI agent, and delivers replies back.
20
+ */
21
+ export async function monitorAniProvider(opts: MonitorAniOpts = {}): Promise<void> {
22
+ const core = getAniRuntime();
23
+ const cfg = core.config.loadConfig() as CoreConfig;
24
+ const aniCfg = cfg.channels?.ani;
25
+ if (!aniCfg || aniCfg.enabled === false) return;
26
+
27
+ const serverUrl = normalizeAniServerUrl(aniCfg.serverUrl);
28
+ const apiKey = aniCfg.apiKey ?? "";
29
+ if (!serverUrl || !apiKey) {
30
+ throw new Error("ANI requires serverUrl and apiKey in channels.ani config");
31
+ }
32
+ if (apiKey.startsWith("aimb_")) {
33
+ throw new Error(
34
+ "ANI apiKey must be a permanent key (aim_ prefix). Legacy aimb_ keys are no longer supported.",
35
+ );
36
+ }
37
+
38
+ const logger = core.logging.getChildLogger({ module: "ani-channel" });
39
+ const formatMsg = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
40
+ const runtime: RuntimeEnv = opts.runtime ?? {
41
+ log: (...args) => logger.info(formatMsg(...args)),
42
+ error: (...args) => logger.error(formatMsg(...args)),
43
+ exit: (code: number): never => {
44
+ throw new Error(`exit ${code}`);
45
+ },
46
+ };
47
+ const logVerbose = (message: string) => {
48
+ if (!core.logging.shouldLogVerbose()) return;
49
+ logger.debug(message);
50
+ };
51
+
52
+ // Verify connection before starting WebSocket
53
+ logger.info("ani: verifying connection...");
54
+ const me = await verifyAniConnection({ serverUrl, apiKey });
55
+ logger.info(`ani: authenticated as entity ${me.entityId} (${me.name})`);
56
+
57
+ const handleMessage = createAniMessageHandler({
58
+ core,
59
+ cfg,
60
+ runtime,
61
+ logger,
62
+ logVerbose,
63
+ serverUrl,
64
+ apiKey,
65
+ selfEntityId: me.entityId,
66
+ selfName: me.name,
67
+ accountId: opts.accountId ?? "default",
68
+ });
69
+
70
+ // Build WebSocket URL
71
+ const wsProto = serverUrl.startsWith("https") ? "wss" : "ws";
72
+ const wsHost = serverUrl.replace(/^https?:\/\//, "");
73
+ const wsUrl = `${wsProto}://${wsHost}/api/v1/ws`;
74
+
75
+ // Dynamic import ws (Node.js WebSocket library)
76
+ const { default: WebSocket } = await import("ws");
77
+
78
+ let ws: InstanceType<typeof WebSocket> | null = null;
79
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
80
+ let isShuttingDown = false;
81
+ const PING_INTERVAL_MS = 30000;
82
+
83
+ // Exponential backoff with jitter for reconnection
84
+ const BACKOFF_BASE_MS = 1000;
85
+ const BACKOFF_MAX_MS = 60000;
86
+ const BACKOFF_JITTER = 0.25; // 0-25% random jitter
87
+ let backoffAttempt = 0;
88
+
89
+ function getReconnectDelay(): number {
90
+ const exponential = Math.min(BACKOFF_BASE_MS * Math.pow(2, backoffAttempt), BACKOFF_MAX_MS);
91
+ const jitter = exponential * BACKOFF_JITTER * Math.random();
92
+ return Math.round(exponential + jitter);
93
+ }
94
+
95
+ function connect() {
96
+ if (isShuttingDown) return;
97
+
98
+ logVerbose("ani: connecting WebSocket...");
99
+ ws = new WebSocket(wsUrl, {
100
+ headers: { Authorization: `Bearer ${apiKey}` },
101
+ });
102
+
103
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
104
+
105
+ ws.on("open", () => {
106
+ logger.info("ani: WebSocket connected");
107
+ // Reset backoff on successful connection
108
+ backoffAttempt = 0;
109
+ // Start ping keep-alive
110
+ pingTimer = setInterval(() => {
111
+ if (ws?.readyState === WebSocket.OPEN) {
112
+ ws.ping();
113
+ }
114
+ }, PING_INTERVAL_MS);
115
+ });
116
+
117
+ ws.on("pong", () => {
118
+ logVerbose("ani: pong received");
119
+ });
120
+
121
+ ws.on("message", (data) => {
122
+ (async () => {
123
+ const raw = typeof data === "string" ? data : data.toString("utf-8");
124
+ logVerbose(`ani: WS message received: ${raw.slice(0, 200)}`);
125
+ const msg = JSON.parse(raw);
126
+ await handleMessage(msg);
127
+ })().catch((err) => {
128
+ logger.warn({ error: String(err) }, "ani: WebSocket message handler error");
129
+ });
130
+ });
131
+
132
+ ws.on("close", (code, reason) => {
133
+ if (pingTimer) clearInterval(pingTimer);
134
+ if (isShuttingDown) return;
135
+ const delay = getReconnectDelay();
136
+ backoffAttempt++;
137
+ logger.info(`ani: WebSocket closed (code=${code}), reconnecting in ${delay}ms (attempt ${backoffAttempt})...`);
138
+ reconnectTimer = setTimeout(connect, delay);
139
+ });
140
+
141
+ ws.on("error", (err) => {
142
+ runtime.error?.(`ani: WebSocket error: ${String(err)}`);
143
+ });
144
+ }
145
+
146
+ connect();
147
+
148
+ // Wait for abort signal (shutdown)
149
+ await new Promise<void>((resolve) => {
150
+ const onAbort = () => {
151
+ isShuttingDown = true;
152
+ if (reconnectTimer) clearTimeout(reconnectTimer);
153
+ if (ws) {
154
+ try {
155
+ ws.close(1000, "shutdown");
156
+ } catch {
157
+ // ignore
158
+ }
159
+ }
160
+ resolve();
161
+ };
162
+ if (opts.abortSignal?.aborted) {
163
+ onAbort();
164
+ return;
165
+ }
166
+ opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
167
+ });
168
+ }
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Fetch wrapper with exponential backoff retry for transient failures.
3
+ * Retries on network errors, 429 (rate limit), and 502/503/504 server errors.
4
+ */
5
+ async function fetchWithRetry(
6
+ url: string,
7
+ opts: RequestInit,
8
+ maxAttempts = 3,
9
+ baseDelayMs = 1000,
10
+ ): Promise<Response> {
11
+ let lastErr: Error | null = null;
12
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
13
+ try {
14
+ const res = await fetch(url, opts);
15
+
16
+ // Retry on 429 (rate limit) — respect Retry-After header
17
+ if (res.status === 429 && attempt < maxAttempts) {
18
+ const retryAfterSec = Number(res.headers.get("Retry-After") || 0);
19
+ const retryDelay = retryAfterSec > 0
20
+ ? Math.min(retryAfterSec, 30) * 1000
21
+ : baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 500;
22
+ await res.body?.cancel(); // drain response to free connection
23
+ await new Promise((r) => setTimeout(r, retryDelay));
24
+ continue;
25
+ }
26
+
27
+ // Retry on server errors (502, 503, 504) but NOT on client errors (4xx)
28
+ if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts) {
29
+ await res.body?.cancel(); // drain response to free connection
30
+ const delay = baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 500;
31
+ await new Promise((r) => setTimeout(r, delay));
32
+ continue;
33
+ }
34
+ return res;
35
+ } catch (err) {
36
+ lastErr = err as Error;
37
+ if (attempt < maxAttempts) {
38
+ const delay = baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 500;
39
+ await new Promise((r) => setTimeout(r, delay));
40
+ }
41
+ }
42
+ }
43
+ throw lastErr ?? new Error("fetchWithRetry: all attempts failed");
44
+ }
45
+
46
+ /** Interaction option for interactive cards. */
47
+ export interface AniInteractionOption {
48
+ label: string;
49
+ value: string;
50
+ }
51
+
52
+ /** Interaction layer payload for approval/selection UI. */
53
+ export interface AniInteraction {
54
+ type: "approval" | "selection";
55
+ prompt?: string;
56
+ options?: AniInteractionOption[];
57
+ }
58
+
59
+ /** Artifact payload for ANI structured content. */
60
+ export interface AniArtifact {
61
+ artifact_type: "html" | "code" | "mermaid" | "image";
62
+ source: string;
63
+ title?: string;
64
+ language?: string;
65
+ }
66
+
67
+ /** Result from uploading a file to the ANI backend. */
68
+ export interface AniFileUploadResult {
69
+ url: string;
70
+ filename: string;
71
+ size: number;
72
+ }
73
+
74
+ /**
75
+ * Upload a file to the ANI backend via multipart form data.
76
+ * Endpoint: POST /api/v1/files/upload (max 32MB).
77
+ */
78
+ export async function uploadAniFile(opts: {
79
+ serverUrl: string;
80
+ apiKey: string;
81
+ buffer: Buffer | Uint8Array;
82
+ filename: string;
83
+ }): Promise<AniFileUploadResult> {
84
+ const url = `${opts.serverUrl}/api/v1/files/upload`;
85
+ const form = new FormData();
86
+ const blob = new Blob([opts.buffer]);
87
+ form.append("file", blob, opts.filename);
88
+
89
+ const res = await fetchWithRetry(url, {
90
+ method: "POST",
91
+ headers: {
92
+ Authorization: `Bearer ${opts.apiKey}`,
93
+ },
94
+ body: form,
95
+ signal: AbortSignal.timeout(30_000),
96
+ });
97
+ if (!res.ok) {
98
+ const body = await res.text().catch(() => "");
99
+ throw new Error(`ANI file upload failed (${res.status}): ${body}`);
100
+ }
101
+ const json = (await res.json()) as {
102
+ data?: { url?: string; filename?: string; size?: number };
103
+ };
104
+ const data = json.data ?? {};
105
+ return {
106
+ url: data.url ?? "",
107
+ filename: data.filename ?? opts.filename,
108
+ size: data.size ?? 0,
109
+ };
110
+ }
111
+
112
+ /** ANI attachment matching backend model.Attachment. */
113
+ export interface AniAttachment {
114
+ type: string;
115
+ url?: string;
116
+ filename?: string;
117
+ mime_type?: string;
118
+ size?: number;
119
+ content?: string;
120
+ }
121
+
122
+ /** Send a message to an ANI conversation via REST API. */
123
+ export async function sendAniMessage(opts: {
124
+ serverUrl: string;
125
+ apiKey: string;
126
+ conversationId: number;
127
+ text: string;
128
+ /** If provided, sends as content_type "artifact" instead of plain text. */
129
+ artifact?: AniArtifact;
130
+ /** Entity IDs to @mention (must be conversation participants). */
131
+ mentions?: number[];
132
+ /** Interaction layer for interactive cards (approval/selection UI). */
133
+ interaction?: AniInteraction;
134
+ /** File/media attachments to include with the message. */
135
+ attachments?: AniAttachment[];
136
+ /** Content type override (e.g. "image", "audio", "file", "video"). */
137
+ contentType?: string;
138
+ /** Message ID to reply to. */
139
+ replyTo?: number;
140
+ /** Stream identifier for streaming responses. */
141
+ streamId?: string;
142
+ /** Status layer for progress updates during streaming. */
143
+ statusLayer?: { phase: string; progress: number; text: string };
144
+ }): Promise<{ messageId: number }> {
145
+ const url = `${opts.serverUrl}/api/v1/messages/send`;
146
+
147
+ const layers: Record<string, unknown> = opts.artifact
148
+ ? { summary: opts.text, data: opts.artifact }
149
+ : { summary: opts.text };
150
+
151
+ if (opts.interaction) {
152
+ layers.interaction = opts.interaction;
153
+ }
154
+ if (opts.statusLayer) {
155
+ layers.status = opts.statusLayer;
156
+ }
157
+
158
+ // Determine content_type: artifact > explicit contentType > default (omit for text)
159
+ let contentType: string | undefined;
160
+ if (opts.artifact) {
161
+ contentType = "artifact";
162
+ } else if (opts.contentType) {
163
+ contentType = opts.contentType;
164
+ }
165
+
166
+ const payload: Record<string, unknown> = {
167
+ conversation_id: opts.conversationId,
168
+ layers,
169
+ ...(contentType ? { content_type: contentType } : {}),
170
+ ...(opts.mentions && opts.mentions.length > 0 ? { mentions: opts.mentions } : {}),
171
+ ...(opts.attachments && opts.attachments.length > 0 ? { attachments: opts.attachments } : {}),
172
+ ...(opts.replyTo ? { reply_to: opts.replyTo } : {}),
173
+ ...(opts.streamId ? { stream_id: opts.streamId } : {}),
174
+ };
175
+
176
+ const res = await fetchWithRetry(url, {
177
+ method: "POST",
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ Authorization: `Bearer ${opts.apiKey}`,
181
+ },
182
+ body: JSON.stringify(payload),
183
+ signal: AbortSignal.timeout(30_000),
184
+ });
185
+ if (!res.ok) {
186
+ const body = await res.text().catch(() => "");
187
+ throw new Error(`ANI send failed (${res.status}): ${body}`);
188
+ }
189
+ const json = (await res.json()) as { data?: { id?: number }; id?: number };
190
+ const msg = json.data ?? json;
191
+ return { messageId: msg.id ?? 0 };
192
+ }
193
+
194
+ /** Fetch conversation details (title, description, prompt, participants). */
195
+ export async function fetchConversation(opts: {
196
+ serverUrl: string;
197
+ apiKey: string;
198
+ conversationId: number;
199
+ }): Promise<AniConversation | null> {
200
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}`;
201
+ try {
202
+ const res = await fetchWithRetry(url, {
203
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
204
+ signal: AbortSignal.timeout(30_000),
205
+ });
206
+ if (!res.ok) return null;
207
+ const json = (await res.json()) as { data?: AniConversation };
208
+ return json.data ?? null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** Fetch conversation memories. */
215
+ export async function fetchConversationMemories(opts: {
216
+ serverUrl: string;
217
+ apiKey: string;
218
+ conversationId: number;
219
+ }): Promise<AniMemory[]> {
220
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}/memories`;
221
+ try {
222
+ const res = await fetchWithRetry(url, {
223
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
224
+ signal: AbortSignal.timeout(30_000),
225
+ });
226
+ if (!res.ok) return [];
227
+ const json = (await res.json()) as { data?: { memories?: AniMemory[] } };
228
+ return json.data?.memories ?? [];
229
+ } catch {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ export interface AniConversation {
235
+ id: number;
236
+ conv_type?: string;
237
+ title?: string;
238
+ description?: string;
239
+ prompt?: string;
240
+ participants?: Array<{
241
+ entity_id: number;
242
+ role?: string;
243
+ entity?: {
244
+ id: number;
245
+ display_name?: string;
246
+ entity_type?: string;
247
+ };
248
+ }>;
249
+ }
250
+
251
+ export interface AniMemory {
252
+ id: number;
253
+ key: string;
254
+ content: string;
255
+ }
256
+
257
+ export interface AniTaskEntity {
258
+ id?: number;
259
+ display_name?: string;
260
+ entity_type?: string;
261
+ }
262
+
263
+ export interface AniTask {
264
+ id: number;
265
+ conversation_id: number;
266
+ title: string;
267
+ description?: string;
268
+ assignee_id?: number | null;
269
+ status: string;
270
+ priority: string;
271
+ due_date?: string | null;
272
+ parent_task_id?: number | null;
273
+ sort_order?: number;
274
+ created_by: number;
275
+ created_at?: string;
276
+ updated_at?: string;
277
+ completed_at?: string | null;
278
+ assignee?: AniTaskEntity | null;
279
+ creator?: AniTaskEntity | null;
280
+ }
281
+
282
+ export async function listAniTasks(opts: {
283
+ serverUrl: string;
284
+ apiKey: string;
285
+ conversationId: number;
286
+ status?: string;
287
+ }): Promise<AniTask[]> {
288
+ const query = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
289
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}/tasks${query}`;
290
+ const res = await fetchWithRetry(url, {
291
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
292
+ signal: AbortSignal.timeout(30_000),
293
+ });
294
+ if (!res.ok) {
295
+ const body = await res.text().catch(() => "");
296
+ throw new Error(`ANI list tasks failed (${res.status}): ${body}`);
297
+ }
298
+ const json = (await res.json()) as { data?: AniTask[] };
299
+ return json.data ?? [];
300
+ }
301
+
302
+ export async function getAniTask(opts: {
303
+ serverUrl: string;
304
+ apiKey: string;
305
+ taskId: number;
306
+ }): Promise<AniTask> {
307
+ const url = `${opts.serverUrl}/api/v1/tasks/${opts.taskId}`;
308
+ const res = await fetchWithRetry(url, {
309
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
310
+ signal: AbortSignal.timeout(30_000),
311
+ });
312
+ if (!res.ok) {
313
+ const body = await res.text().catch(() => "");
314
+ throw new Error(`ANI get task failed (${res.status}): ${body}`);
315
+ }
316
+ const json = (await res.json()) as { data?: AniTask };
317
+ if (!json.data) {
318
+ throw new Error("ANI get task failed: missing task payload");
319
+ }
320
+ return json.data;
321
+ }
322
+
323
+ export async function createAniTask(opts: {
324
+ serverUrl: string;
325
+ apiKey: string;
326
+ conversationId: number;
327
+ title: string;
328
+ description?: string;
329
+ assignee_id?: number;
330
+ priority?: string;
331
+ due_date?: string;
332
+ parent_task_id?: number;
333
+ }): Promise<AniTask> {
334
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}/tasks`;
335
+ const res = await fetchWithRetry(url, {
336
+ method: "POST",
337
+ headers: {
338
+ "Content-Type": "application/json",
339
+ Authorization: `Bearer ${opts.apiKey}`,
340
+ },
341
+ body: JSON.stringify({
342
+ title: opts.title,
343
+ ...(opts.description ? { description: opts.description } : {}),
344
+ ...(opts.assignee_id != null ? { assignee_id: opts.assignee_id } : {}),
345
+ ...(opts.priority ? { priority: opts.priority } : {}),
346
+ ...(opts.due_date ? { due_date: opts.due_date } : {}),
347
+ ...(opts.parent_task_id != null ? { parent_task_id: opts.parent_task_id } : {}),
348
+ }),
349
+ signal: AbortSignal.timeout(30_000),
350
+ });
351
+ if (!res.ok) {
352
+ const body = await res.text().catch(() => "");
353
+ throw new Error(`ANI create task failed (${res.status}): ${body}`);
354
+ }
355
+ const json = (await res.json()) as { data?: AniTask };
356
+ if (!json.data) {
357
+ throw new Error("ANI create task failed: missing task payload");
358
+ }
359
+ return json.data;
360
+ }
361
+
362
+ export async function updateAniTask(opts: {
363
+ serverUrl: string;
364
+ apiKey: string;
365
+ taskId: number;
366
+ title?: string;
367
+ description?: string;
368
+ assignee_id?: number;
369
+ status?: string;
370
+ priority?: string;
371
+ due_date?: string;
372
+ sort_order?: number;
373
+ }): Promise<AniTask> {
374
+ const url = `${opts.serverUrl}/api/v1/tasks/${opts.taskId}`;
375
+ const body: Record<string, unknown> = {};
376
+ if (opts.title !== undefined) body.title = opts.title;
377
+ if (opts.description !== undefined) body.description = opts.description;
378
+ if (opts.assignee_id !== undefined) body.assignee_id = opts.assignee_id;
379
+ if (opts.status !== undefined) body.status = opts.status;
380
+ if (opts.priority !== undefined) body.priority = opts.priority;
381
+ if (opts.due_date !== undefined) body.due_date = opts.due_date;
382
+ if (opts.sort_order !== undefined) body.sort_order = opts.sort_order;
383
+
384
+ const res = await fetchWithRetry(url, {
385
+ method: "PUT",
386
+ headers: {
387
+ "Content-Type": "application/json",
388
+ Authorization: `Bearer ${opts.apiKey}`,
389
+ },
390
+ body: JSON.stringify(body),
391
+ signal: AbortSignal.timeout(30_000),
392
+ });
393
+ if (!res.ok) {
394
+ const bodyText = await res.text().catch(() => "");
395
+ throw new Error(`ANI update task failed (${res.status}): ${bodyText}`);
396
+ }
397
+ const json = (await res.json()) as { data?: AniTask };
398
+ if (!json.data) {
399
+ throw new Error("ANI update task failed: missing task payload");
400
+ }
401
+ return json.data;
402
+ }
403
+
404
+ export async function deleteAniTask(opts: {
405
+ serverUrl: string;
406
+ apiKey: string;
407
+ taskId: number;
408
+ }): Promise<void> {
409
+ const url = `${opts.serverUrl}/api/v1/tasks/${opts.taskId}`;
410
+ const res = await fetchWithRetry(url, {
411
+ method: "DELETE",
412
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
413
+ signal: AbortSignal.timeout(30_000),
414
+ });
415
+ if (!res.ok) {
416
+ const body = await res.text().catch(() => "");
417
+ throw new Error(`ANI delete task failed (${res.status}): ${body}`);
418
+ }
419
+ }
420
+
421
+ /** Verify the API key works and return entity info. */
422
+ export async function verifyAniConnection(opts: {
423
+ serverUrl: string;
424
+ apiKey: string;
425
+ }): Promise<{ entityId: number; name: string; entityType: string }> {
426
+ const url = `${opts.serverUrl}/api/v1/me`;
427
+ const res = await fetchWithRetry(url, {
428
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
429
+ signal: AbortSignal.timeout(30_000),
430
+ });
431
+ if (!res.ok) {
432
+ const body = await res.text().catch(() => "");
433
+ throw new Error(`ANI /me failed (${res.status}): ${body}`);
434
+ }
435
+ const json = (await res.json()) as {
436
+ data?: { id?: number; display_name?: string; entity_type?: string };
437
+ id?: number;
438
+ display_name?: string;
439
+ entity_type?: string;
440
+ };
441
+ // ANI wraps response in { data: { ... }, ok: true }
442
+ const entity = json.data ?? json;
443
+ const entityId = entity.id ?? 0;
444
+ if (!entityId) {
445
+ throw new Error("ANI /me returned no entity ID — check API key validity");
446
+ }
447
+ return {
448
+ entityId,
449
+ name: entity.display_name ?? "unknown",
450
+ entityType: entity.entity_type ?? "bot",
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Send a transient progress event via POST /conversations/:id/progress.
456
+ * This is NOT persisted to the database — broadcast to WebSocket clients only.
457
+ */
458
+ export async function sendAniProgress(opts: {
459
+ serverUrl: string;
460
+ apiKey: string;
461
+ conversationId: number;
462
+ streamId: string;
463
+ status: { phase: string; progress: number; text: string };
464
+ }): Promise<void> {
465
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}/progress`;
466
+ const res = await fetchWithRetry(
467
+ url,
468
+ {
469
+ method: "POST",
470
+ headers: {
471
+ "Content-Type": "application/json",
472
+ Authorization: `Bearer ${opts.apiKey}`,
473
+ },
474
+ body: JSON.stringify({
475
+ stream_id: opts.streamId,
476
+ status: opts.status,
477
+ }),
478
+ signal: AbortSignal.timeout(10_000),
479
+ },
480
+ 2, // fire-and-forget: less aggressive retry
481
+ );
482
+ if (!res.ok) {
483
+ const body = await res.text().catch(() => "");
484
+ throw new Error(`ANI progress failed (${res.status}): ${body}`);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Send a typing indicator via POST /conversations/:id/typing.
490
+ * This is NOT persisted — broadcast to WebSocket clients only.
491
+ * Fire-and-forget: errors are silently ignored.
492
+ */
493
+ export async function sendAniTyping(opts: {
494
+ serverUrl: string;
495
+ apiKey: string;
496
+ conversationId: number;
497
+ isProcessing?: boolean;
498
+ phase?: string;
499
+ }): Promise<void> {
500
+ const url = `${opts.serverUrl}/api/v1/conversations/${opts.conversationId}/typing`;
501
+ const body: Record<string, unknown> = {};
502
+ if (opts.isProcessing) {
503
+ body.is_processing = true;
504
+ if (opts.phase) body.phase = opts.phase;
505
+ }
506
+ const res = await fetchWithRetry(
507
+ url,
508
+ {
509
+ method: "POST",
510
+ headers: {
511
+ "Content-Type": "application/json",
512
+ Authorization: `Bearer ${opts.apiKey}`,
513
+ },
514
+ body: JSON.stringify(body),
515
+ signal: AbortSignal.timeout(10_000),
516
+ },
517
+ 2, // fire-and-forget: less aggressive retry
518
+ );
519
+ if (!res.ok) {
520
+ const text = await res.text().catch(() => "");
521
+ throw new Error(`ANI typing failed (${res.status}): ${text}`);
522
+ }
523
+ }
524
+
525
+ /** Toggle an emoji reaction on a message. POST /messages/:id/reactions */
526
+ export async function toggleAniReaction(opts: {
527
+ serverUrl: string;
528
+ apiKey: string;
529
+ messageId: number;
530
+ emoji: string;
531
+ }): Promise<void> {
532
+ const url = `${opts.serverUrl}/api/v1/messages/${opts.messageId}/reactions`;
533
+ const res = await fetchWithRetry(url, {
534
+ method: "POST",
535
+ headers: {
536
+ "Content-Type": "application/json",
537
+ Authorization: `Bearer ${opts.apiKey}`,
538
+ },
539
+ body: JSON.stringify({ emoji: opts.emoji }),
540
+ signal: AbortSignal.timeout(30_000),
541
+ });
542
+ if (!res.ok) {
543
+ const body = await res.text().catch(() => "");
544
+ throw new Error(`ANI reaction failed (${res.status}): ${body}`);
545
+ }
546
+ }