@moda-labs/bobi-events-core 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/chat-sdk-slack.d.ts +18 -0
  4. package/dist/adapters/chat-sdk-slack.d.ts.map +1 -0
  5. package/dist/adapters/chat-sdk-slack.js +201 -0
  6. package/dist/adapters/chat-sdk-slack.js.map +1 -0
  7. package/dist/adapters/github.d.ts +3 -0
  8. package/dist/adapters/github.d.ts.map +1 -0
  9. package/dist/adapters/github.js +114 -0
  10. package/dist/adapters/github.js.map +1 -0
  11. package/dist/adapters/linear.d.ts +3 -0
  12. package/dist/adapters/linear.d.ts.map +1 -0
  13. package/dist/adapters/linear.js +47 -0
  14. package/dist/adapters/linear.js.map +1 -0
  15. package/dist/adapters/whatsapp.d.ts +25 -0
  16. package/dist/adapters/whatsapp.d.ts.map +1 -0
  17. package/dist/adapters/whatsapp.js +96 -0
  18. package/dist/adapters/whatsapp.js.map +1 -0
  19. package/dist/channels.d.ts +80 -0
  20. package/dist/channels.d.ts.map +1 -0
  21. package/dist/channels.js +499 -0
  22. package/dist/channels.js.map +1 -0
  23. package/dist/circuit-breaker.d.ts +79 -0
  24. package/dist/circuit-breaker.d.ts.map +1 -0
  25. package/dist/circuit-breaker.js +288 -0
  26. package/dist/circuit-breaker.js.map +1 -0
  27. package/dist/conversation.d.ts +29 -0
  28. package/dist/conversation.d.ts.map +1 -0
  29. package/dist/conversation.js +86 -0
  30. package/dist/conversation.js.map +1 -0
  31. package/dist/core.d.ts +254 -0
  32. package/dist/core.d.ts.map +1 -0
  33. package/dist/core.js +1775 -0
  34. package/dist/core.js.map +1 -0
  35. package/package.json +40 -0
  36. package/src/adapters/chat-sdk-slack.ts +209 -0
  37. package/src/adapters/github.ts +111 -0
  38. package/src/adapters/linear.ts +53 -0
  39. package/src/adapters/whatsapp.ts +120 -0
  40. package/src/channels.ts +600 -0
  41. package/src/circuit-breaker.ts +337 -0
  42. package/src/conversation.ts +96 -0
  43. package/src/core.ts +2413 -0
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Channel adapters - outbound contract for the channel gateway (#190 Phase 2).
3
+ *
4
+ * Each chat channel is one adapter implementing this contract, registered in
5
+ * CHANNEL_ADAPTERS. The generic /channels/* handlers in core.ts parse a
6
+ * conversation reference, resolve the sending credential, and delegate here;
7
+ * they never learn platform semantics. Capability degradation is the
8
+ * gateway's job: an `update` on a channel without edit support becomes a
9
+ * follow-up post, `typing` on a channel without indicators is a silent no-op.
10
+ *
11
+ * The Slack adapter is built on the Chat SDK's api module
12
+ * (@chat-adapter/slack/api) - the same package whose webhook parser serves
13
+ * inbound (adapters/chat-sdk-slack.ts). Outbound text arrives as markdown and
14
+ * is delivered natively via Slack's `markdown_text`, so callers never convert
15
+ * client-side.
16
+ */
17
+ import {
18
+ postSlackMessage,
19
+ updateSlackMessage,
20
+ uploadSlackFiles,
21
+ fetchSlackThreadReplies,
22
+ callSlackApi,
23
+ SlackApiError,
24
+ type SlackMessageOptions,
25
+ } from "@chat-adapter/slack/api";
26
+ import type { Conversation } from "./conversation.js";
27
+
28
+ export interface CredentialSpec {
29
+ env: string;
30
+ secret: boolean;
31
+ label: string;
32
+ }
33
+
34
+ // Slack Web API base. Overridable so integration tests can stub the Slack
35
+ // API with a local server (local.ts wires BOBI_ES_SLACK_API_URL); production
36
+ // always uses the default. Must end with "/" - the SDK resolves method names
37
+ // relative to it.
38
+ const DEFAULT_SLACK_API_URL = "https://slack.com/api/";
39
+ let slackApiUrlOverride: string | undefined;
40
+
41
+ export function setSlackApiUrl(url: string | undefined): void {
42
+ slackApiUrlOverride = url ? (url.endsWith("/") ? url : `${url}/`) : undefined;
43
+ }
44
+
45
+ export function slackApiUrl(): string {
46
+ return slackApiUrlOverride ?? DEFAULT_SLACK_API_URL;
47
+ }
48
+
49
+ // Meta Graph API base for WhatsApp Cloud API calls. Overridable so tests can
50
+ // stub the Graph API with a local server (local.ts wires
51
+ // BOBI_ES_WHATSAPP_API_URL); production always uses the default.
52
+ const DEFAULT_WHATSAPP_API_URL = "https://graph.facebook.com/v23.0/";
53
+ let whatsappApiUrlOverride: string | undefined;
54
+
55
+ export function setWhatsAppApiUrl(url: string | undefined): void {
56
+ whatsappApiUrlOverride = url ? (url.endsWith("/") ? url : `${url}/`) : undefined;
57
+ }
58
+
59
+ export function whatsappApiUrl(): string {
60
+ return whatsappApiUrlOverride ?? DEFAULT_WHATSAPP_API_URL;
61
+ }
62
+
63
+ export interface ChannelCapabilities {
64
+ edit: boolean;
65
+ typing: boolean;
66
+ streaming: "native" | "edit-fallback" | "none";
67
+ threads: boolean;
68
+ files: boolean;
69
+ /** Outbound message budget; text beyond it is truncated with a marker. */
70
+ maxLength: number;
71
+ lengthUnit: "chars" | "utf16";
72
+ messageWindow?: { hours: number; outsideWindow: "template" | "blocked" };
73
+ }
74
+
75
+ /**
76
+ * One struct declaring a channel's entire integration surface, so adding a
77
+ * channel means one adapter module and one registry entry, with zero core
78
+ * edits (epic #190 design).
79
+ */
80
+ export interface ChannelDescriptor {
81
+ name: string;
82
+ topicShape: string;
83
+ transport: "webhook" | "socket";
84
+ capabilities: ChannelCapabilities;
85
+ credentials: CredentialSpec[];
86
+ promptHint: string;
87
+ setupSkill: string;
88
+ }
89
+
90
+ export interface SendResult {
91
+ ok: boolean;
92
+ /** Platform message id of the posted/updated message (Slack: ts). */
93
+ ts?: string;
94
+ error?: string;
95
+ }
96
+
97
+ export interface OutboundFile {
98
+ name: string;
99
+ // ArrayBuffer-backed by contract: the bytes go into Blob/BufferSource
100
+ // APIs that reject SharedArrayBuffer-backed views.
101
+ data: Uint8Array<ArrayBuffer>;
102
+ title?: string;
103
+ }
104
+
105
+ export interface ConversationMessage {
106
+ user: string;
107
+ text: string;
108
+ ts: string;
109
+ files?: Array<Record<string, string>>;
110
+ }
111
+
112
+ export interface ChannelAdapter {
113
+ descriptor: ChannelDescriptor;
114
+ send(token: string, conv: Conversation, text: string): Promise<SendResult>;
115
+ update?(token: string, conv: Conversation, messageId: string, text: string): Promise<SendResult>;
116
+ /** Set (on) or clear (off) the channel's thinking/typing indicator. Best-effort. */
117
+ typing?(token: string, conv: Conversation, on: boolean): Promise<void>;
118
+ fetchConversation?(token: string, conv: Conversation, limit?: number): Promise<ConversationMessage[]>;
119
+ uploadFiles?(token: string, conv: Conversation, files: OutboundFile[], comment?: string): Promise<SendResult>;
120
+ }
121
+
122
+ /**
123
+ * Outbound budget enforcement (#651). Terminal sends (post / final) go out
124
+ * whole as natural-boundary chunks via chunkForChannel; streaming updates
125
+ * rewrite one message in place, so they stay on truncateForChannel - chunking
126
+ * a rewrite would post a new message on every tick.
127
+ */
128
+ const TRUNCATION_MARKER = "\n_(truncated)_";
129
+ /** Runaway guard: past this many chunks the tail is truncated with the marker. */
130
+ const MAX_CHUNKS = 8;
131
+ /** Appended to a chunk that splits inside a code fence, so it renders closed. */
132
+ const FENCE_CLOSE = "\n```";
133
+
134
+ /** Length of `s` in the channel's declared unit. */
135
+ function unitLength(s: string, caps: ChannelCapabilities): number {
136
+ if (caps.lengthUnit === "utf16") return s.length;
137
+ let n = 0;
138
+ for (const _ch of s) n++;
139
+ return n;
140
+ }
141
+
142
+ /**
143
+ * UTF-16 index covering at most `units` of `s` in the channel's unit, never
144
+ * splitting a surrogate pair.
145
+ */
146
+ function unitIndex(s: string, units: number, caps: ChannelCapabilities): number {
147
+ if (units <= 0) return 0;
148
+ let idx: number;
149
+ if (caps.lengthUnit === "utf16") {
150
+ idx = Math.min(units, s.length);
151
+ } else {
152
+ idx = 0;
153
+ let counted = 0;
154
+ for (const ch of s) {
155
+ if (counted >= units) break;
156
+ idx += ch.length;
157
+ counted++;
158
+ }
159
+ }
160
+ // Never split a surrogate pair: if the boundary lands between a high and
161
+ // low surrogate, step back one unit.
162
+ if (idx > 0 && idx < s.length) {
163
+ const cc = s.charCodeAt(idx - 1);
164
+ if (cc >= 0xd800 && cc <= 0xdbff) idx--;
165
+ }
166
+ return idx;
167
+ }
168
+
169
+ export function truncateForChannel(text: string, caps: ChannelCapabilities): string {
170
+ if (unitLength(text, caps) <= caps.maxLength) return text;
171
+ // The marker must fit INSIDE the budget - maxLength is the channel's hard
172
+ // limit, and overshooting it fails the whole send (msg_too_long).
173
+ const keep = unitIndex(text, caps.maxLength - TRUNCATION_MARKER.length, caps);
174
+ return text.slice(0, keep) + TRUNCATION_MARKER;
175
+ }
176
+
177
+ /**
178
+ * Fence state after scanning `s`. `open` is "" outside a fence, else the
179
+ * fence header to reopen with (e.g. "```python"). Fence lines toggle; an
180
+ * opening line's info string is preserved for reopening. The info string may
181
+ * not contain backticks (per CommonMark), which keeps a line-leading inline
182
+ * span like ```cmd``` from reading as an opener; an implausibly long "info
183
+ * string" (a one-line blob that happens to start with backticks) reopens as
184
+ * a bare fence so the carried prefix can never blow the chunk budget.
185
+ */
186
+ const MAX_FENCE_HEADER = 24;
187
+
188
+ function fenceStateAfter(s: string, open: string): string {
189
+ for (const line of s.split("\n")) {
190
+ const m = line.match(/^\s*(`{3,})([^`]*)$/);
191
+ if (!m) continue;
192
+ if (open) {
193
+ open = "";
194
+ } else {
195
+ const info = m[2].trim();
196
+ open = info.length > MAX_FENCE_HEADER ? m[1] : m[1] + info;
197
+ }
198
+ }
199
+ return open;
200
+ }
201
+
202
+ /**
203
+ * Split over-budget text into channel-sized chunks at natural boundaries:
204
+ * paragraph break, then line break, then a hard (surrogate-safe) cut. A split
205
+ * inside a code fence closes the fence at the chunk end and reopens it (with
206
+ * its info string) at the start of the next chunk, so every chunk renders.
207
+ * Within-budget text comes back as a single chunk, byte-identical.
208
+ */
209
+ export function chunkForChannel(text: string, caps: ChannelCapabilities): string[] {
210
+ if (unitLength(text, caps) <= caps.maxLength) return [text];
211
+
212
+ const chunks: string[] = [];
213
+ let remaining = text;
214
+ let reopen = ""; // fence header carried into the next chunk
215
+
216
+ while (chunks.length < MAX_CHUNKS - 1) {
217
+ const prefix = reopen ? reopen + "\n" : "";
218
+ if (unitLength(prefix, caps) + unitLength(remaining, caps) <= caps.maxLength) {
219
+ chunks.push(prefix + remaining);
220
+ return chunks;
221
+ }
222
+ // Reserve room for the prefix and a possible fence close so the chunk
223
+ // never overshoots the hard limit (over-reserving costs a few chars).
224
+ const avail = Math.max(
225
+ 1, caps.maxLength - unitLength(prefix, caps) - unitLength(FENCE_CLOSE, caps));
226
+ const windowEnd = unitIndex(remaining, avail, caps);
227
+ const window = remaining.slice(0, windowEnd);
228
+
229
+ // Natural boundaries, but never a blank chunk: a cut that leaves only
230
+ // whitespace (e.g. leading newlines) would fail the whole send.
231
+ let cut = window.lastIndexOf("\n\n");
232
+ if (cut <= 0 || !window.slice(0, cut).trim()) cut = window.lastIndexOf("\n");
233
+ if (cut <= 0 || !window.slice(0, cut).trim()) cut = windowEnd; // hard split, surrogate-safe
234
+
235
+ const head = remaining.slice(0, cut);
236
+ remaining = remaining.slice(cut).replace(/^\n+/, "");
237
+
238
+ reopen = fenceStateAfter(head, reopen);
239
+ let chunk = prefix + head;
240
+ if (reopen) chunk += FENCE_CLOSE;
241
+ chunks.push(chunk);
242
+ }
243
+
244
+ // Chunk-count guard hit: truncate the tail instead of posting unbounded
245
+ // follow-ups for pathological input, keeping any open fence closed so the
246
+ // final chunk still renders.
247
+ const prefix = reopen ? reopen + "\n" : "";
248
+ let tail = truncateForChannel(prefix + remaining, caps);
249
+ if (fenceStateAfter(tail, "")) {
250
+ tail = truncateForChannel(
251
+ prefix + remaining,
252
+ { ...caps, maxLength: caps.maxLength - unitLength(FENCE_CLOSE, caps) },
253
+ ) + FENCE_CLOSE;
254
+ }
255
+ chunks.push(tail);
256
+ return chunks;
257
+ }
258
+
259
+ function slackError(err: unknown): string {
260
+ if (err instanceof SlackApiError) {
261
+ return (err.response?.error as string) || err.message;
262
+ }
263
+ return String(err);
264
+ }
265
+
266
+ function toConversationMessage(raw: Record<string, unknown>): ConversationMessage {
267
+ const entry: ConversationMessage = {
268
+ user: (raw.user as string) || "",
269
+ text: (raw.text as string) || "",
270
+ ts: (raw.ts as string) || "",
271
+ };
272
+ const rawFiles = raw.files as Array<Record<string, unknown>> | undefined;
273
+ if (Array.isArray(rawFiles) && rawFiles.length > 0) {
274
+ entry.files = rawFiles.map((f) => ({
275
+ id: String(f.id ?? ""),
276
+ name: String(f.name ?? ""),
277
+ mimetype: String(f.mimetype ?? ""),
278
+ url_private: String(f.url_private ?? ""),
279
+ }));
280
+ }
281
+ return entry;
282
+ }
283
+
284
+ // Message body for a Slack post/update. Raw markdown goes out as Slack's
285
+ // native `markdown_text` (AST-rendered by Slack, 12k budget).
286
+ function slackContent(text: string): Pick<SlackMessageOptions, "markdownText"> {
287
+ return { markdownText: text };
288
+ }
289
+
290
+ const slackAdapter: ChannelAdapter = {
291
+ descriptor: {
292
+ name: "slack",
293
+ topicShape: "slack:<team_id>[:app:<app_id>][:<channel>]",
294
+ transport: "webhook",
295
+ capabilities: {
296
+ edit: true,
297
+ typing: true,
298
+ streaming: "native",
299
+ threads: true,
300
+ files: true,
301
+ // Slack's markdown_text limit; plain text allows ~40k but the
302
+ // gateway sends markdown, so the stricter budget applies. The
303
+ // budget has always been enforced in UTF-16 units (JS .length);
304
+ // declare that unit so chunking keeps the proven behavior.
305
+ maxLength: 12000,
306
+ lengthUnit: "utf16",
307
+ },
308
+ credentials: [
309
+ { env: "SLACK_BOT_TOKEN", secret: true, label: "Bot User OAuth Token" },
310
+ { env: "SLACK_SIGNING_SECRET", secret: true, label: "App Signing Secret" },
311
+ ],
312
+ promptHint:
313
+ "Slack renders standard markdown. Keep replies concise; use "
314
+ + "triple-backtick blocks for multi-line code.",
315
+ setupSkill: "skills/slack-setup.md",
316
+ },
317
+
318
+ async send(token, conv, text) {
319
+ try {
320
+ const posted = await postSlackMessage({
321
+ apiUrl: slackApiUrl(),
322
+ token,
323
+ channel: conv.chatId,
324
+ ...(conv.threadId ? { threadTs: conv.threadId } : {}),
325
+ ...slackContent(text),
326
+ });
327
+ return { ok: true, ts: posted.id };
328
+ } catch (err) {
329
+ return { ok: false, error: slackError(err) };
330
+ }
331
+ },
332
+
333
+ async update(token, conv, messageId, text) {
334
+ try {
335
+ const posted = await updateSlackMessage({
336
+ apiUrl: slackApiUrl(),
337
+ token,
338
+ channel: conv.chatId,
339
+ ts: messageId,
340
+ ...slackContent(text),
341
+ });
342
+ return { ok: true, ts: posted.id };
343
+ } catch (err) {
344
+ return { ok: false, error: slackError(err) };
345
+ }
346
+ },
347
+
348
+ // Slack's "typing" is the assistant thread status ("is thinking..."),
349
+ // which only exists in thread context and expires after ~2 minutes -
350
+ // callers refresh while work runs. Best-effort: only DM threads support
351
+ // it, so failures are swallowed.
352
+ async typing(token, conv, on) {
353
+ if (!conv.threadId) return;
354
+ try {
355
+ await callSlackApi("assistant.threads.setStatus", {
356
+ channel_id: conv.chatId,
357
+ thread_ts: conv.threadId,
358
+ status: on ? "is thinking…" : "",
359
+ }, { apiUrl: slackApiUrl(), token });
360
+ } catch {
361
+ // non-fatal
362
+ }
363
+ },
364
+
365
+ async fetchConversation(token, conv, limit = 100) {
366
+ // A ref without a thread anchor reads the channel/DM history instead
367
+ // of thread replies (e.g. a proactive conversation with no thread).
368
+ if (!conv.threadId) {
369
+ const raw = await callSlackApi("conversations.history", {
370
+ channel: conv.chatId,
371
+ limit: Math.min(limit, 200),
372
+ }, { apiUrl: slackApiUrl(), token });
373
+ const rows = (raw.messages as Array<Record<string, unknown>> | undefined) ?? [];
374
+ // conversations.history returns newest-first; deliver oldest-first
375
+ // to match the thread-replies ordering.
376
+ return rows.map(toConversationMessage).reverse().slice(0, limit);
377
+ }
378
+ const messages: ConversationMessage[] = [];
379
+ let cursor: string | undefined;
380
+ while (messages.length < limit) {
381
+ const page = await fetchSlackThreadReplies({
382
+ apiUrl: slackApiUrl(),
383
+ token,
384
+ channel: conv.chatId,
385
+ ts: conv.threadId,
386
+ limit: Math.min(limit - messages.length, 200),
387
+ ...(cursor ? { cursor } : {}),
388
+ });
389
+ for (const raw of page.messages as Array<Record<string, unknown>>) {
390
+ messages.push(toConversationMessage(raw));
391
+ }
392
+ cursor = page.nextCursor;
393
+ if (!cursor) break;
394
+ }
395
+ return messages.slice(0, limit);
396
+ },
397
+
398
+ async uploadFiles(token, conv, files, comment) {
399
+ try {
400
+ const result = await uploadSlackFiles(
401
+ files.map((f) => ({
402
+ data: f.data,
403
+ filename: f.name,
404
+ ...(f.title ? { title: f.title } : {}),
405
+ })),
406
+ {
407
+ apiUrl: slackApiUrl(),
408
+ token,
409
+ channelId: conv.chatId,
410
+ ...(conv.threadId ? { threadTs: conv.threadId } : {}),
411
+ ...(comment ? { initialComment: comment } : {}),
412
+ },
413
+ );
414
+ return { ok: true, ts: result.fileIds[0] };
415
+ } catch (err) {
416
+ return { ok: false, error: slackError(err) };
417
+ }
418
+ },
419
+ };
420
+
421
+ // --- WhatsApp (Meta Cloud API) - #656, epic #190 Phase 3 ---
422
+
423
+ // One Graph API call. Returns the parsed JSON body; throws on transport
424
+ // errors; a Graph error body ({error: {message}}) is surfaced by callers.
425
+ export async function whatsappApi(
426
+ token: string,
427
+ path: string,
428
+ init: { method?: string; json?: unknown; form?: FormData },
429
+ ): Promise<Record<string, unknown>> {
430
+ const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
431
+ // Structural (not BodyInit): only these two shapes are ever built, and
432
+ // the name resolves under every lib variant this file compiles against
433
+ // (dom for the published dist, @types/node for the in-repo build).
434
+ let body: string | FormData | undefined;
435
+ if (init.json !== undefined) {
436
+ headers["Content-Type"] = "application/json";
437
+ body = JSON.stringify(init.json);
438
+ } else if (init.form) {
439
+ body = init.form; // fetch sets the multipart boundary itself
440
+ }
441
+ const resp = await fetch(`${whatsappApiUrl()}${path}`, {
442
+ method: init.method ?? "POST",
443
+ headers,
444
+ ...(body !== undefined ? { body } : {}),
445
+ });
446
+ return (await resp.json()) as Record<string, unknown>;
447
+ }
448
+
449
+ // Meta's cap for media/document captions, distinct from the 4096 text limit.
450
+ const WHATSAPP_CAPTION_LIMIT = 1024;
451
+
452
+ function whatsappError(data: Record<string, unknown>): string {
453
+ const err = data.error as Record<string, unknown> | undefined;
454
+ return (err?.message as string) || "whatsapp api error";
455
+ }
456
+
457
+ const whatsappAdapter: ChannelAdapter = {
458
+ descriptor: {
459
+ name: "whatsapp",
460
+ topicShape: "whatsapp:<phone_number_id>",
461
+ transport: "webhook",
462
+ capabilities: {
463
+ // No message editing on WhatsApp - the gateway degrades placeholder
464
+ // edits to follow-up posts. No typing indicator, no threads, no
465
+ // native streaming: reactive-only replies (epic #190 Phase 3).
466
+ edit: false,
467
+ typing: false,
468
+ streaming: "none",
469
+ threads: false,
470
+ files: true,
471
+ // Cloud API text body limit, counted in characters (code points) -
472
+ // the unit-aware budget #651 wired in.
473
+ maxLength: 4096,
474
+ lengthUnit: "chars",
475
+ // Meta's customer-service window: replies are free-form for 24h
476
+ // after the user's last inbound message; outside it only template
477
+ // messages send (template support is a follow-up - the gateway
478
+ // returns a typed error so the agent can report the situation).
479
+ messageWindow: { hours: 24, outsideWindow: "template" },
480
+ },
481
+ credentials: [
482
+ { env: "WHATSAPP_ACCESS_TOKEN", secret: true, label: "Cloud API access token" },
483
+ { env: "WHATSAPP_PHONE_NUMBER_ID", secret: false, label: "Phone number ID" },
484
+ { env: "WHATSAPP_APP_SECRET", secret: true, label: "Meta app secret (webhook signatures)" },
485
+ { env: "WHATSAPP_VERIFY_TOKEN", secret: true, label: "Webhook verify token (your choice)" },
486
+ ],
487
+ promptHint:
488
+ "WhatsApp renders its own light formatting (*bold*, _italic_, "
489
+ + "```monospace```), not full markdown. Keep replies short and "
490
+ + "conversational; avoid headers and tables.",
491
+ setupSkill: "skills/whatsapp-setup.md",
492
+ },
493
+
494
+ async send(token, conv, text) {
495
+ try {
496
+ const data = await whatsappApi(token, `${conv.scope}/messages`, {
497
+ json: {
498
+ messaging_product: "whatsapp",
499
+ to: conv.chatId,
500
+ type: "text",
501
+ text: { body: text },
502
+ },
503
+ });
504
+ const id = (data.messages as Array<Record<string, unknown>>)?.[0]?.id as string;
505
+ if (!id) return { ok: false, error: whatsappError(data) };
506
+ return { ok: true, ts: id };
507
+ } catch (err) {
508
+ return { ok: false, error: String(err) };
509
+ }
510
+ },
511
+
512
+ // Two-step Cloud API upload: POST the bytes to /{pnid}/media, then send a
513
+ // document message referencing the media id. Sent as `document` for any
514
+ // mime type - it preserves the filename and works for all content. The
515
+ // media endpoint validates the declared MIME, so it is inferred from the
516
+ // filename (octet-stream is not on Meta's supported list).
517
+ async uploadFiles(token, conv, files, comment) {
518
+ let delivered = 0;
519
+ try {
520
+ let lastId = "";
521
+ for (const [i, f] of files.entries()) {
522
+ const mime = mimeForFilename(f.name);
523
+ const form = new FormData();
524
+ form.append("messaging_product", "whatsapp");
525
+ form.append("type", mime);
526
+ form.append("file", new Blob([f.data], { type: mime }), f.name);
527
+ const uploaded = await whatsappApi(token, `${conv.scope}/media`, { form });
528
+ const mediaId = uploaded.id as string;
529
+ if (!mediaId) return { ok: false, error: partialUploadError(whatsappError(uploaded), i, files.length) };
530
+
531
+ // Media captions have their own Cloud API limit (1024 chars),
532
+ // well below the 4096 text-body cap the gateway budget uses.
533
+ const caption = (i === 0 ? comment || f.title || "" : f.title || "")
534
+ .slice(0, WHATSAPP_CAPTION_LIMIT);
535
+ const sent = await whatsappApi(token, `${conv.scope}/messages`, {
536
+ json: {
537
+ messaging_product: "whatsapp",
538
+ to: conv.chatId,
539
+ type: "document",
540
+ document: {
541
+ id: mediaId,
542
+ filename: f.name,
543
+ ...(caption ? { caption } : {}),
544
+ },
545
+ },
546
+ });
547
+ const id = (sent.messages as Array<Record<string, unknown>>)?.[0]?.id as string;
548
+ if (!id) return { ok: false, error: partialUploadError(whatsappError(sent), i, files.length) };
549
+ lastId = id;
550
+ delivered = i + 1;
551
+ }
552
+ return { ok: true, ts: lastId };
553
+ } catch (err) {
554
+ return { ok: false, error: partialUploadError(String(err), delivered, files.length) };
555
+ }
556
+ },
557
+ };
558
+
559
+ // Mirror the chunk path's partial-delivery contract: once any file reached
560
+ // the user, the error must tell the caller not to blind-retry the batch.
561
+ function partialUploadError(error: string, delivered: number, total: number): string {
562
+ if (delivered === 0) return error;
563
+ return `file ${delivered + 1}/${total} failed after partial delivery `
564
+ + `(do not resend; ${delivered} file(s) are already visible): ${error}`;
565
+ }
566
+
567
+ // Minimal extension map for the document types agents actually send. Meta's
568
+ // media endpoint rejects MIME types outside its supported list
569
+ // (application/octet-stream is not on it), so unknowns go out as text/plain.
570
+ const MIME_BY_EXT: Record<string, string> = {
571
+ pdf: "application/pdf",
572
+ txt: "text/plain",
573
+ md: "text/plain",
574
+ csv: "text/csv",
575
+ png: "image/png",
576
+ jpg: "image/jpeg",
577
+ jpeg: "image/jpeg",
578
+ webp: "image/webp",
579
+ mp4: "video/mp4",
580
+ doc: "application/msword",
581
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
582
+ xls: "application/vnd.ms-excel",
583
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
584
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
585
+ zip: "application/zip",
586
+ };
587
+
588
+ function mimeForFilename(name: string): string {
589
+ const ext = name.toLowerCase().split(".").pop() || "";
590
+ return MIME_BY_EXT[ext] || "text/plain";
591
+ }
592
+
593
+ const CHANNEL_ADAPTERS: Record<string, ChannelAdapter> = {
594
+ slack: slackAdapter,
595
+ whatsapp: whatsappAdapter,
596
+ };
597
+
598
+ export function getChannelAdapter(source: string): ChannelAdapter | undefined {
599
+ return CHANNEL_ADAPTERS[source];
600
+ }