@ch4p/cli 0.1.4 → 0.1.5

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,4368 @@
1
+ import {
2
+ generateId
3
+ } from "./chunk-YSCX2QQQ.js";
4
+
5
+ // ../../packages/channels/dist/index.js
6
+ import { createInterface } from "readline";
7
+ import WebSocket from "ws";
8
+ import WebSocket2 from "ws";
9
+ import { EventEmitter } from "events";
10
+ import { Socket } from "net";
11
+ import { execFile as execFileCb } from "child_process";
12
+ import { homedir } from "os";
13
+ import { promisify } from "util";
14
+ import { createSign } from "crypto";
15
+ import { connect as netConnect } from "net";
16
+ import { connect as tlsConnect } from "tls";
17
+ import { execFile as execFileCb2 } from "child_process";
18
+ import { promisify as promisify2 } from "util";
19
+ var CliChannel = class {
20
+ id = "cli";
21
+ name = "CLI";
22
+ rl = null;
23
+ messageHandler = null;
24
+ running = false;
25
+ async start(_config) {
26
+ if (this.running) return;
27
+ this.rl = createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout,
30
+ terminal: false
31
+ });
32
+ this.rl.on("line", (line) => {
33
+ const text = line.trim();
34
+ if (!text) return;
35
+ if (this.messageHandler) {
36
+ const msg = {
37
+ id: generateId(),
38
+ channelId: this.id,
39
+ from: {
40
+ channelId: this.id,
41
+ userId: "cli-user"
42
+ },
43
+ text,
44
+ timestamp: /* @__PURE__ */ new Date(),
45
+ raw: line
46
+ };
47
+ this.messageHandler(msg);
48
+ }
49
+ });
50
+ this.rl.on("close", () => {
51
+ this.running = false;
52
+ });
53
+ this.running = true;
54
+ }
55
+ async stop() {
56
+ if (this.rl) {
57
+ this.rl.close();
58
+ this.rl = null;
59
+ }
60
+ this.running = false;
61
+ }
62
+ async send(_to, message) {
63
+ try {
64
+ const output = this.formatOutput(message);
65
+ process.stdout.write(output + "\n");
66
+ return {
67
+ success: true,
68
+ messageId: generateId()
69
+ };
70
+ } catch (err) {
71
+ return {
72
+ success: false,
73
+ error: err instanceof Error ? err.message : String(err)
74
+ };
75
+ }
76
+ }
77
+ onMessage(handler) {
78
+ this.messageHandler = handler;
79
+ }
80
+ onPresence(_handler) {
81
+ }
82
+ async isHealthy() {
83
+ return this.running;
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Private helpers
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Format an outbound message for terminal display.
90
+ * When the format is markdown, strip or convert common markdown syntax
91
+ * to terminal-friendly plain text.
92
+ */
93
+ formatOutput(message) {
94
+ if (message.format === "markdown") {
95
+ return this.markdownToTerminal(message.text);
96
+ }
97
+ if (message.format === "html") {
98
+ return this.stripHtml(message.text);
99
+ }
100
+ return message.text;
101
+ }
102
+ /**
103
+ * Lightweight markdown-to-terminal conversion.
104
+ * Handles headers, bold, italic, code blocks, inline code, links, and
105
+ * horizontal rules without pulling in any external dependency.
106
+ */
107
+ markdownToTerminal(md) {
108
+ let text = md;
109
+ text = text.replace(/```[\s\S]*?\n([\s\S]*?)```/g, (_match, code) => {
110
+ return code.split("\n").map((line) => ` ${line}`).join("\n");
111
+ });
112
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, (_match, heading) => {
113
+ return `
114
+ ${heading.toUpperCase()}
115
+ ${"=".repeat(heading.length)}`;
116
+ });
117
+ text = text.replace(/\*\*(.+?)\*\*/g, "$1");
118
+ text = text.replace(/__(.+?)__/g, "$1");
119
+ text = text.replace(/\*(.+?)\*/g, "$1");
120
+ text = text.replace(/_(.+?)_/g, "$1");
121
+ text = text.replace(/`([^`]+)`/g, "$1");
122
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
123
+ text = text.replace(/^[-*_]{3,}$/gm, "---");
124
+ text = text.replace(/^\s*[-*+]\s+/gm, " - ");
125
+ return text.trim();
126
+ }
127
+ /** Strip HTML tags, returning plain text. */
128
+ stripHtml(html) {
129
+ return html.replace(/<[^>]*>/g, "").trim();
130
+ }
131
+ };
132
+ function splitMessage(text, maxLen) {
133
+ if (!text) return [""];
134
+ if (maxLen < 1) return [text];
135
+ if (text.length <= maxLen) return [text];
136
+ const chunks = [];
137
+ let remaining = text;
138
+ while (remaining.length > 0) {
139
+ if (remaining.length <= maxLen) {
140
+ chunks.push(remaining);
141
+ break;
142
+ }
143
+ const lastSpace = remaining.lastIndexOf(" ", maxLen);
144
+ const splitAt = lastSpace > maxLen * 0.5 ? lastSpace : maxLen;
145
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
146
+ remaining = remaining.slice(splitAt).trimStart();
147
+ }
148
+ return chunks;
149
+ }
150
+ function truncateMessage(text, maxLen) {
151
+ if (maxLen < 1) return text;
152
+ if (text.length <= maxLen) return text;
153
+ let end = maxLen - 1;
154
+ if (end > 0 && text.charCodeAt(end - 1) >= 55296 && text.charCodeAt(end - 1) <= 56319) {
155
+ end--;
156
+ }
157
+ return text.slice(0, end) + "\u2026";
158
+ }
159
+ function evictOldTimestamps(map, maxEntries) {
160
+ if (map.size <= maxEntries) return;
161
+ const sorted = [...map.entries()].sort((a, b) => a[1] - b[1]);
162
+ const toDelete = Math.max(1, Math.floor(sorted.length / 4));
163
+ for (let i = 0; i < toDelete; i++) {
164
+ map.delete(sorted[i][0]);
165
+ }
166
+ }
167
+ var WEBHOOK_TIMEOUT_MS = 8e3;
168
+ var TELEGRAM_MAX_MESSAGE_LEN = 4096;
169
+ var TELEGRAM_EDIT_RATE_LIMIT_MS = 1e3;
170
+ var TelegramChannel = class _TelegramChannel {
171
+ id = "telegram";
172
+ name = "Telegram";
173
+ token = "";
174
+ baseUrl = "";
175
+ messageHandler = null;
176
+ running = false;
177
+ pollTimer = null;
178
+ pollOffset = 0;
179
+ pollInterval = 1e3;
180
+ allowedUsers = /* @__PURE__ */ new Set();
181
+ abortController = null;
182
+ webhookSecret = null;
183
+ streamMode = "off";
184
+ lastEditTimestamps = /* @__PURE__ */ new Map();
185
+ static EDIT_TS_MAX_ENTRIES = 500;
186
+ // -----------------------------------------------------------------------
187
+ // IChannel implementation
188
+ // -----------------------------------------------------------------------
189
+ async start(config) {
190
+ if (this.running) return;
191
+ const cfg = config;
192
+ if (!cfg.token) {
193
+ throw new Error('Telegram channel requires a "token" in config');
194
+ }
195
+ this.token = cfg.token;
196
+ this.baseUrl = `https://api.telegram.org/bot${this.token}`;
197
+ this.pollInterval = cfg.pollInterval ?? 1e3;
198
+ this.streamMode = cfg.streamMode ?? "off";
199
+ this.webhookSecret = cfg.webhookSecret ?? null;
200
+ this.abortController = new AbortController();
201
+ const userEntries = cfg.allowedUsers ?? [];
202
+ for (const entry of userEntries) {
203
+ if (!/^\d+$/.test(entry)) {
204
+ console.warn(
205
+ `[Telegram] allowedUsers entry "${entry}" is not a numeric Telegram user ID. Telegram identifies users by numeric ID, not username. This entry may never match.`
206
+ );
207
+ }
208
+ }
209
+ this.allowedUsers = new Set(userEntries);
210
+ const me = await this.apiCall("getMe");
211
+ if (!me) {
212
+ throw new Error("Failed to verify Telegram bot token (getMe returned null)");
213
+ }
214
+ const mode = cfg.mode ?? "polling";
215
+ if (mode === "webhook") {
216
+ if (!cfg.webhookUrl) {
217
+ throw new Error('Webhook mode requires a "webhookUrl" in config');
218
+ }
219
+ const webhookParams = { url: cfg.webhookUrl };
220
+ if (this.webhookSecret) {
221
+ webhookParams.secret_token = this.webhookSecret;
222
+ }
223
+ await this.apiCall("setWebhook", webhookParams);
224
+ } else {
225
+ await this.apiCall("deleteWebhook");
226
+ this.startPolling();
227
+ }
228
+ this.running = true;
229
+ }
230
+ async stop() {
231
+ this.running = false;
232
+ if (this.pollTimer) {
233
+ clearTimeout(this.pollTimer);
234
+ this.pollTimer = null;
235
+ }
236
+ if (this.abortController) {
237
+ this.abortController.abort();
238
+ this.abortController = null;
239
+ }
240
+ }
241
+ async send(to, message) {
242
+ const chatId = to.userId ?? to.groupId;
243
+ if (!chatId) {
244
+ return { success: false, error: "Recipient must have userId or groupId" };
245
+ }
246
+ try {
247
+ const parseMode = message.format === "markdown" ? "MarkdownV2" : message.format === "html" ? "HTML" : void 0;
248
+ const rawText = parseMode === "MarkdownV2" ? this.escapeMarkdownV2(message.text) : message.text;
249
+ const chunks = splitMessage(rawText ?? "", TELEGRAM_MAX_MESSAGE_LEN);
250
+ let lastMessageId;
251
+ for (const chunk of chunks) {
252
+ const params = {
253
+ chat_id: chatId,
254
+ text: chunk,
255
+ ...parseMode ? { parse_mode: parseMode } : {},
256
+ ...message.replyTo && !lastMessageId ? { reply_to_message_id: Number(message.replyTo) } : {},
257
+ // Thread replies: send into the correct forum topic.
258
+ ...to.threadId ? { message_thread_id: Number(to.threadId) } : {}
259
+ };
260
+ const result = await this.apiCall("sendMessage", params);
261
+ lastMessageId = result ? String(result.message_id) : void 0;
262
+ }
263
+ if (message.attachments?.length) {
264
+ for (const att of message.attachments) {
265
+ await this.sendAttachment(chatId, att, to.threadId);
266
+ }
267
+ }
268
+ return {
269
+ success: true,
270
+ messageId: lastMessageId ?? generateId()
271
+ };
272
+ } catch (err) {
273
+ return {
274
+ success: false,
275
+ error: err instanceof Error ? err.message : String(err)
276
+ };
277
+ }
278
+ }
279
+ /**
280
+ * Edit a previously sent message. Used for progressive streaming updates.
281
+ * Rate-limited to avoid hitting Telegram API limits.
282
+ */
283
+ async editMessage(to, messageId, message) {
284
+ const chatId = to.userId ?? to.groupId;
285
+ if (!chatId) {
286
+ return { success: false, error: "Recipient must have userId or groupId" };
287
+ }
288
+ const lastEdit = this.lastEditTimestamps.get(messageId);
289
+ const now = Date.now();
290
+ if (lastEdit && now - lastEdit < TELEGRAM_EDIT_RATE_LIMIT_MS) {
291
+ return { success: true, messageId };
292
+ }
293
+ try {
294
+ const parseMode = message.format === "markdown" ? "MarkdownV2" : message.format === "html" ? "HTML" : void 0;
295
+ const rawEditText = parseMode === "MarkdownV2" ? this.escapeMarkdownV2(message.text) : message.text;
296
+ const text = truncateMessage(rawEditText ?? "", TELEGRAM_MAX_MESSAGE_LEN);
297
+ await this.apiCall("editMessageText", {
298
+ chat_id: chatId,
299
+ message_id: Number(messageId),
300
+ text,
301
+ ...parseMode ? { parse_mode: parseMode } : {},
302
+ ...to.threadId ? { message_thread_id: Number(to.threadId) } : {}
303
+ });
304
+ this.lastEditTimestamps.set(messageId, now);
305
+ evictOldTimestamps(this.lastEditTimestamps, _TelegramChannel.EDIT_TS_MAX_ENTRIES);
306
+ return { success: true, messageId };
307
+ } catch (err) {
308
+ return {
309
+ success: false,
310
+ error: err instanceof Error ? err.message : String(err)
311
+ };
312
+ }
313
+ }
314
+ onMessage(handler) {
315
+ this.messageHandler = handler;
316
+ }
317
+ onPresence(_handler) {
318
+ }
319
+ async isHealthy() {
320
+ if (!this.running) return false;
321
+ try {
322
+ const me = await this.apiCall("getMe");
323
+ return me !== null;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+ // -----------------------------------------------------------------------
329
+ // Webhook handler (called by the gateway when a webhook POST arrives)
330
+ // -----------------------------------------------------------------------
331
+ /**
332
+ * Verify the X-Telegram-Bot-Api-Secret-Token header matches our configured secret.
333
+ * Returns true if no secret is configured (verification disabled) or if the header matches.
334
+ */
335
+ verifyWebhookSecret(secretHeader) {
336
+ if (!this.webhookSecret) return true;
337
+ return secretHeader === this.webhookSecret;
338
+ }
339
+ /**
340
+ * Process a webhook update from Telegram.
341
+ * Call this from a gateway route handler when receiving POST /webhook/telegram.
342
+ *
343
+ * @param update - The Telegram update object.
344
+ * @param secretHeader - The X-Telegram-Bot-Api-Secret-Token header value (optional).
345
+ */
346
+ handleWebhookUpdate(update, secretHeader) {
347
+ if (!this.verifyWebhookSecret(secretHeader)) {
348
+ return;
349
+ }
350
+ if (update.message) {
351
+ this.processMessage(update.message);
352
+ }
353
+ }
354
+ /**
355
+ * Get the webhook timeout value for gateway-level timeout enforcement.
356
+ * The gateway should race its HTTP response against this timeout.
357
+ */
358
+ static get WEBHOOK_TIMEOUT_MS() {
359
+ return WEBHOOK_TIMEOUT_MS;
360
+ }
361
+ /**
362
+ * Get the current stream mode configuration.
363
+ */
364
+ getStreamMode() {
365
+ return this.streamMode;
366
+ }
367
+ // -----------------------------------------------------------------------
368
+ // Polling
369
+ // -----------------------------------------------------------------------
370
+ startPolling() {
371
+ if (!this.running && !this.abortController) return;
372
+ const POLL_CLIENT_TIMEOUT_MS = 35e3;
373
+ const poll = async () => {
374
+ if (!this.running) return;
375
+ try {
376
+ const deadline = AbortSignal.timeout(POLL_CLIENT_TIMEOUT_MS);
377
+ const signal = this.abortController ? AbortSignal.any([this.abortController.signal, deadline]) : deadline;
378
+ const updates = await this.apiCall("getUpdates", {
379
+ offset: this.pollOffset,
380
+ timeout: 30,
381
+ limit: 100
382
+ }, signal);
383
+ if (updates && updates.length > 0) {
384
+ for (const update of updates) {
385
+ this.pollOffset = update.update_id + 1;
386
+ if (update.message) {
387
+ this.processMessage(update.message);
388
+ }
389
+ }
390
+ }
391
+ } catch {
392
+ }
393
+ if (this.running) {
394
+ this.pollTimer = setTimeout(poll, this.pollInterval);
395
+ }
396
+ };
397
+ this.pollTimer = setTimeout(poll, 0);
398
+ }
399
+ // -----------------------------------------------------------------------
400
+ // Message processing
401
+ // -----------------------------------------------------------------------
402
+ processMessage(msg) {
403
+ if (!this.messageHandler) return;
404
+ const userId = msg.from ? String(msg.from.id) : "unknown";
405
+ if (this.allowedUsers.size > 0 && !this.allowedUsers.has(userId)) {
406
+ return;
407
+ }
408
+ const text = msg.text ?? "";
409
+ if (!text && !msg.photo && !msg.document && !msg.voice && !msg.audio && !msg.video) return;
410
+ const attachments = [];
411
+ if (msg.photo && msg.photo.length > 0) {
412
+ const largest = msg.photo[msg.photo.length - 1];
413
+ attachments.push({
414
+ type: "image",
415
+ url: largest.file_id,
416
+ // Will need getFile() to resolve actual URL
417
+ mimeType: "image/jpeg"
418
+ });
419
+ }
420
+ if (msg.document) {
421
+ attachments.push({
422
+ type: "file",
423
+ url: msg.document.file_id,
424
+ filename: msg.document.file_name,
425
+ mimeType: msg.document.mime_type
426
+ });
427
+ }
428
+ if (msg.voice) {
429
+ attachments.push({
430
+ type: "audio",
431
+ url: msg.voice.file_id,
432
+ mimeType: msg.voice.mime_type ?? "audio/ogg"
433
+ });
434
+ }
435
+ if (msg.audio) {
436
+ attachments.push({
437
+ type: "audio",
438
+ url: msg.audio.file_id,
439
+ mimeType: msg.audio.mime_type ?? "audio/mpeg",
440
+ filename: msg.audio.title
441
+ });
442
+ }
443
+ if (msg.video) {
444
+ attachments.push({
445
+ type: "video",
446
+ url: msg.video.file_id,
447
+ mimeType: msg.video.mime_type ?? "video/mp4"
448
+ });
449
+ }
450
+ const isGroup = msg.chat.type !== "private";
451
+ const groupId = isGroup ? String(msg.chat.id) : void 0;
452
+ const threadId = isGroup && msg.is_topic_message && msg.message_thread_id !== void 0 ? String(msg.message_thread_id) : void 0;
453
+ const inbound = {
454
+ id: String(msg.message_id),
455
+ channelId: this.id,
456
+ from: {
457
+ channelId: this.id,
458
+ userId,
459
+ groupId,
460
+ threadId
461
+ },
462
+ text,
463
+ attachments: attachments.length > 0 ? attachments : void 0,
464
+ replyTo: msg.reply_to_message ? String(msg.reply_to_message.message_id) : void 0,
465
+ timestamp: new Date(msg.date * 1e3),
466
+ raw: msg
467
+ };
468
+ this.messageHandler(inbound);
469
+ }
470
+ // -----------------------------------------------------------------------
471
+ // API helpers
472
+ // -----------------------------------------------------------------------
473
+ async apiCall(method, params, signal) {
474
+ const url = `${this.baseUrl}/${method}`;
475
+ const response = await fetch(url, {
476
+ method: "POST",
477
+ headers: { "Content-Type": "application/json" },
478
+ body: params ? JSON.stringify(params) : void 0,
479
+ signal: signal ?? this.abortController?.signal
480
+ });
481
+ const data = await response.json();
482
+ if (!data.ok) {
483
+ throw new Error(`Telegram API error: ${data.description ?? "Unknown error"}`);
484
+ }
485
+ return data.result ?? null;
486
+ }
487
+ /**
488
+ * Resolve a Telegram file_id to a downloadable URL.
489
+ * Uses the getFile API to get the file_path, then constructs the full URL.
490
+ */
491
+ async getFileUrl(fileId) {
492
+ try {
493
+ const file = await this.apiCall("getFile", { file_id: fileId });
494
+ if (file?.file_path) {
495
+ return `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
496
+ }
497
+ } catch {
498
+ }
499
+ return null;
500
+ }
501
+ /**
502
+ * Download a Telegram file by its file_id and return the raw Buffer.
503
+ */
504
+ async downloadFile(fileId) {
505
+ const url = await this.getFileUrl(fileId);
506
+ if (!url) return null;
507
+ try {
508
+ const response = await fetch(url, { signal: this.abortController?.signal });
509
+ if (!response.ok) return null;
510
+ const arrayBuf = await response.arrayBuffer();
511
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
512
+ return { data: Buffer.from(arrayBuf), mimeType: contentType };
513
+ } catch {
514
+ return null;
515
+ }
516
+ }
517
+ async sendAttachment(chatId, att, threadId) {
518
+ const isVoice = att.type === "audio" && (att.mimeType === "audio/ogg" || att.mimeType === "audio/ogg; codecs=opus" || att.filename?.endsWith(".ogg") || att.filename?.endsWith(".oga"));
519
+ let method;
520
+ let paramKey;
521
+ if (isVoice) {
522
+ method = "sendVoice";
523
+ paramKey = "voice";
524
+ } else if (att.type === "image") {
525
+ method = "sendPhoto";
526
+ paramKey = "photo";
527
+ } else if (att.type === "audio") {
528
+ method = "sendAudio";
529
+ paramKey = "audio";
530
+ } else if (att.type === "video") {
531
+ method = "sendVideo";
532
+ paramKey = "video";
533
+ } else {
534
+ method = "sendDocument";
535
+ paramKey = "document";
536
+ }
537
+ await this.apiCall(method, {
538
+ chat_id: chatId,
539
+ [paramKey]: att.url ?? att.filename ?? "",
540
+ ...threadId ? { message_thread_id: Number(threadId) } : {}
541
+ });
542
+ }
543
+ /**
544
+ * Escape special characters for Telegram MarkdownV2 parse mode.
545
+ * Telegram requires escaping: _ * [ ] ( ) ~ ` > # + - = | { } . !
546
+ */
547
+ escapeMarkdownV2(text) {
548
+ return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
549
+ }
550
+ };
551
+ var DISCORD_MAX_MESSAGE_LEN = 2e3;
552
+ var DISCORD_EDIT_RATE_LIMIT_MS = 1e3;
553
+ var DISCORD_RECONNECT_BASE_MS = 1e3;
554
+ var DISCORD_RECONNECT_MAX_MS = 6e4;
555
+ var GatewayOp = {
556
+ DISPATCH: 0,
557
+ HEARTBEAT: 1,
558
+ IDENTIFY: 2,
559
+ RESUME: 6,
560
+ RECONNECT: 7,
561
+ INVALID_SESSION: 9,
562
+ HELLO: 10,
563
+ HEARTBEAT_ACK: 11
564
+ };
565
+ var DiscordIntents = {
566
+ GUILDS: 1 << 0,
567
+ GUILD_MESSAGES: 1 << 9,
568
+ GUILD_MESSAGE_TYPING: 1 << 11,
569
+ DIRECT_MESSAGES: 1 << 12,
570
+ DIRECT_MESSAGE_TYPING: 1 << 14,
571
+ MESSAGE_CONTENT: 1 << 15
572
+ };
573
+ var DEFAULT_INTENTS = DiscordIntents.GUILDS | DiscordIntents.GUILD_MESSAGES | DiscordIntents.DIRECT_MESSAGES | DiscordIntents.MESSAGE_CONTENT;
574
+ var API_BASE = "https://discord.com/api/v10";
575
+ var GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
576
+ var DiscordChannel = class _DiscordChannel {
577
+ id = "discord";
578
+ name = "Discord";
579
+ token = "";
580
+ intents = DEFAULT_INTENTS;
581
+ messageHandler = null;
582
+ presenceHandler = null;
583
+ running = false;
584
+ ws = null;
585
+ heartbeatTimer = null;
586
+ sequence = null;
587
+ sessionId = null;
588
+ resumeGatewayUrl = null;
589
+ botUserId = null;
590
+ allowedGuilds = /* @__PURE__ */ new Set();
591
+ allowedUsers = /* @__PURE__ */ new Set();
592
+ streamMode = "off";
593
+ reconnectAttempts = 0;
594
+ lastEditTimestamps = /* @__PURE__ */ new Map();
595
+ static EDIT_TS_MAX_ENTRIES = 500;
596
+ // -----------------------------------------------------------------------
597
+ // IChannel implementation
598
+ // -----------------------------------------------------------------------
599
+ async start(config) {
600
+ if (this.running) return;
601
+ const cfg = config;
602
+ if (!cfg.token) {
603
+ throw new Error('Discord channel requires a "token" in config');
604
+ }
605
+ this.token = cfg.token;
606
+ this.intents = cfg.intents ?? DEFAULT_INTENTS;
607
+ this.allowedGuilds = new Set(cfg.allowedGuilds ?? []);
608
+ this.allowedUsers = new Set(cfg.allowedUsers ?? []);
609
+ this.streamMode = cfg.streamMode ?? "off";
610
+ const me = await this.apiCall("/users/@me");
611
+ this.botUserId = me.id;
612
+ await this.connectGateway();
613
+ this.running = true;
614
+ }
615
+ async stop() {
616
+ this.running = false;
617
+ if (this.heartbeatTimer) {
618
+ clearInterval(this.heartbeatTimer);
619
+ this.heartbeatTimer = null;
620
+ }
621
+ if (this.ws) {
622
+ try {
623
+ this.ws.close(1e3, "Shutting down");
624
+ } catch {
625
+ }
626
+ this.ws = null;
627
+ }
628
+ }
629
+ async send(to, message) {
630
+ const channelId = to.groupId ?? to.threadId ?? to.userId;
631
+ if (!channelId) {
632
+ return { success: false, error: "Recipient must have groupId, threadId, or userId (DM channel ID)" };
633
+ }
634
+ try {
635
+ const chunks = splitMessage(message.text ?? "", DISCORD_MAX_MESSAGE_LEN);
636
+ let lastId;
637
+ for (const chunk of chunks) {
638
+ const body = { content: chunk };
639
+ if (!lastId && message.replyTo) {
640
+ body.message_reference = { message_id: message.replyTo };
641
+ }
642
+ const result = await this.apiCall(
643
+ `/channels/${channelId}/messages`,
644
+ "POST",
645
+ body
646
+ );
647
+ lastId = result.id;
648
+ }
649
+ return { success: true, messageId: lastId ?? "" };
650
+ } catch (err) {
651
+ return {
652
+ success: false,
653
+ error: err instanceof Error ? err.message : String(err)
654
+ };
655
+ }
656
+ }
657
+ /**
658
+ * Edit a previously sent message. Used for progressive streaming updates.
659
+ * Rate-limited to avoid hitting Discord API limits.
660
+ */
661
+ async editMessage(to, messageId, message) {
662
+ const channelId = to.groupId ?? to.threadId ?? to.userId;
663
+ if (!channelId) {
664
+ return { success: false, error: "Recipient must have groupId, threadId, or userId" };
665
+ }
666
+ const lastEdit = this.lastEditTimestamps.get(messageId);
667
+ const now = Date.now();
668
+ if (lastEdit && now - lastEdit < DISCORD_EDIT_RATE_LIMIT_MS) {
669
+ return { success: true, messageId };
670
+ }
671
+ try {
672
+ await this.apiCall(
673
+ `/channels/${channelId}/messages/${messageId}`,
674
+ "PATCH",
675
+ { content: truncateMessage(message.text ?? "", DISCORD_MAX_MESSAGE_LEN) }
676
+ );
677
+ this.lastEditTimestamps.set(messageId, now);
678
+ evictOldTimestamps(this.lastEditTimestamps, _DiscordChannel.EDIT_TS_MAX_ENTRIES);
679
+ return { success: true, messageId };
680
+ } catch (err) {
681
+ return {
682
+ success: false,
683
+ error: err instanceof Error ? err.message : String(err)
684
+ };
685
+ }
686
+ }
687
+ /**
688
+ * Get the current stream mode configuration.
689
+ */
690
+ getStreamMode() {
691
+ return this.streamMode;
692
+ }
693
+ onMessage(handler) {
694
+ this.messageHandler = handler;
695
+ }
696
+ onPresence(handler) {
697
+ this.presenceHandler = handler;
698
+ }
699
+ async isHealthy() {
700
+ if (!this.running) return false;
701
+ try {
702
+ await this.apiCall("/users/@me");
703
+ return true;
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+ // -----------------------------------------------------------------------
709
+ // Gateway WebSocket
710
+ // -----------------------------------------------------------------------
711
+ async connectGateway() {
712
+ const url = this.resumeGatewayUrl ?? GATEWAY_URL;
713
+ return new Promise((resolve, reject) => {
714
+ this.ws = new WebSocket(url);
715
+ let identified = false;
716
+ this.ws.on("message", (data) => {
717
+ try {
718
+ const payload = JSON.parse(data.toString());
719
+ if (payload.s !== null) {
720
+ this.sequence = payload.s;
721
+ }
722
+ switch (payload.op) {
723
+ case GatewayOp.HELLO: {
724
+ const { heartbeat_interval } = payload.d;
725
+ this.startHeartbeat(heartbeat_interval);
726
+ if (this.sessionId && this.sequence !== null) {
727
+ this.ws.send(JSON.stringify({
728
+ op: GatewayOp.RESUME,
729
+ d: {
730
+ token: this.token,
731
+ session_id: this.sessionId,
732
+ seq: this.sequence
733
+ }
734
+ }));
735
+ } else {
736
+ this.ws.send(JSON.stringify({
737
+ op: GatewayOp.IDENTIFY,
738
+ d: {
739
+ token: this.token,
740
+ intents: this.intents,
741
+ properties: {
742
+ os: "linux",
743
+ browser: "ch4p",
744
+ device: "ch4p"
745
+ }
746
+ }
747
+ }));
748
+ }
749
+ break;
750
+ }
751
+ case GatewayOp.HEARTBEAT_ACK:
752
+ break;
753
+ case GatewayOp.HEARTBEAT:
754
+ this.sendHeartbeat();
755
+ break;
756
+ case GatewayOp.RECONNECT:
757
+ this.reconnect();
758
+ break;
759
+ case GatewayOp.INVALID_SESSION: {
760
+ const resumable = payload.d;
761
+ if (!resumable) {
762
+ this.sessionId = null;
763
+ this.sequence = null;
764
+ this.resumeGatewayUrl = null;
765
+ }
766
+ this.reconnect();
767
+ break;
768
+ }
769
+ case GatewayOp.DISPATCH:
770
+ this.handleDispatch(payload.t, payload.d);
771
+ if (payload.t === "READY") {
772
+ const ready = payload.d;
773
+ this.sessionId = ready.session_id;
774
+ this.resumeGatewayUrl = ready.resume_gateway_url;
775
+ this.reconnectAttempts = 0;
776
+ if (!identified) {
777
+ identified = true;
778
+ resolve();
779
+ }
780
+ }
781
+ break;
782
+ }
783
+ } catch {
784
+ }
785
+ });
786
+ this.ws.on("error", (err) => {
787
+ if (!identified) {
788
+ reject(err);
789
+ }
790
+ });
791
+ this.ws.on("close", () => {
792
+ if (this.running) {
793
+ this.reconnect();
794
+ }
795
+ });
796
+ setTimeout(() => {
797
+ if (!identified) {
798
+ reject(new Error("Discord gateway connection timed out"));
799
+ }
800
+ }, 3e4);
801
+ });
802
+ }
803
+ startHeartbeat(intervalMs) {
804
+ if (this.heartbeatTimer) {
805
+ clearInterval(this.heartbeatTimer);
806
+ }
807
+ setTimeout(() => this.sendHeartbeat(), Math.random() * intervalMs);
808
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), intervalMs);
809
+ }
810
+ sendHeartbeat() {
811
+ if (this.ws?.readyState === 1) {
812
+ this.ws.send(JSON.stringify({
813
+ op: GatewayOp.HEARTBEAT,
814
+ d: this.sequence
815
+ }));
816
+ }
817
+ }
818
+ reconnect() {
819
+ this.reconnectAttempts++;
820
+ const delayMs = Math.min(DISCORD_RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts - 1), DISCORD_RECONNECT_MAX_MS);
821
+ const jitter = delayMs * 0.2 * (Math.random() * 2 - 1);
822
+ if (this.ws) {
823
+ try {
824
+ this.ws.close(4e3, "Reconnecting");
825
+ } catch {
826
+ }
827
+ this.ws = null;
828
+ }
829
+ if (this.heartbeatTimer) {
830
+ clearInterval(this.heartbeatTimer);
831
+ this.heartbeatTimer = null;
832
+ }
833
+ if (this.running) {
834
+ setTimeout(() => {
835
+ this.connectGateway().catch(() => {
836
+ this.reconnect();
837
+ });
838
+ }, Math.max(0, delayMs + jitter));
839
+ }
840
+ }
841
+ // -----------------------------------------------------------------------
842
+ // Event dispatch
843
+ // -----------------------------------------------------------------------
844
+ handleDispatch(event, data) {
845
+ switch (event) {
846
+ case "MESSAGE_CREATE":
847
+ this.handleMessageCreate(data);
848
+ break;
849
+ case "TYPING_START":
850
+ this.handleTypingStart(data);
851
+ break;
852
+ }
853
+ }
854
+ handleMessageCreate(msg) {
855
+ if (!this.messageHandler) return;
856
+ if (msg.author.id === this.botUserId) return;
857
+ if (msg.author.bot) return;
858
+ if (msg.guild_id && this.allowedGuilds.size > 0 && !this.allowedGuilds.has(msg.guild_id)) {
859
+ return;
860
+ }
861
+ if (this.allowedUsers.size > 0 && !this.allowedUsers.has(msg.author.id)) {
862
+ return;
863
+ }
864
+ const attachments = (msg.attachments ?? []).map((a) => ({
865
+ type: this.classifyAttachment(a.content_type ?? ""),
866
+ url: a.url,
867
+ filename: a.filename,
868
+ mimeType: a.content_type
869
+ }));
870
+ const inbound = {
871
+ id: msg.id,
872
+ channelId: this.id,
873
+ from: {
874
+ channelId: this.id,
875
+ userId: msg.author.id,
876
+ groupId: msg.channel_id
877
+ },
878
+ text: msg.content,
879
+ attachments: attachments.length > 0 ? attachments : void 0,
880
+ replyTo: msg.message_reference?.message_id,
881
+ timestamp: new Date(msg.timestamp),
882
+ raw: msg
883
+ };
884
+ this.messageHandler(inbound);
885
+ }
886
+ handleTypingStart(event) {
887
+ if (!this.presenceHandler) return;
888
+ this.presenceHandler({
889
+ userId: event.user_id,
890
+ status: "typing",
891
+ channelId: event.channel_id
892
+ });
893
+ }
894
+ // -----------------------------------------------------------------------
895
+ // REST API
896
+ // -----------------------------------------------------------------------
897
+ async apiCall(path, method = "GET", body) {
898
+ const response = await fetch(`${API_BASE}${path}`, {
899
+ method,
900
+ headers: {
901
+ "Authorization": `Bot ${this.token}`,
902
+ "Content-Type": "application/json"
903
+ },
904
+ body: body ? JSON.stringify(body) : void 0
905
+ });
906
+ if (!response.ok) {
907
+ const text = await response.text().catch(() => "Unknown error");
908
+ throw new Error(`Discord API error (${response.status}): ${text}`);
909
+ }
910
+ return await response.json();
911
+ }
912
+ classifyAttachment(mimeType) {
913
+ if (mimeType.startsWith("image/")) return "image";
914
+ if (mimeType.startsWith("audio/")) return "audio";
915
+ if (mimeType.startsWith("video/")) return "video";
916
+ return "file";
917
+ }
918
+ };
919
+ var API_BASE2 = "https://slack.com/api";
920
+ var SLACK_MAX_MESSAGE_LEN = 4e3;
921
+ var SLACK_EDIT_RATE_LIMIT_MS = 1e3;
922
+ var SLACK_RECONNECT_BASE_MS = 1e3;
923
+ var SLACK_RECONNECT_MAX_MS = 6e4;
924
+ var SlackChannel = class _SlackChannel {
925
+ id = "slack";
926
+ name = "Slack";
927
+ botToken = "";
928
+ appToken = "";
929
+ signingSecret = "";
930
+ streamMode = "off";
931
+ messageHandler = null;
932
+ presenceHandler = null;
933
+ running = false;
934
+ ws = null;
935
+ botUserId = null;
936
+ allowedChannels = /* @__PURE__ */ new Set();
937
+ allowedUsers = /* @__PURE__ */ new Set();
938
+ pingTimer = null;
939
+ reconnectAttempts = 0;
940
+ lastEditTimestamps = /* @__PURE__ */ new Map();
941
+ static EDIT_TS_MAX_ENTRIES = 500;
942
+ // -----------------------------------------------------------------------
943
+ // IChannel implementation
944
+ // -----------------------------------------------------------------------
945
+ async start(config) {
946
+ if (this.running) return;
947
+ const cfg = config;
948
+ if (!cfg.botToken) {
949
+ throw new Error('Slack channel requires a "botToken" in config');
950
+ }
951
+ this.botToken = cfg.botToken;
952
+ this.appToken = cfg.appToken ?? "";
953
+ this.signingSecret = cfg.signingSecret ?? "";
954
+ this.streamMode = cfg.streamMode ?? "off";
955
+ this.allowedChannels = new Set(cfg.allowedChannels ?? []);
956
+ this.allowedUsers = new Set(cfg.allowedUsers ?? []);
957
+ const auth = await this.apiCall("auth.test");
958
+ if (!auth.ok) {
959
+ throw new Error(`Slack auth.test failed: ${auth.error ?? "Unknown error"}`);
960
+ }
961
+ this.botUserId = auth.user_id;
962
+ const mode = cfg.mode ?? (this.appToken ? "socket" : "events");
963
+ if (mode === "socket") {
964
+ if (!this.appToken) {
965
+ throw new Error('Socket Mode requires an "appToken" (xapp-*) in config');
966
+ }
967
+ await this.connectSocketMode();
968
+ }
969
+ this.running = true;
970
+ }
971
+ async stop() {
972
+ this.running = false;
973
+ if (this.pingTimer) {
974
+ clearInterval(this.pingTimer);
975
+ this.pingTimer = null;
976
+ }
977
+ if (this.ws) {
978
+ try {
979
+ this.ws.close(1e3, "Shutting down");
980
+ } catch {
981
+ }
982
+ this.ws = null;
983
+ }
984
+ }
985
+ async send(to, message) {
986
+ const channel = to.groupId ?? to.userId;
987
+ if (!channel) {
988
+ return { success: false, error: "Recipient must have groupId or userId" };
989
+ }
990
+ try {
991
+ const chunks = splitMessage(message.text ?? "", SLACK_MAX_MESSAGE_LEN);
992
+ let lastTs;
993
+ for (const chunk of chunks) {
994
+ const params = {
995
+ channel,
996
+ text: chunk
997
+ };
998
+ if (message.format === "markdown" || !message.format) {
999
+ params.mrkdwn = true;
1000
+ }
1001
+ if (!lastTs && message.replyTo) {
1002
+ params.thread_ts = message.replyTo;
1003
+ }
1004
+ const result = await this.apiCall("chat.postMessage", params);
1005
+ if (!result.ok) {
1006
+ return { success: false, error: result.error ?? "chat.postMessage failed" };
1007
+ }
1008
+ lastTs = result.ts;
1009
+ }
1010
+ if (message.attachments?.length) {
1011
+ for (const att of message.attachments) {
1012
+ await this.uploadFile(channel, att, message.replyTo);
1013
+ }
1014
+ }
1015
+ return {
1016
+ success: true,
1017
+ messageId: lastTs ?? ""
1018
+ };
1019
+ } catch (err) {
1020
+ return {
1021
+ success: false,
1022
+ error: err instanceof Error ? err.message : String(err)
1023
+ };
1024
+ }
1025
+ }
1026
+ /** Edit a previously sent message. Used for progressive streaming updates. */
1027
+ async editMessage(to, messageId, message) {
1028
+ const channel = to.groupId ?? to.userId;
1029
+ if (!channel) {
1030
+ return { success: false, error: "Recipient must have groupId or userId" };
1031
+ }
1032
+ const lastEdit = this.lastEditTimestamps.get(messageId);
1033
+ const now = Date.now();
1034
+ if (lastEdit && now - lastEdit < SLACK_EDIT_RATE_LIMIT_MS) {
1035
+ return { success: true, messageId };
1036
+ }
1037
+ try {
1038
+ const result = await this.apiCall("chat.update", {
1039
+ channel,
1040
+ ts: messageId,
1041
+ text: truncateMessage(message.text ?? "", SLACK_MAX_MESSAGE_LEN)
1042
+ });
1043
+ if (!result.ok) {
1044
+ return { success: false, error: result.error ?? "chat.update failed" };
1045
+ }
1046
+ this.lastEditTimestamps.set(messageId, now);
1047
+ evictOldTimestamps(this.lastEditTimestamps, _SlackChannel.EDIT_TS_MAX_ENTRIES);
1048
+ return { success: true, messageId };
1049
+ } catch (err) {
1050
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1051
+ }
1052
+ }
1053
+ /** Return the configured stream mode. */
1054
+ getStreamMode() {
1055
+ return this.streamMode;
1056
+ }
1057
+ onMessage(handler) {
1058
+ this.messageHandler = handler;
1059
+ }
1060
+ onPresence(handler) {
1061
+ this.presenceHandler = handler;
1062
+ }
1063
+ async isHealthy() {
1064
+ if (!this.running) return false;
1065
+ try {
1066
+ const result = await this.apiCall("auth.test");
1067
+ return result.ok === true;
1068
+ } catch {
1069
+ return false;
1070
+ }
1071
+ }
1072
+ // -----------------------------------------------------------------------
1073
+ // Events API webhook handler
1074
+ // -----------------------------------------------------------------------
1075
+ /**
1076
+ * Process an Events API payload from the gateway.
1077
+ * Call this from a gateway route handler when receiving POST /webhook/slack.
1078
+ *
1079
+ * Returns a response body:
1080
+ * - For url_verification: returns { challenge } for Slack's verification.
1081
+ * - For event_callback: processes the event and returns { ok: true }.
1082
+ */
1083
+ handleEventsPayload(body) {
1084
+ const type = body.type;
1085
+ if (type === "url_verification") {
1086
+ return {
1087
+ status: 200,
1088
+ body: { challenge: body.challenge }
1089
+ };
1090
+ }
1091
+ if (type === "event_callback") {
1092
+ const event = body.event;
1093
+ if (event) {
1094
+ this.processEvent(event);
1095
+ }
1096
+ return { status: 200, body: { ok: true } };
1097
+ }
1098
+ return { status: 200, body: { ok: true } };
1099
+ }
1100
+ /**
1101
+ * Verify a Slack request signature.
1102
+ * Uses the signing secret to validate that the request came from Slack.
1103
+ */
1104
+ async verifySignature(body, timestamp, signature) {
1105
+ if (!this.signingSecret) return true;
1106
+ const { createHmac } = await import("crypto");
1107
+ const baseString = `v0:${timestamp}:${body}`;
1108
+ const hash = createHmac("sha256", this.signingSecret).update(baseString).digest("hex");
1109
+ const expected = `v0=${hash}`;
1110
+ return expected === signature;
1111
+ }
1112
+ // -----------------------------------------------------------------------
1113
+ // Socket Mode
1114
+ // -----------------------------------------------------------------------
1115
+ async connectSocketMode() {
1116
+ const response = await fetch(`${API_BASE2}/apps.connections.open`, {
1117
+ method: "POST",
1118
+ headers: {
1119
+ "Authorization": `Bearer ${this.appToken}`,
1120
+ "Content-Type": "application/x-www-form-urlencoded"
1121
+ }
1122
+ });
1123
+ const data = await response.json();
1124
+ if (!data.ok) {
1125
+ throw new Error(`Socket Mode connection failed: ${data.error ?? "Unknown error"}`);
1126
+ }
1127
+ const wsUrl = data.url;
1128
+ return new Promise((resolve, reject) => {
1129
+ this.ws = new WebSocket2(wsUrl);
1130
+ let connected = false;
1131
+ this.ws.on("open", () => {
1132
+ connected = true;
1133
+ this.reconnectAttempts = 0;
1134
+ if (this.pingTimer) {
1135
+ clearInterval(this.pingTimer);
1136
+ this.pingTimer = null;
1137
+ }
1138
+ this.pingTimer = setInterval(() => {
1139
+ if (this.ws?.readyState === 1) {
1140
+ this.ws.ping();
1141
+ }
1142
+ }, 3e4);
1143
+ resolve();
1144
+ });
1145
+ this.ws.on("message", (data2) => {
1146
+ try {
1147
+ const envelope = JSON.parse(data2.toString());
1148
+ this.handleSocketEnvelope(envelope);
1149
+ } catch {
1150
+ }
1151
+ });
1152
+ this.ws.on("error", (err) => {
1153
+ if (!connected) {
1154
+ reject(err);
1155
+ }
1156
+ });
1157
+ this.ws.on("close", () => {
1158
+ if (this.running) {
1159
+ this.scheduleReconnect();
1160
+ }
1161
+ });
1162
+ setTimeout(() => {
1163
+ if (!connected) {
1164
+ reject(new Error("Slack Socket Mode connection timed out"));
1165
+ }
1166
+ }, 3e4);
1167
+ });
1168
+ }
1169
+ scheduleReconnect() {
1170
+ this.reconnectAttempts++;
1171
+ const delay = Math.min(SLACK_RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts - 1), SLACK_RECONNECT_MAX_MS);
1172
+ const jitter = delay * 0.2 * (Math.random() * 2 - 1);
1173
+ setTimeout(() => {
1174
+ if (!this.running) return;
1175
+ this.connectSocketMode().catch(() => {
1176
+ this.scheduleReconnect();
1177
+ });
1178
+ }, Math.max(0, delay + jitter));
1179
+ }
1180
+ handleSocketEnvelope(envelope) {
1181
+ if (envelope.envelope_id && this.ws?.readyState === 1) {
1182
+ this.ws.send(JSON.stringify({
1183
+ envelope_id: envelope.envelope_id
1184
+ }));
1185
+ }
1186
+ if (envelope.type === "events_api" && envelope.payload?.event) {
1187
+ this.processEvent(envelope.payload.event);
1188
+ }
1189
+ }
1190
+ // -----------------------------------------------------------------------
1191
+ // Event processing
1192
+ // -----------------------------------------------------------------------
1193
+ processEvent(event) {
1194
+ if (event.type === "user_typing" && this.presenceHandler) {
1195
+ this.presenceHandler({
1196
+ userId: event.user ?? "unknown",
1197
+ status: "typing",
1198
+ channelId: event.channel
1199
+ });
1200
+ return;
1201
+ }
1202
+ if (event.type === "message" && !event.subtype) {
1203
+ this.processMessage(event);
1204
+ }
1205
+ }
1206
+ processMessage(event) {
1207
+ if (!this.messageHandler) return;
1208
+ if (event.bot_id) return;
1209
+ if (event.user === this.botUserId) return;
1210
+ const userId = event.user ?? "unknown";
1211
+ if (this.allowedChannels.size > 0 && !this.allowedChannels.has(event.channel)) {
1212
+ return;
1213
+ }
1214
+ if (this.allowedUsers.size > 0 && !this.allowedUsers.has(userId)) {
1215
+ return;
1216
+ }
1217
+ const attachments = (event.files ?? []).map((f) => ({
1218
+ type: this.classifyFile(f.mimetype),
1219
+ url: f.url_private,
1220
+ filename: f.name,
1221
+ mimeType: f.mimetype
1222
+ }));
1223
+ const inbound = {
1224
+ id: event.ts,
1225
+ channelId: this.id,
1226
+ from: {
1227
+ channelId: this.id,
1228
+ userId,
1229
+ groupId: event.channel,
1230
+ threadId: event.thread_ts
1231
+ },
1232
+ text: event.text ?? "",
1233
+ attachments: attachments.length > 0 ? attachments : void 0,
1234
+ replyTo: event.thread_ts,
1235
+ timestamp: new Date(parseFloat(event.ts) * 1e3),
1236
+ raw: event
1237
+ };
1238
+ this.messageHandler(inbound);
1239
+ }
1240
+ // -----------------------------------------------------------------------
1241
+ // Web API
1242
+ // -----------------------------------------------------------------------
1243
+ async apiCall(method, params) {
1244
+ const response = await fetch(`${API_BASE2}/${method}`, {
1245
+ method: "POST",
1246
+ headers: {
1247
+ "Authorization": `Bearer ${this.botToken}`,
1248
+ "Content-Type": "application/json; charset=utf-8"
1249
+ },
1250
+ body: params ? JSON.stringify(params) : void 0
1251
+ });
1252
+ return await response.json();
1253
+ }
1254
+ async uploadFile(channel, att, threadTs) {
1255
+ if (att.url) {
1256
+ await this.apiCall("chat.postMessage", {
1257
+ channel,
1258
+ text: `\u{1F4CE} ${att.filename ?? "attachment"}: ${att.url}`,
1259
+ ...threadTs ? { thread_ts: threadTs } : {}
1260
+ });
1261
+ }
1262
+ }
1263
+ classifyFile(mimeType) {
1264
+ if (mimeType.startsWith("image/")) return "image";
1265
+ if (mimeType.startsWith("audio/")) return "audio";
1266
+ if (mimeType.startsWith("video/")) return "video";
1267
+ return "file";
1268
+ }
1269
+ };
1270
+ var MinimalMatrixClient = class extends EventEmitter {
1271
+ constructor(homeserverUrl, accessToken) {
1272
+ super();
1273
+ this.homeserverUrl = homeserverUrl;
1274
+ this.accessToken = accessToken;
1275
+ }
1276
+ nextBatch = null;
1277
+ abortController = null;
1278
+ running = false;
1279
+ txnCounter = 0;
1280
+ // -----------------------------------------------------------------------
1281
+ // Public API
1282
+ // -----------------------------------------------------------------------
1283
+ /** Resolve the authenticated user's Matrix user ID. */
1284
+ async getUserId() {
1285
+ const res = await this.api("GET", "/_matrix/client/v3/account/whoami");
1286
+ return res.user_id;
1287
+ }
1288
+ /** Start the long-poll sync loop. Resolves after the first sync completes. */
1289
+ async start() {
1290
+ if (this.running) return;
1291
+ this.running = true;
1292
+ this.abortController = new AbortController();
1293
+ await this.sync(0);
1294
+ void this.syncLoop().catch((err) => {
1295
+ this.emit("error", err);
1296
+ });
1297
+ }
1298
+ /** Stop the sync loop. */
1299
+ stop() {
1300
+ this.running = false;
1301
+ this.abortController?.abort();
1302
+ this.abortController = null;
1303
+ this.removeAllListeners();
1304
+ }
1305
+ /** Send a room event. Returns the event ID. */
1306
+ async sendMessage(roomId, content) {
1307
+ const txnId = `ch4p_${Date.now()}_${this.txnCounter++}`;
1308
+ const encoded = encodeURIComponent(roomId);
1309
+ const res = await this.api(
1310
+ "PUT",
1311
+ `/_matrix/client/v3/rooms/${encoded}/send/m.room.message/${txnId}`,
1312
+ content
1313
+ );
1314
+ return res.event_id;
1315
+ }
1316
+ /** Edit a previously sent message using the m.replace relation. */
1317
+ async editMessage(roomId, eventId, content) {
1318
+ const newContent = {
1319
+ ...content,
1320
+ "m.new_content": { ...content },
1321
+ "m.relates_to": {
1322
+ rel_type: "m.replace",
1323
+ event_id: eventId
1324
+ }
1325
+ };
1326
+ const txnId = `ch4p_edit_${Date.now()}_${this.txnCounter++}`;
1327
+ const encoded = encodeURIComponent(roomId);
1328
+ const res = await this.api(
1329
+ "PUT",
1330
+ `/_matrix/client/v3/rooms/${encoded}/send/m.room.message/${txnId}`,
1331
+ newContent
1332
+ );
1333
+ return res.event_id;
1334
+ }
1335
+ /** Join a room by ID or alias. */
1336
+ async joinRoom(roomId) {
1337
+ const encoded = encodeURIComponent(roomId);
1338
+ await this.api("POST", `/_matrix/client/v3/join/${encoded}`);
1339
+ }
1340
+ // -----------------------------------------------------------------------
1341
+ // Sync loop
1342
+ // -----------------------------------------------------------------------
1343
+ async syncLoop() {
1344
+ while (this.running) {
1345
+ try {
1346
+ await this.sync(3e4);
1347
+ } catch (err) {
1348
+ if (!this.running) return;
1349
+ this.emit("error", err);
1350
+ await this.sleep(5e3);
1351
+ }
1352
+ }
1353
+ }
1354
+ async sync(timeoutMs) {
1355
+ const params = new URLSearchParams({ timeout: String(timeoutMs) });
1356
+ if (this.nextBatch) {
1357
+ params.set("since", this.nextBatch);
1358
+ }
1359
+ params.set("filter", JSON.stringify({
1360
+ presence: { types: [] },
1361
+ // Ignore global presence.
1362
+ account_data: { types: [] },
1363
+ // Ignore account data.
1364
+ room: {
1365
+ state: { lazy_load_members: true },
1366
+ timeline: { limit: 50 }
1367
+ }
1368
+ }));
1369
+ const data = await this.api(
1370
+ "GET",
1371
+ `/_matrix/client/v3/sync?${params.toString()}`
1372
+ );
1373
+ this.nextBatch = data.next_batch;
1374
+ if (data.rooms?.invite) {
1375
+ for (const roomId of Object.keys(data.rooms.invite)) {
1376
+ this.emit("room.invite", roomId);
1377
+ }
1378
+ }
1379
+ if (data.rooms?.join) {
1380
+ for (const [roomId, room] of Object.entries(data.rooms.join)) {
1381
+ for (const event of room.timeline?.events ?? []) {
1382
+ event.room_id = roomId;
1383
+ if (event.type === "m.room.message") {
1384
+ this.emit("room.message", roomId, event);
1385
+ }
1386
+ this.emit("room.event", roomId, event);
1387
+ }
1388
+ for (const event of room.ephemeral?.events ?? []) {
1389
+ event.room_id = roomId;
1390
+ this.emit("room.event", roomId, event);
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ // -----------------------------------------------------------------------
1396
+ // HTTP helpers
1397
+ // -----------------------------------------------------------------------
1398
+ async api(method, path, body) {
1399
+ const url = `${this.homeserverUrl}${path}`;
1400
+ const headers = {
1401
+ Authorization: `Bearer ${this.accessToken}`
1402
+ };
1403
+ const init = {
1404
+ method,
1405
+ headers,
1406
+ signal: this.abortController?.signal
1407
+ };
1408
+ if (body) {
1409
+ headers["Content-Type"] = "application/json";
1410
+ init.body = JSON.stringify(body);
1411
+ }
1412
+ const res = await fetch(url, init);
1413
+ if (!res.ok) {
1414
+ const text = await res.text().catch(() => "");
1415
+ throw new Error(`Matrix API ${method} ${path} failed (${res.status}): ${text}`);
1416
+ }
1417
+ return await res.json();
1418
+ }
1419
+ sleep(ms) {
1420
+ return new Promise((resolve) => setTimeout(resolve, ms));
1421
+ }
1422
+ };
1423
+ var MatrixChannel = class _MatrixChannel {
1424
+ id = "matrix";
1425
+ name = "Matrix";
1426
+ client = null;
1427
+ messageHandler = null;
1428
+ presenceHandler = null;
1429
+ running = false;
1430
+ allowedRooms = /* @__PURE__ */ new Set();
1431
+ allowedUsers = /* @__PURE__ */ new Set();
1432
+ botUserId = null;
1433
+ lastEditTimestamps = /* @__PURE__ */ new Map();
1434
+ static EDIT_TS_MAX_ENTRIES = 500;
1435
+ // -----------------------------------------------------------------------
1436
+ // IChannel implementation
1437
+ // -----------------------------------------------------------------------
1438
+ async start(config) {
1439
+ if (this.running) return;
1440
+ const cfg = config;
1441
+ if (!cfg.homeserverUrl) {
1442
+ throw new Error('Matrix channel requires a "homeserverUrl" in config');
1443
+ }
1444
+ if (!cfg.accessToken) {
1445
+ throw new Error('Matrix channel requires an "accessToken" in config');
1446
+ }
1447
+ this.allowedRooms = new Set(cfg.allowedRooms ?? []);
1448
+ this.allowedUsers = new Set(cfg.allowedUsers ?? []);
1449
+ this.client = new MinimalMatrixClient(cfg.homeserverUrl, cfg.accessToken);
1450
+ const autoJoin = cfg.autoJoin ?? true;
1451
+ if (autoJoin) {
1452
+ this.client.on("room.invite", (roomId) => {
1453
+ void this.client?.joinRoom(roomId).catch(() => {
1454
+ });
1455
+ });
1456
+ }
1457
+ this.botUserId = await this.client.getUserId();
1458
+ this.client.on("room.message", (roomId, event) => {
1459
+ this.processEvent(roomId, event);
1460
+ });
1461
+ this.client.on("room.event", (roomId, event) => {
1462
+ if (event.type === "m.typing") {
1463
+ this.handleTypingEvent(roomId, event);
1464
+ }
1465
+ });
1466
+ await this.client.start();
1467
+ this.running = true;
1468
+ }
1469
+ async stop() {
1470
+ this.running = false;
1471
+ if (this.client) {
1472
+ this.client.stop();
1473
+ this.client = null;
1474
+ }
1475
+ }
1476
+ async send(to, message) {
1477
+ const roomId = to.groupId ?? to.userId;
1478
+ if (!roomId) {
1479
+ return { success: false, error: "Recipient must have groupId (room ID) or userId" };
1480
+ }
1481
+ if (!this.client) {
1482
+ return { success: false, error: "Matrix client is not running" };
1483
+ }
1484
+ try {
1485
+ const content = {};
1486
+ if (message.format === "markdown" || message.format === "html") {
1487
+ content.msgtype = "m.notice";
1488
+ content.body = message.text;
1489
+ content.format = "org.matrix.custom.html";
1490
+ content.formatted_body = message.format === "html" ? message.text : this.markdownToSimpleHtml(message.text);
1491
+ } else {
1492
+ content.msgtype = "m.text";
1493
+ content.body = message.text;
1494
+ }
1495
+ if (message.replyTo) {
1496
+ content["m.relates_to"] = {
1497
+ "m.in_reply_to": { event_id: message.replyTo }
1498
+ };
1499
+ }
1500
+ const eventId = await this.client.sendMessage(roomId, content);
1501
+ if (message.attachments?.length) {
1502
+ for (const att of message.attachments) {
1503
+ await this.sendAttachment(roomId, att);
1504
+ }
1505
+ }
1506
+ return {
1507
+ success: true,
1508
+ messageId: eventId ?? generateId()
1509
+ };
1510
+ } catch (err) {
1511
+ return {
1512
+ success: false,
1513
+ error: err instanceof Error ? err.message : String(err)
1514
+ };
1515
+ }
1516
+ }
1517
+ /** Edit a previously sent message using Matrix m.replace relation. */
1518
+ async editMessage(to, messageId, message) {
1519
+ const roomId = to.groupId ?? to.userId;
1520
+ if (!roomId) {
1521
+ return { success: false, error: "Recipient must have groupId (room ID) or userId" };
1522
+ }
1523
+ if (!this.client) {
1524
+ return { success: false, error: "Matrix client is not running" };
1525
+ }
1526
+ const lastEdit = this.lastEditTimestamps.get(messageId);
1527
+ const now = Date.now();
1528
+ const MATRIX_EDIT_RATE_LIMIT_MS = 1e3;
1529
+ if (lastEdit && now - lastEdit < MATRIX_EDIT_RATE_LIMIT_MS) {
1530
+ return { success: true, messageId };
1531
+ }
1532
+ try {
1533
+ const content = {
1534
+ msgtype: "m.text",
1535
+ body: `* ${message.text}`
1536
+ };
1537
+ const newEventId = await this.client.editMessage(roomId, messageId, content);
1538
+ this.lastEditTimestamps.set(messageId, now);
1539
+ evictOldTimestamps(this.lastEditTimestamps, _MatrixChannel.EDIT_TS_MAX_ENTRIES);
1540
+ return { success: true, messageId: newEventId ?? messageId };
1541
+ } catch (err) {
1542
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1543
+ }
1544
+ }
1545
+ onMessage(handler) {
1546
+ this.messageHandler = handler;
1547
+ }
1548
+ onPresence(handler) {
1549
+ this.presenceHandler = handler;
1550
+ }
1551
+ async isHealthy() {
1552
+ if (!this.running || !this.client) return false;
1553
+ try {
1554
+ await this.client.getUserId();
1555
+ return true;
1556
+ } catch {
1557
+ return false;
1558
+ }
1559
+ }
1560
+ // -----------------------------------------------------------------------
1561
+ // Event processing
1562
+ // -----------------------------------------------------------------------
1563
+ processEvent(roomId, event) {
1564
+ if (!this.messageHandler) return;
1565
+ if (event.type !== "m.room.message") return;
1566
+ const sender = event.sender;
1567
+ if (sender === this.botUserId) return;
1568
+ if (this.allowedRooms.size > 0 && !this.allowedRooms.has(roomId)) {
1569
+ return;
1570
+ }
1571
+ if (this.allowedUsers.size > 0 && !this.allowedUsers.has(sender)) {
1572
+ return;
1573
+ }
1574
+ const content = event.content;
1575
+ const msgtype = content.msgtype;
1576
+ const text = content.body ?? "";
1577
+ const attachments = [];
1578
+ if (msgtype === "m.image" || msgtype === "m.audio" || msgtype === "m.video" || msgtype === "m.file") {
1579
+ attachments.push({
1580
+ type: this.classifyMsgtype(msgtype),
1581
+ url: content.url,
1582
+ mimeType: content.info?.mimetype,
1583
+ filename: content.filename ?? content.body
1584
+ });
1585
+ }
1586
+ if (!text && attachments.length === 0) return;
1587
+ const replyTo = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
1588
+ const inbound = {
1589
+ id: event.event_id,
1590
+ channelId: this.id,
1591
+ from: {
1592
+ channelId: this.id,
1593
+ userId: sender,
1594
+ groupId: roomId
1595
+ },
1596
+ text,
1597
+ attachments: attachments.length > 0 ? attachments : void 0,
1598
+ replyTo,
1599
+ timestamp: new Date(event.origin_server_ts),
1600
+ raw: event
1601
+ };
1602
+ this.messageHandler(inbound);
1603
+ }
1604
+ handleTypingEvent(roomId, event) {
1605
+ if (!this.presenceHandler) return;
1606
+ const content = event.content;
1607
+ const typingUsers = content.user_ids ?? [];
1608
+ for (const userId of typingUsers) {
1609
+ if (userId === this.botUserId) continue;
1610
+ this.presenceHandler({
1611
+ userId,
1612
+ status: "typing",
1613
+ channelId: roomId
1614
+ });
1615
+ }
1616
+ }
1617
+ // -----------------------------------------------------------------------
1618
+ // Helpers
1619
+ // -----------------------------------------------------------------------
1620
+ async sendAttachment(roomId, att) {
1621
+ if (!this.client) return;
1622
+ const msgtype = att.type === "image" ? "m.image" : att.type === "audio" ? "m.audio" : att.type === "video" ? "m.video" : "m.file";
1623
+ const content = {
1624
+ msgtype,
1625
+ body: att.filename ?? "attachment",
1626
+ url: att.url ?? ""
1627
+ };
1628
+ if (att.mimeType) {
1629
+ content.info = { mimetype: att.mimeType };
1630
+ }
1631
+ await this.client.sendMessage(roomId, content);
1632
+ }
1633
+ /**
1634
+ * Classify a Matrix msgtype to an Attachment type.
1635
+ * m.image -> 'image'
1636
+ * m.audio -> 'audio'
1637
+ * m.video -> 'video'
1638
+ * m.file -> 'file'
1639
+ */
1640
+ classifyMsgtype(msgtype) {
1641
+ switch (msgtype) {
1642
+ case "m.image":
1643
+ return "image";
1644
+ case "m.audio":
1645
+ return "audio";
1646
+ case "m.video":
1647
+ return "video";
1648
+ default:
1649
+ return "file";
1650
+ }
1651
+ }
1652
+ /**
1653
+ * Convert simple markdown to basic HTML for formatted_body.
1654
+ * Handles bold, italic, code, and line breaks.
1655
+ */
1656
+ markdownToSimpleHtml(text) {
1657
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/\*(.+?)\*/g, "<em>$1</em>").replace(/`(.+?)`/g, "<code>$1</code>").replace(/\n/g, "<br>");
1658
+ }
1659
+ };
1660
+ var WA_MAX_MESSAGE_LEN = 4096;
1661
+ var WA_EDIT_RATE_LIMIT_MS = 1e3;
1662
+ var WhatsAppChannel = class _WhatsAppChannel {
1663
+ id = "whatsapp";
1664
+ name = "WhatsApp";
1665
+ accessToken = "";
1666
+ phoneNumberId = "";
1667
+ verifyToken = "";
1668
+ appSecret = "";
1669
+ apiVersion = "v21.0";
1670
+ allowedNumbers = /* @__PURE__ */ new Set();
1671
+ messageHandler = null;
1672
+ running = false;
1673
+ lastEditTimestamps = /* @__PURE__ */ new Map();
1674
+ static EDIT_TS_MAX_ENTRIES = 500;
1675
+ // -----------------------------------------------------------------------
1676
+ // IChannel implementation
1677
+ // -----------------------------------------------------------------------
1678
+ async start(config) {
1679
+ if (this.running) return;
1680
+ const cfg = config;
1681
+ if (!cfg.accessToken) {
1682
+ throw new Error('WhatsApp channel requires an "accessToken" in config');
1683
+ }
1684
+ if (!cfg.phoneNumberId) {
1685
+ throw new Error('WhatsApp channel requires a "phoneNumberId" in config');
1686
+ }
1687
+ if (!cfg.verifyToken) {
1688
+ throw new Error('WhatsApp channel requires a "verifyToken" in config');
1689
+ }
1690
+ this.accessToken = cfg.accessToken;
1691
+ this.phoneNumberId = cfg.phoneNumberId;
1692
+ this.verifyToken = cfg.verifyToken;
1693
+ this.appSecret = cfg.appSecret ?? "";
1694
+ this.apiVersion = cfg.apiVersion ?? "v21.0";
1695
+ this.allowedNumbers = new Set(cfg.allowedNumbers ?? []);
1696
+ this.running = true;
1697
+ }
1698
+ async stop() {
1699
+ this.running = false;
1700
+ }
1701
+ async send(to, message) {
1702
+ const recipient = to.userId;
1703
+ if (!recipient) {
1704
+ return { success: false, error: "Recipient must have a userId (phone number)" };
1705
+ }
1706
+ try {
1707
+ if (message.attachments?.length) {
1708
+ for (const att of message.attachments) {
1709
+ await this.sendAttachment(recipient, att);
1710
+ }
1711
+ }
1712
+ if (message.text) {
1713
+ const chunks = splitMessage(message.text, WA_MAX_MESSAGE_LEN);
1714
+ let lastId;
1715
+ for (const chunk of chunks) {
1716
+ const body = {
1717
+ messaging_product: "whatsapp",
1718
+ to: recipient,
1719
+ type: "text",
1720
+ text: { body: chunk },
1721
+ // Only attach reply context to the first chunk.
1722
+ ...!lastId && message.replyTo ? { context: { message_id: message.replyTo } } : {}
1723
+ };
1724
+ const result = await this.graphApiCall(
1725
+ `${this.phoneNumberId}/messages`,
1726
+ "POST",
1727
+ body
1728
+ );
1729
+ lastId = result?.messages?.[0]?.id ?? lastId;
1730
+ }
1731
+ return {
1732
+ success: true,
1733
+ messageId: lastId ?? generateId()
1734
+ };
1735
+ }
1736
+ return { success: true, messageId: generateId() };
1737
+ } catch (err) {
1738
+ return {
1739
+ success: false,
1740
+ error: err instanceof Error ? err.message : String(err)
1741
+ };
1742
+ }
1743
+ }
1744
+ /** Edit a previously sent message using WhatsApp Cloud API. */
1745
+ async editMessage(to, messageId, message) {
1746
+ const recipient = to.userId;
1747
+ if (!recipient) {
1748
+ return { success: false, error: "Recipient must have a userId (phone number)" };
1749
+ }
1750
+ const lastEdit = this.lastEditTimestamps.get(messageId);
1751
+ const now = Date.now();
1752
+ if (lastEdit && now - lastEdit < WA_EDIT_RATE_LIMIT_MS) {
1753
+ return { success: true, messageId };
1754
+ }
1755
+ try {
1756
+ const safeText = truncateMessage(message.text ?? "", WA_MAX_MESSAGE_LEN);
1757
+ await this.graphApiCall(
1758
+ `${this.phoneNumberId}/messages`,
1759
+ "POST",
1760
+ {
1761
+ messaging_product: "whatsapp",
1762
+ to: recipient,
1763
+ type: "text",
1764
+ text: { body: safeText },
1765
+ context: { message_id: messageId }
1766
+ }
1767
+ );
1768
+ this.lastEditTimestamps.set(messageId, now);
1769
+ evictOldTimestamps(this.lastEditTimestamps, _WhatsAppChannel.EDIT_TS_MAX_ENTRIES);
1770
+ return { success: true, messageId };
1771
+ } catch (err) {
1772
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1773
+ }
1774
+ }
1775
+ onMessage(handler) {
1776
+ this.messageHandler = handler;
1777
+ }
1778
+ onPresence(_handler) {
1779
+ }
1780
+ async isHealthy() {
1781
+ if (!this.running) return false;
1782
+ try {
1783
+ const data = await this.graphApiCall(
1784
+ this.phoneNumberId,
1785
+ "GET"
1786
+ );
1787
+ return data?.id !== void 0;
1788
+ } catch {
1789
+ return false;
1790
+ }
1791
+ }
1792
+ // -----------------------------------------------------------------------
1793
+ // Webhook handlers (called by the gateway)
1794
+ // -----------------------------------------------------------------------
1795
+ /**
1796
+ * Handle the GET webhook verification challenge from Meta.
1797
+ * Call this from a gateway route handler when receiving GET /webhook/whatsapp.
1798
+ *
1799
+ * Returns the challenge string to echo back, or null if verification fails.
1800
+ */
1801
+ handleWebhookVerification(query) {
1802
+ const mode = query["hub.mode"];
1803
+ const token = query["hub.verify_token"];
1804
+ const challenge = query["hub.challenge"];
1805
+ if (mode === "subscribe" && token === this.verifyToken) {
1806
+ return challenge ?? null;
1807
+ }
1808
+ return null;
1809
+ }
1810
+ /**
1811
+ * Process an incoming webhook POST payload from WhatsApp Cloud API.
1812
+ * Call this from a gateway route handler when receiving POST /webhook/whatsapp.
1813
+ */
1814
+ handleWebhookPayload(body) {
1815
+ if (body.object !== "whatsapp_business_account") return;
1816
+ if (!body.entry) return;
1817
+ for (const entry of body.entry) {
1818
+ if (!entry.changes) continue;
1819
+ for (const change of entry.changes) {
1820
+ const value = change.value;
1821
+ if (!value?.messages) continue;
1822
+ const contactNames = /* @__PURE__ */ new Map();
1823
+ if (value.contacts) {
1824
+ for (const contact of value.contacts) {
1825
+ if (contact.wa_id && contact.profile?.name) {
1826
+ contactNames.set(contact.wa_id, contact.profile.name);
1827
+ }
1828
+ }
1829
+ }
1830
+ for (const msg of value.messages) {
1831
+ this.processMessage(msg, contactNames);
1832
+ }
1833
+ }
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Verify webhook payload signature using HMAC-SHA256 with the app secret.
1838
+ * The signature header from Meta is formatted as "sha256=<hex>".
1839
+ *
1840
+ * Uses lazy import of node:crypto (same pattern as Slack adapter).
1841
+ */
1842
+ async verifySignature(rawBody, signature) {
1843
+ if (!this.appSecret) return true;
1844
+ const { createHmac } = await import("crypto");
1845
+ const hash = createHmac("sha256", this.appSecret).update(rawBody).digest("hex");
1846
+ const expected = `sha256=${hash}`;
1847
+ return expected === signature;
1848
+ }
1849
+ // -----------------------------------------------------------------------
1850
+ // Media helpers
1851
+ // -----------------------------------------------------------------------
1852
+ /**
1853
+ * Download media by its Cloud API media ID.
1854
+ * Two-step process: first GET the media URL, then download the binary.
1855
+ *
1856
+ * Returns the raw Buffer and mime type, or null on failure.
1857
+ */
1858
+ async downloadMedia(mediaId) {
1859
+ try {
1860
+ const meta = await this.graphApiCall(mediaId, "GET");
1861
+ if (!meta?.url) return null;
1862
+ const response = await fetch(meta.url, {
1863
+ headers: { Authorization: `Bearer ${this.accessToken}` }
1864
+ });
1865
+ if (!response.ok) return null;
1866
+ const arrayBuf = await response.arrayBuffer();
1867
+ return {
1868
+ data: Buffer.from(arrayBuf),
1869
+ mimeType: meta.mime_type ?? "application/octet-stream"
1870
+ };
1871
+ } catch {
1872
+ return null;
1873
+ }
1874
+ }
1875
+ // -----------------------------------------------------------------------
1876
+ // Message processing
1877
+ // -----------------------------------------------------------------------
1878
+ processMessage(msg, _contactNames) {
1879
+ if (!this.messageHandler) return;
1880
+ const sender = msg.from;
1881
+ if (this.allowedNumbers.size > 0 && !this.allowedNumbers.has(sender)) {
1882
+ return;
1883
+ }
1884
+ const text = this.extractText(msg);
1885
+ const attachments = this.extractAttachments(msg);
1886
+ if (!text && attachments.length === 0) return;
1887
+ const inbound = {
1888
+ id: msg.id,
1889
+ channelId: this.id,
1890
+ from: {
1891
+ channelId: this.id,
1892
+ userId: sender
1893
+ },
1894
+ text,
1895
+ attachments: attachments.length > 0 ? attachments : void 0,
1896
+ replyTo: msg.context?.message_id,
1897
+ timestamp: new Date(parseInt(msg.timestamp, 10) * 1e3),
1898
+ raw: msg
1899
+ };
1900
+ this.messageHandler(inbound);
1901
+ }
1902
+ extractText(msg) {
1903
+ switch (msg.type) {
1904
+ case "text":
1905
+ return msg.text?.body ?? "";
1906
+ case "image":
1907
+ return msg.image?.caption ?? "";
1908
+ case "video":
1909
+ return msg.video?.caption ?? "";
1910
+ case "document":
1911
+ return msg.document?.caption ?? "";
1912
+ case "location": {
1913
+ const loc = msg.location;
1914
+ if (!loc) return "";
1915
+ const label = loc.name ? `${loc.name}: ` : "";
1916
+ return `${label}${loc.latitude},${loc.longitude}`;
1917
+ }
1918
+ default:
1919
+ return "";
1920
+ }
1921
+ }
1922
+ extractAttachments(msg) {
1923
+ const attachments = [];
1924
+ if (msg.image) {
1925
+ attachments.push({
1926
+ type: "image",
1927
+ url: msg.image.id,
1928
+ // Cloud API media ID — resolve via downloadMedia()
1929
+ mimeType: msg.image.mime_type
1930
+ });
1931
+ }
1932
+ if (msg.audio) {
1933
+ attachments.push({
1934
+ type: "audio",
1935
+ url: msg.audio.id,
1936
+ mimeType: msg.audio.mime_type
1937
+ });
1938
+ }
1939
+ if (msg.video) {
1940
+ attachments.push({
1941
+ type: "video",
1942
+ url: msg.video.id,
1943
+ mimeType: msg.video.mime_type
1944
+ });
1945
+ }
1946
+ if (msg.document) {
1947
+ attachments.push({
1948
+ type: "file",
1949
+ url: msg.document.id,
1950
+ mimeType: msg.document.mime_type,
1951
+ filename: msg.document.filename
1952
+ });
1953
+ }
1954
+ if (msg.sticker) {
1955
+ attachments.push({
1956
+ type: "image",
1957
+ url: msg.sticker.id,
1958
+ mimeType: msg.sticker.mime_type
1959
+ });
1960
+ }
1961
+ return attachments;
1962
+ }
1963
+ // -----------------------------------------------------------------------
1964
+ // Outbound attachment sending
1965
+ // -----------------------------------------------------------------------
1966
+ async sendAttachment(recipient, att) {
1967
+ if (att.data) {
1968
+ const mediaId = await this.uploadMedia(att);
1969
+ if (!mediaId) return;
1970
+ const waType = this.attachmentTypeToWaType(att.type);
1971
+ const body = {
1972
+ messaging_product: "whatsapp",
1973
+ to: recipient,
1974
+ type: waType,
1975
+ [waType]: { id: mediaId }
1976
+ };
1977
+ await this.graphApiCall(`${this.phoneNumberId}/messages`, "POST", body);
1978
+ return;
1979
+ }
1980
+ if (att.url) {
1981
+ const waType = this.attachmentTypeToWaType(att.type);
1982
+ const mediaObj = { link: att.url };
1983
+ if (att.filename) mediaObj.filename = att.filename;
1984
+ const body = {
1985
+ messaging_product: "whatsapp",
1986
+ to: recipient,
1987
+ type: waType,
1988
+ [waType]: mediaObj
1989
+ };
1990
+ await this.graphApiCall(`${this.phoneNumberId}/messages`, "POST", body);
1991
+ }
1992
+ }
1993
+ attachmentTypeToWaType(type) {
1994
+ switch (type) {
1995
+ case "image":
1996
+ return "image";
1997
+ case "audio":
1998
+ return "audio";
1999
+ case "video":
2000
+ return "video";
2001
+ case "file":
2002
+ return "document";
2003
+ }
2004
+ }
2005
+ /**
2006
+ * Upload binary media to Meta's media endpoint.
2007
+ * Returns the media ID on success, or null on failure.
2008
+ */
2009
+ async uploadMedia(att) {
2010
+ if (!att.data) return null;
2011
+ try {
2012
+ const url = `https://graph.facebook.com/${this.apiVersion}/${this.phoneNumberId}/media`;
2013
+ const boundary = `----ch4p${Date.now()}${Math.random().toString(36).slice(2)}`;
2014
+ const mimeType = att.mimeType ?? "application/octet-stream";
2015
+ const filename = att.filename ?? "upload";
2016
+ const preamble = `--${boundary}\r
2017
+ Content-Disposition: form-data; name="messaging_product"\r
2018
+ \r
2019
+ whatsapp\r
2020
+ --${boundary}\r
2021
+ Content-Disposition: form-data; name="type"\r
2022
+ \r
2023
+ ${mimeType}\r
2024
+ --${boundary}\r
2025
+ Content-Disposition: form-data; name="file"; filename="${filename}"\r
2026
+ Content-Type: ${mimeType}\r
2027
+ \r
2028
+ `;
2029
+ const epilogue = `\r
2030
+ --${boundary}--\r
2031
+ `;
2032
+ const preambleBuf = Buffer.from(preamble, "utf-8");
2033
+ const epilogueBuf = Buffer.from(epilogue, "utf-8");
2034
+ const bodyBuf = Buffer.concat([preambleBuf, att.data, epilogueBuf]);
2035
+ const response = await fetch(url, {
2036
+ method: "POST",
2037
+ headers: {
2038
+ "Authorization": `Bearer ${this.accessToken}`,
2039
+ "Content-Type": `multipart/form-data; boundary=${boundary}`
2040
+ },
2041
+ body: bodyBuf
2042
+ });
2043
+ const data = await response.json();
2044
+ return data.id ?? null;
2045
+ } catch {
2046
+ return null;
2047
+ }
2048
+ }
2049
+ // -----------------------------------------------------------------------
2050
+ // Graph API helper
2051
+ // -----------------------------------------------------------------------
2052
+ async graphApiCall(endpoint, method, body) {
2053
+ const url = `https://graph.facebook.com/${this.apiVersion}/${endpoint}`;
2054
+ const headers = {
2055
+ "Authorization": `Bearer ${this.accessToken}`
2056
+ };
2057
+ const init = { method, headers };
2058
+ if (body && method === "POST") {
2059
+ headers["Content-Type"] = "application/json";
2060
+ init.body = JSON.stringify(body);
2061
+ }
2062
+ const response = await fetch(url, init);
2063
+ const data = await response.json();
2064
+ if (data.error) {
2065
+ const err = data.error;
2066
+ throw new Error(`WhatsApp API error: ${err.message ?? "Unknown error"} (code ${err.code})`);
2067
+ }
2068
+ return data;
2069
+ }
2070
+ };
2071
+ var SignalChannel = class _SignalChannel {
2072
+ id = "signal";
2073
+ name = "Signal";
2074
+ account = "";
2075
+ host = "localhost";
2076
+ port = 7583;
2077
+ reconnectInterval = 5e3;
2078
+ messageHandler = null;
2079
+ running = false;
2080
+ socket = null;
2081
+ reconnectTimer = null;
2082
+ allowedNumbers = /* @__PURE__ */ new Set();
2083
+ buffer = "";
2084
+ nextRequestId = 1;
2085
+ pendingRequests = /* @__PURE__ */ new Map();
2086
+ lastEditTimestamps = /* @__PURE__ */ new Map();
2087
+ static EDIT_TS_MAX_ENTRIES = 500;
2088
+ // -----------------------------------------------------------------------
2089
+ // IChannel implementation
2090
+ // -----------------------------------------------------------------------
2091
+ async start(config) {
2092
+ if (this.running) return;
2093
+ const cfg = config;
2094
+ if (!cfg.account) {
2095
+ throw new Error('Signal channel requires an "account" (phone number) in config');
2096
+ }
2097
+ this.account = cfg.account;
2098
+ this.host = cfg.host ?? "localhost";
2099
+ this.port = cfg.port ?? 7583;
2100
+ this.reconnectInterval = cfg.reconnectInterval ?? 5e3;
2101
+ this.allowedNumbers = new Set(cfg.allowedNumbers ?? []);
2102
+ await this.connect();
2103
+ this.running = true;
2104
+ try {
2105
+ await this.rpcCall("listContacts", { account: this.account });
2106
+ } catch {
2107
+ }
2108
+ }
2109
+ async stop() {
2110
+ this.running = false;
2111
+ if (this.reconnectTimer) {
2112
+ clearTimeout(this.reconnectTimer);
2113
+ this.reconnectTimer = null;
2114
+ }
2115
+ for (const [id, pending] of this.pendingRequests) {
2116
+ pending.reject(new Error("Channel stopped"));
2117
+ this.pendingRequests.delete(id);
2118
+ }
2119
+ if (this.socket) {
2120
+ try {
2121
+ this.socket.destroy();
2122
+ } catch {
2123
+ }
2124
+ this.socket = null;
2125
+ }
2126
+ this.buffer = "";
2127
+ }
2128
+ async send(to, message) {
2129
+ const recipient = to.userId ?? to.groupId;
2130
+ if (!recipient) {
2131
+ return { success: false, error: "Recipient must have userId (phone number) or groupId" };
2132
+ }
2133
+ try {
2134
+ const params = {
2135
+ account: this.account,
2136
+ message: message.text
2137
+ };
2138
+ if (to.groupId) {
2139
+ params.groupId = to.groupId;
2140
+ } else {
2141
+ params.recipient = [to.userId];
2142
+ }
2143
+ if (message.replyTo) {
2144
+ params.quoteTimestamp = Number(message.replyTo);
2145
+ }
2146
+ const result = await this.rpcCall("send", params);
2147
+ const resultObj = result;
2148
+ return {
2149
+ success: true,
2150
+ messageId: resultObj?.timestamp ? String(resultObj.timestamp) : generateId()
2151
+ };
2152
+ } catch (err) {
2153
+ return {
2154
+ success: false,
2155
+ error: err instanceof Error ? err.message : String(err)
2156
+ };
2157
+ }
2158
+ }
2159
+ /** Edit a previously sent message via signal-cli editMessage RPC. */
2160
+ async editMessage(to, messageId, message) {
2161
+ const recipient = to.userId ?? to.groupId;
2162
+ if (!recipient) {
2163
+ return { success: false, error: "Recipient must have userId or groupId" };
2164
+ }
2165
+ const lastEdit = this.lastEditTimestamps.get(messageId);
2166
+ const now = Date.now();
2167
+ const SIGNAL_EDIT_RATE_LIMIT_MS = 1e3;
2168
+ if (lastEdit && now - lastEdit < SIGNAL_EDIT_RATE_LIMIT_MS) {
2169
+ return { success: true, messageId };
2170
+ }
2171
+ try {
2172
+ const params = {
2173
+ account: this.account,
2174
+ targetTimestamp: Number(messageId),
2175
+ message: message.text
2176
+ };
2177
+ if (to.groupId) {
2178
+ params.groupId = to.groupId;
2179
+ } else {
2180
+ params.recipient = [to.userId];
2181
+ }
2182
+ await this.rpcCall("editMessage", params);
2183
+ this.lastEditTimestamps.set(messageId, now);
2184
+ evictOldTimestamps(this.lastEditTimestamps, _SignalChannel.EDIT_TS_MAX_ENTRIES);
2185
+ return { success: true, messageId };
2186
+ } catch (err) {
2187
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
2188
+ }
2189
+ }
2190
+ onMessage(handler) {
2191
+ this.messageHandler = handler;
2192
+ }
2193
+ onPresence(_handler) {
2194
+ }
2195
+ async isHealthy() {
2196
+ if (!this.running) return false;
2197
+ if (!this.socket || this.socket.destroyed) return false;
2198
+ try {
2199
+ await this.rpcCall("listContacts", { account: this.account });
2200
+ return true;
2201
+ } catch {
2202
+ return false;
2203
+ }
2204
+ }
2205
+ // -----------------------------------------------------------------------
2206
+ // TCP connection management
2207
+ // -----------------------------------------------------------------------
2208
+ connect() {
2209
+ return new Promise((resolve, reject) => {
2210
+ this.buffer = "";
2211
+ this.socket = new Socket();
2212
+ let connected = false;
2213
+ this.socket.on("connect", () => {
2214
+ connected = true;
2215
+ resolve();
2216
+ });
2217
+ this.socket.on("data", (chunk) => {
2218
+ this.onData(chunk);
2219
+ });
2220
+ this.socket.on("error", (err) => {
2221
+ if (!connected) {
2222
+ reject(new Error(`Failed to connect to signal-cli at ${this.host}:${this.port}: ${err.message}`));
2223
+ }
2224
+ });
2225
+ this.socket.on("close", () => {
2226
+ this.socket = null;
2227
+ for (const [id, pending] of this.pendingRequests) {
2228
+ pending.reject(new Error("Socket closed"));
2229
+ this.pendingRequests.delete(id);
2230
+ }
2231
+ if (this.running) {
2232
+ this.scheduleReconnect();
2233
+ }
2234
+ });
2235
+ this.socket.connect(this.port, this.host);
2236
+ });
2237
+ }
2238
+ scheduleReconnect() {
2239
+ if (this.reconnectTimer) return;
2240
+ this.reconnectTimer = setTimeout(async () => {
2241
+ this.reconnectTimer = null;
2242
+ if (!this.running) return;
2243
+ try {
2244
+ await this.connect();
2245
+ } catch {
2246
+ if (this.running) {
2247
+ this.scheduleReconnect();
2248
+ }
2249
+ }
2250
+ }, this.reconnectInterval);
2251
+ }
2252
+ // -----------------------------------------------------------------------
2253
+ // Line-delimited JSON-RPC handling
2254
+ // -----------------------------------------------------------------------
2255
+ onData(chunk) {
2256
+ this.buffer += chunk.toString("utf-8");
2257
+ let newlineIdx;
2258
+ while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
2259
+ const line = this.buffer.slice(0, newlineIdx).trim();
2260
+ this.buffer = this.buffer.slice(newlineIdx + 1);
2261
+ if (line.length === 0) continue;
2262
+ try {
2263
+ const parsed = JSON.parse(line);
2264
+ if ("id" in parsed && typeof parsed.id === "number") {
2265
+ this.handleResponse(parsed);
2266
+ } else if ("method" in parsed) {
2267
+ this.handleNotification(parsed);
2268
+ }
2269
+ } catch {
2270
+ }
2271
+ }
2272
+ }
2273
+ handleResponse(response) {
2274
+ const pending = this.pendingRequests.get(response.id);
2275
+ if (!pending) return;
2276
+ this.pendingRequests.delete(response.id);
2277
+ if (response.error) {
2278
+ pending.reject(new Error(
2279
+ `JSON-RPC error ${response.error.code}: ${response.error.message}`
2280
+ ));
2281
+ } else {
2282
+ pending.resolve(response.result);
2283
+ }
2284
+ }
2285
+ handleNotification(notification) {
2286
+ if (notification.method === "receive") {
2287
+ this.processReceiveNotification(notification.params);
2288
+ }
2289
+ }
2290
+ // -----------------------------------------------------------------------
2291
+ // Message processing
2292
+ // -----------------------------------------------------------------------
2293
+ processReceiveNotification(params) {
2294
+ if (!this.messageHandler) return;
2295
+ const envelope = params.envelope;
2296
+ if (!envelope) return;
2297
+ const sourceNumber = envelope.sourceNumber;
2298
+ if (!sourceNumber) return;
2299
+ if (this.allowedNumbers.size > 0 && !this.allowedNumbers.has(sourceNumber)) {
2300
+ return;
2301
+ }
2302
+ const dataMessage = envelope.dataMessage;
2303
+ if (!dataMessage) return;
2304
+ const text = dataMessage.message ?? "";
2305
+ if (!text && (!dataMessage.attachments || dataMessage.attachments.length === 0)) {
2306
+ return;
2307
+ }
2308
+ const attachments = [];
2309
+ if (dataMessage.attachments) {
2310
+ for (const att of dataMessage.attachments) {
2311
+ attachments.push({
2312
+ type: this.classifyAttachment(att.contentType ?? ""),
2313
+ url: att.id,
2314
+ filename: att.filename,
2315
+ mimeType: att.contentType
2316
+ });
2317
+ }
2318
+ }
2319
+ const timestamp = dataMessage.timestamp ?? envelope.timestamp ?? Date.now();
2320
+ const inbound = {
2321
+ id: String(timestamp),
2322
+ channelId: this.id,
2323
+ from: {
2324
+ channelId: this.id,
2325
+ userId: sourceNumber,
2326
+ groupId: dataMessage.groupInfo?.groupId
2327
+ },
2328
+ text,
2329
+ attachments: attachments.length > 0 ? attachments : void 0,
2330
+ replyTo: dataMessage.quote?.id ? String(dataMessage.quote.id) : void 0,
2331
+ timestamp: new Date(timestamp),
2332
+ raw: params
2333
+ };
2334
+ this.messageHandler(inbound);
2335
+ }
2336
+ // -----------------------------------------------------------------------
2337
+ // JSON-RPC helpers
2338
+ // -----------------------------------------------------------------------
2339
+ rpcCall(method, params) {
2340
+ return new Promise((resolve, reject) => {
2341
+ if (!this.socket || this.socket.destroyed) {
2342
+ reject(new Error("Not connected to signal-cli daemon"));
2343
+ return;
2344
+ }
2345
+ const id = this.nextRequestId++;
2346
+ const request = {
2347
+ jsonrpc: "2.0",
2348
+ id,
2349
+ method,
2350
+ params
2351
+ };
2352
+ this.pendingRequests.set(id, { resolve, reject });
2353
+ const payload = JSON.stringify(request) + "\n";
2354
+ this.socket.write(payload, "utf-8", (err) => {
2355
+ if (err) {
2356
+ this.pendingRequests.delete(id);
2357
+ reject(new Error(`Failed to write to signal-cli socket: ${err.message}`));
2358
+ }
2359
+ });
2360
+ });
2361
+ }
2362
+ classifyAttachment(contentType) {
2363
+ if (contentType.startsWith("image/")) return "image";
2364
+ if (contentType.startsWith("audio/")) return "audio";
2365
+ if (contentType.startsWith("video/")) return "video";
2366
+ return "file";
2367
+ }
2368
+ };
2369
+ var execFile = promisify(execFileCb);
2370
+ var IMESSAGE_EPOCH_MS = Date.UTC(2001, 0, 1);
2371
+ function imessageDateToJS(nanoseconds) {
2372
+ return new Date(IMESSAGE_EPOCH_MS + nanoseconds / 1e6);
2373
+ }
2374
+ function classifyMimeType(mime) {
2375
+ if (!mime) return "file";
2376
+ if (mime.startsWith("image/")) return "image";
2377
+ if (mime.startsWith("audio/")) return "audio";
2378
+ if (mime.startsWith("video/")) return "video";
2379
+ return "file";
2380
+ }
2381
+ var TAPBACK_SEND_MAP = {
2382
+ love: 0,
2383
+ heart: 0,
2384
+ like: 1,
2385
+ thumbsup: 1,
2386
+ dislike: 2,
2387
+ thumbsdown: 2,
2388
+ laugh: 3,
2389
+ haha: 3,
2390
+ emphasis: 4,
2391
+ exclamation: 4,
2392
+ question: 5
2393
+ };
2394
+ var TAPBACK_TYPES = {
2395
+ 2e3: { name: "love", isRemove: false },
2396
+ 2001: { name: "like", isRemove: false },
2397
+ 2002: { name: "dislike", isRemove: false },
2398
+ 2003: { name: "laugh", isRemove: false },
2399
+ 2004: { name: "emphasis", isRemove: false },
2400
+ 2005: { name: "question", isRemove: false },
2401
+ 3e3: { name: "love", isRemove: true },
2402
+ 3001: { name: "like", isRemove: true },
2403
+ 3002: { name: "dislike", isRemove: true },
2404
+ 3003: { name: "laugh", isRemove: true },
2405
+ 3004: { name: "emphasis", isRemove: true },
2406
+ 3005: { name: "question", isRemove: true }
2407
+ };
2408
+ function isReaction(associatedMessageType) {
2409
+ if (associatedMessageType === null || associatedMessageType === 0) return false;
2410
+ return associatedMessageType in TAPBACK_TYPES;
2411
+ }
2412
+ var IMessageChannel = class {
2413
+ id = "imessage";
2414
+ name = "iMessage";
2415
+ messageHandler = null;
2416
+ running = false;
2417
+ pollTimer = null;
2418
+ pollInterval = 2e3;
2419
+ lastRowId = 0;
2420
+ dbPath = "";
2421
+ allowedHandles = /* @__PURE__ */ new Set();
2422
+ // -----------------------------------------------------------------------
2423
+ // IChannel implementation
2424
+ // -----------------------------------------------------------------------
2425
+ async start(config) {
2426
+ if (this.running) return;
2427
+ if (process.platform !== "darwin") {
2428
+ throw new Error(
2429
+ `IMessageChannel is macOS-only. Current platform "${process.platform}" is not supported.`
2430
+ );
2431
+ }
2432
+ const cfg = config;
2433
+ try {
2434
+ await execFile("which", ["sqlite3"]);
2435
+ } catch {
2436
+ throw new Error(
2437
+ "sqlite3 CLI not found on PATH. It ships with macOS by default -- ensure your system is not missing it or that PATH is configured correctly."
2438
+ );
2439
+ }
2440
+ this.pollInterval = Math.max(100, cfg.pollInterval ?? 2e3);
2441
+ this.allowedHandles = new Set(cfg.allowedHandles ?? []);
2442
+ this.dbPath = cfg.dbPath ?? `${homedir()}/Library/Messages/chat.db`;
2443
+ try {
2444
+ const { stdout } = await execFile("sqlite3", [
2445
+ "-json",
2446
+ this.dbPath,
2447
+ "SELECT MAX(ROWID) as max_id FROM message;"
2448
+ ]);
2449
+ const rows = JSON.parse(stdout || "[]");
2450
+ this.lastRowId = rows[0]?.max_id ?? 0;
2451
+ } catch (err) {
2452
+ const message = err instanceof Error ? err.message : String(err);
2453
+ if (message.includes("unable to open database") || message.includes("permission denied") || message.includes("not authorized")) {
2454
+ throw new Error(
2455
+ `Cannot read iMessage database. Grant Full Disk Access to your terminal:
2456
+ System Settings > Privacy & Security > Full Disk Access
2457
+ Database path: ${this.dbPath}`
2458
+ );
2459
+ }
2460
+ throw new Error(`Failed to query iMessage database: ${message}`);
2461
+ }
2462
+ this.running = true;
2463
+ this.startPolling();
2464
+ }
2465
+ async stop() {
2466
+ this.running = false;
2467
+ if (this.pollTimer) {
2468
+ clearTimeout(this.pollTimer);
2469
+ this.pollTimer = null;
2470
+ }
2471
+ }
2472
+ async send(to, message) {
2473
+ const handle = to.userId;
2474
+ const group = to.groupId;
2475
+ if (!handle && !group) {
2476
+ return { success: false, error: "Recipient must have userId (handle) or groupId (chat name)" };
2477
+ }
2478
+ try {
2479
+ const escapedText = message.text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
2480
+ let jxa;
2481
+ if (group) {
2482
+ const escapedGroup = group.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2483
+ jxa = [
2484
+ 'const app = Application("Messages");',
2485
+ `const chat = app.chats.whose({name: "${escapedGroup}"})[0];`,
2486
+ `app.send("${escapedText}", {to: chat});`
2487
+ ].join("\n");
2488
+ } else {
2489
+ const escapedHandle = handle.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2490
+ jxa = [
2491
+ 'const app = Application("Messages");',
2492
+ `const buddy = app.buddies.whose({handle: "${escapedHandle}"})[0];`,
2493
+ `app.send("${escapedText}", {to: buddy});`
2494
+ ].join("\n");
2495
+ }
2496
+ await execFile("osascript", ["-l", "JavaScript", "-e", jxa]);
2497
+ return {
2498
+ success: true,
2499
+ messageId: generateId()
2500
+ };
2501
+ } catch (err) {
2502
+ const errMsg = err instanceof Error ? err.message : String(err);
2503
+ if (errMsg.includes("not authorized") || errMsg.includes("assistive access")) {
2504
+ return {
2505
+ success: false,
2506
+ error: "Automation permission denied. Allow your terminal to control Messages.app:\nSystem Settings > Privacy & Security > Automation"
2507
+ };
2508
+ }
2509
+ return {
2510
+ success: false,
2511
+ error: errMsg
2512
+ };
2513
+ }
2514
+ }
2515
+ /**
2516
+ * Send a tapback reaction to a specific message via JXA UI scripting.
2517
+ *
2518
+ * Uses System Events accessibility API to:
2519
+ * 1. Look up the message text + chat identifier from chat.db by GUID.
2520
+ * 2. Focus the conversation in Messages.app.
2521
+ * 3. Right-click the target message bubble.
2522
+ * 4. Select the tapback from the context menu.
2523
+ *
2524
+ * Requires macOS 13+ and Accessibility permission for your terminal in
2525
+ * System Settings > Privacy & Security > Accessibility.
2526
+ *
2527
+ * Valid reactionTypes: love, heart, like, thumbsup, dislike, thumbsdown,
2528
+ * laugh, haha, emphasis, exclamation, question.
2529
+ */
2530
+ async sendReaction(_to, messageGuid, reactionType) {
2531
+ const reactionIndex = TAPBACK_SEND_MAP[reactionType.toLowerCase()];
2532
+ if (reactionIndex === void 0) {
2533
+ const valid = Object.keys(TAPBACK_SEND_MAP).join(", ");
2534
+ return {
2535
+ success: false,
2536
+ error: `Unknown reaction type "${reactionType}". Valid types: ${valid}.`
2537
+ };
2538
+ }
2539
+ const info = await this.getMessageInfo(messageGuid);
2540
+ if (!info) {
2541
+ return {
2542
+ success: false,
2543
+ error: `Message with GUID "${messageGuid}" not found in chat.db.`
2544
+ };
2545
+ }
2546
+ const jxa = buildTapbackScript(info.chatIdentifier, info.text, reactionIndex);
2547
+ try {
2548
+ await execFile("osascript", ["-l", "JavaScript", "-e", jxa]);
2549
+ return { success: true };
2550
+ } catch (err) {
2551
+ const errMsg = err instanceof Error ? err.message : String(err);
2552
+ if (errMsg.includes("not authorized") || errMsg.includes("assistive access")) {
2553
+ return {
2554
+ success: false,
2555
+ error: "Accessibility permission denied. Allow your terminal in System Settings > Privacy & Security > Accessibility."
2556
+ };
2557
+ }
2558
+ return { success: false, error: errMsg };
2559
+ }
2560
+ }
2561
+ /**
2562
+ * Look up the plain text and chat_identifier for a message by GUID from chat.db.
2563
+ * Returns null if the message is not found or the query fails.
2564
+ */
2565
+ async getMessageInfo(guid) {
2566
+ if (!/^[A-Za-z0-9\-:/]+$/.test(guid)) {
2567
+ return null;
2568
+ }
2569
+ const safeGuid = guid.replace(/'/g, "''");
2570
+ const sql = `
2571
+ SELECT m.text, c.chat_identifier
2572
+ FROM message m
2573
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
2574
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
2575
+ WHERE m.guid = '${safeGuid}'
2576
+ LIMIT 1
2577
+ `;
2578
+ try {
2579
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, sql]);
2580
+ const rows = JSON.parse(stdout || "[]");
2581
+ const row = rows[0];
2582
+ if (!row) return null;
2583
+ return {
2584
+ text: row.text ?? "",
2585
+ chatIdentifier: row.chat_identifier ?? ""
2586
+ };
2587
+ } catch {
2588
+ return null;
2589
+ }
2590
+ }
2591
+ onMessage(handler) {
2592
+ this.messageHandler = handler;
2593
+ }
2594
+ onPresence(_handler) {
2595
+ }
2596
+ async isHealthy() {
2597
+ if (!this.running) return false;
2598
+ try {
2599
+ await execFile("sqlite3", [this.dbPath, "SELECT 1;"]);
2600
+ return true;
2601
+ } catch {
2602
+ return false;
2603
+ }
2604
+ }
2605
+ // -----------------------------------------------------------------------
2606
+ // Polling
2607
+ // -----------------------------------------------------------------------
2608
+ startPolling() {
2609
+ const poll = async () => {
2610
+ if (!this.running) return;
2611
+ try {
2612
+ await this.pollMessages();
2613
+ } catch {
2614
+ }
2615
+ if (this.running) {
2616
+ this.pollTimer = setTimeout(poll, this.pollInterval);
2617
+ }
2618
+ };
2619
+ this.pollTimer = setTimeout(poll, 0);
2620
+ }
2621
+ async pollMessages() {
2622
+ if (!this.messageHandler) return;
2623
+ const query = [
2624
+ "SELECT m.ROWID, m.text, m.date, h.id as handle, m.is_from_me,",
2625
+ " m.cache_has_attachments,",
2626
+ " m.associated_message_type, m.associated_message_guid,",
2627
+ " m.thread_originator_guid, m.destination_caller_id,",
2628
+ " c.chat_identifier, c.display_name",
2629
+ "FROM message m",
2630
+ "JOIN handle h ON m.handle_id = h.ROWID",
2631
+ "LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id",
2632
+ "LEFT JOIN chat c ON cmj.chat_id = c.ROWID",
2633
+ `WHERE m.ROWID > ${this.lastRowId} AND m.is_from_me = 0`,
2634
+ "ORDER BY m.ROWID ASC;"
2635
+ ].join(" ");
2636
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, query]);
2637
+ if (!stdout || stdout.trim() === "" || stdout.trim() === "[]") return;
2638
+ let rows;
2639
+ try {
2640
+ rows = JSON.parse(stdout);
2641
+ } catch {
2642
+ return;
2643
+ }
2644
+ for (const row of rows) {
2645
+ if (row.ROWID > this.lastRowId) {
2646
+ this.lastRowId = row.ROWID;
2647
+ }
2648
+ if (this.allowedHandles.size > 0 && !this.allowedHandles.has(row.handle)) {
2649
+ continue;
2650
+ }
2651
+ const inbound = await this.processRow(row);
2652
+ if (inbound) {
2653
+ this.messageHandler(inbound);
2654
+ }
2655
+ }
2656
+ }
2657
+ // -----------------------------------------------------------------------
2658
+ // Message processing
2659
+ // -----------------------------------------------------------------------
2660
+ async processRow(row) {
2661
+ if (isReaction(row.associated_message_type)) {
2662
+ const tapback = TAPBACK_TYPES[row.associated_message_type];
2663
+ if (!tapback) return null;
2664
+ let targetGuid = row.associated_message_guid ?? "";
2665
+ const guidMatch = targetGuid.match(/^(?:p:\d+\/|bp:)(.+)$/);
2666
+ if (guidMatch) {
2667
+ targetGuid = guidMatch[1];
2668
+ }
2669
+ return {
2670
+ id: String(row.ROWID),
2671
+ channelId: this.id,
2672
+ from: {
2673
+ channelId: this.id,
2674
+ userId: row.handle,
2675
+ groupId: row.chat_identifier ?? void 0
2676
+ },
2677
+ text: tapback.isRemove ? `[Removed ${tapback.name} reaction]` : `[${tapback.name} reaction]`,
2678
+ replyTo: targetGuid || void 0,
2679
+ timestamp: imessageDateToJS(row.date),
2680
+ raw: {
2681
+ ...row,
2682
+ reaction: true,
2683
+ reactionType: tapback.name,
2684
+ reactionRemoved: tapback.isRemove
2685
+ }
2686
+ };
2687
+ }
2688
+ const text = row.text ?? "";
2689
+ if (!text && !row.cache_has_attachments) return null;
2690
+ let attachments;
2691
+ if (row.cache_has_attachments) {
2692
+ attachments = await this.fetchAttachments(row.ROWID);
2693
+ if (attachments.length === 0) attachments = void 0;
2694
+ }
2695
+ if (!text && !attachments) return null;
2696
+ const isGroup = row.chat_identifier ? row.chat_identifier.startsWith("chat") : false;
2697
+ return {
2698
+ id: String(row.ROWID),
2699
+ channelId: this.id,
2700
+ from: {
2701
+ channelId: this.id,
2702
+ userId: row.handle,
2703
+ groupId: isGroup ? row.chat_identifier ?? void 0 : void 0
2704
+ },
2705
+ text,
2706
+ attachments,
2707
+ // Thread context: if this message is a reply in a thread.
2708
+ replyTo: row.thread_originator_guid ?? void 0,
2709
+ timestamp: imessageDateToJS(row.date),
2710
+ raw: {
2711
+ ...row,
2712
+ destination_caller_id: row.destination_caller_id,
2713
+ display_name: row.display_name
2714
+ }
2715
+ };
2716
+ }
2717
+ // -----------------------------------------------------------------------
2718
+ // Attachment handling
2719
+ // -----------------------------------------------------------------------
2720
+ async fetchAttachments(messageRowId) {
2721
+ const query = [
2722
+ "SELECT a.filename, a.mime_type, a.uti",
2723
+ "FROM attachment a",
2724
+ "JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id",
2725
+ `WHERE maj.message_id = ${messageRowId};`
2726
+ ].join(" ");
2727
+ try {
2728
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, query]);
2729
+ if (!stdout || stdout.trim() === "" || stdout.trim() === "[]") return [];
2730
+ const rows = JSON.parse(stdout);
2731
+ return rows.map((att) => {
2732
+ let filepath = att.filename ?? void 0;
2733
+ if (filepath?.startsWith("~/")) {
2734
+ filepath = `${process.env.HOME}${filepath.slice(1)}`;
2735
+ }
2736
+ return {
2737
+ type: classifyMimeType(att.mime_type),
2738
+ url: filepath ? `file://${filepath}` : void 0,
2739
+ filename: filepath?.split("/").pop(),
2740
+ mimeType: att.mime_type ?? void 0
2741
+ };
2742
+ });
2743
+ } catch {
2744
+ return [];
2745
+ }
2746
+ }
2747
+ };
2748
+ function buildTapbackScript(chatIdentifier, messageText, reactionIndex) {
2749
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\x00/g, "");
2750
+ return `(function() {
2751
+ const app = Application("Messages");
2752
+ app.activate();
2753
+ delay(0.5);
2754
+ const se = Application("System Events");
2755
+ const proc = se.processes.byName("Messages");
2756
+
2757
+ // Select the conversation in the sidebar.
2758
+ try {
2759
+ const sidebar = proc.windows[0].splitterGroups[0].scrollAreas[0];
2760
+ const rows = sidebar.tables[0].rows();
2761
+ for (const row of rows) {
2762
+ try {
2763
+ const label = row.staticTexts[0].value();
2764
+ if (label && label.includes("${esc(chatIdentifier)}")) {
2765
+ row.select();
2766
+ break;
2767
+ }
2768
+ } catch (_) {}
2769
+ }
2770
+ } catch (_) {}
2771
+ delay(0.3);
2772
+
2773
+ // Find the message bubble.
2774
+ let msgEl = null;
2775
+ const targetText = "${esc(messageText.slice(0, 40))}";
2776
+ try {
2777
+ const msgArea = proc.windows[0].splitterGroups[0].scrollAreas[1];
2778
+ const groups = msgArea.groups();
2779
+ for (const g of groups) {
2780
+ try {
2781
+ const txt = g.staticTexts[0].value();
2782
+ if (txt && txt.includes(targetText)) {
2783
+ msgEl = g;
2784
+ }
2785
+ } catch (_) {}
2786
+ }
2787
+ } catch (_) {}
2788
+
2789
+ if (!msgEl) return "message_not_found";
2790
+
2791
+ // Right-click the bubble to open the context menu.
2792
+ const pos = msgEl.position();
2793
+ const size = msgEl.size();
2794
+ se.rightClick({ at: [pos[0] + size[0] / 2, pos[1] + size[1] / 2] });
2795
+ delay(0.3);
2796
+
2797
+ // Click "React\u2026" item.
2798
+ try {
2799
+ const menu = proc.windows[0].menus[0];
2800
+ const reactItem = menu.menuItems.byName("React\u2026");
2801
+ reactItem.actions.byName("AXPress").perform();
2802
+ delay(0.2);
2803
+ reactItem.menus[0].menuItems[${reactionIndex}].actions.byName("AXPress").perform();
2804
+ } catch (err) {
2805
+ return "react_menu_error: " + err.toString();
2806
+ }
2807
+
2808
+ return "ok";
2809
+ })()`;
2810
+ }
2811
+ var TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token";
2812
+ var BOT_FRAMEWORK_SCOPE = "https://api.botframework.com/.default";
2813
+ var TeamsChannel = class _TeamsChannel {
2814
+ id = "teams";
2815
+ name = "Microsoft Teams";
2816
+ config = null;
2817
+ messageHandler = null;
2818
+ // OAuth token cache.
2819
+ accessToken = null;
2820
+ tokenExpiry = 0;
2821
+ // Service URL → conversation mappings for outbound messages.
2822
+ // Capped to prevent unbounded growth in high-traffic bots.
2823
+ static MAX_SERVICE_URLS = 1e4;
2824
+ serviceUrls = /* @__PURE__ */ new Map();
2825
+ // ---------------------------------------------------------------------------
2826
+ // IChannel lifecycle
2827
+ // ---------------------------------------------------------------------------
2828
+ async start(config) {
2829
+ const cfg = config;
2830
+ if (!cfg.appId || typeof cfg.appId !== "string") {
2831
+ throw new Error("TeamsChannel requires appId in config.");
2832
+ }
2833
+ if (!cfg.appPassword || typeof cfg.appPassword !== "string") {
2834
+ throw new Error("TeamsChannel requires appPassword in config.");
2835
+ }
2836
+ this.config = cfg;
2837
+ await this.getAccessToken();
2838
+ }
2839
+ async stop() {
2840
+ this.messageHandler = null;
2841
+ this.accessToken = null;
2842
+ this.tokenExpiry = 0;
2843
+ this.serviceUrls.clear();
2844
+ this.config = null;
2845
+ }
2846
+ onMessage(handler) {
2847
+ this.messageHandler = handler;
2848
+ }
2849
+ async send(to, message) {
2850
+ if (!this.config) {
2851
+ return { success: false, error: "TeamsChannel not started." };
2852
+ }
2853
+ const serviceUrl = this.serviceUrls.get(to.channelId);
2854
+ if (!serviceUrl) {
2855
+ return { success: false, error: `No service URL known for conversation ${to.channelId}` };
2856
+ }
2857
+ try {
2858
+ const token = await this.getAccessToken();
2859
+ const conversationId = to.channelId;
2860
+ const activity = {
2861
+ type: "message",
2862
+ text: message.text,
2863
+ textFormat: message.format === "markdown" ? "markdown" : "plain"
2864
+ };
2865
+ if (message.replyTo) {
2866
+ activity.replyToId = message.replyTo;
2867
+ }
2868
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(conversationId)}/activities`;
2869
+ const response = await fetch(url, {
2870
+ method: "POST",
2871
+ headers: {
2872
+ "Content-Type": "application/json",
2873
+ "Authorization": `Bearer ${token}`
2874
+ },
2875
+ body: JSON.stringify(activity)
2876
+ });
2877
+ if (!response.ok) {
2878
+ const errText = await response.text().catch(() => "");
2879
+ return { success: false, error: `Teams API ${response.status}: ${errText}` };
2880
+ }
2881
+ const result = await response.json();
2882
+ return { success: true, messageId: result.id };
2883
+ } catch (err) {
2884
+ return { success: false, error: `Teams send failed: ${err.message}` };
2885
+ }
2886
+ }
2887
+ async editMessage(to, messageId, message) {
2888
+ if (!this.config) {
2889
+ return { success: false, error: "TeamsChannel not started." };
2890
+ }
2891
+ const serviceUrl = this.serviceUrls.get(to.channelId);
2892
+ if (!serviceUrl) {
2893
+ return { success: false, error: `No service URL known for conversation ${to.channelId}` };
2894
+ }
2895
+ try {
2896
+ const token = await this.getAccessToken();
2897
+ const conversationId = to.channelId;
2898
+ const activity = {
2899
+ type: "message",
2900
+ id: messageId,
2901
+ text: message.text,
2902
+ textFormat: message.format === "markdown" ? "markdown" : "plain"
2903
+ };
2904
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(messageId)}`;
2905
+ const response = await fetch(url, {
2906
+ method: "PUT",
2907
+ headers: {
2908
+ "Content-Type": "application/json",
2909
+ "Authorization": `Bearer ${token}`
2910
+ },
2911
+ body: JSON.stringify(activity)
2912
+ });
2913
+ if (!response.ok) {
2914
+ const errText = await response.text().catch(() => "");
2915
+ return { success: false, error: `Teams edit API ${response.status}: ${errText}` };
2916
+ }
2917
+ return { success: true, messageId };
2918
+ } catch (err) {
2919
+ return { success: false, error: `Teams edit failed: ${err.message}` };
2920
+ }
2921
+ }
2922
+ async isHealthy() {
2923
+ if (!this.config) return false;
2924
+ try {
2925
+ await this.getAccessToken();
2926
+ return true;
2927
+ } catch {
2928
+ return false;
2929
+ }
2930
+ }
2931
+ // ---------------------------------------------------------------------------
2932
+ // Inbound activity handling
2933
+ // ---------------------------------------------------------------------------
2934
+ /**
2935
+ * Process an inbound activity from the Bot Framework webhook.
2936
+ * Called by the gateway's channel webhook route.
2937
+ */
2938
+ handleIncomingActivity(activity) {
2939
+ if (!this.config || !this.messageHandler) return;
2940
+ if (activity.serviceUrl && activity.conversation?.id) {
2941
+ this.serviceUrls.set(activity.conversation.id, activity.serviceUrl);
2942
+ if (this.serviceUrls.size > _TeamsChannel.MAX_SERVICE_URLS) {
2943
+ const oldest = this.serviceUrls.keys().next().value;
2944
+ if (oldest !== void 0) this.serviceUrls.delete(oldest);
2945
+ }
2946
+ }
2947
+ if (activity.type !== "message") return;
2948
+ if (!activity.text) return;
2949
+ if (activity.from?.id === this.config.appId) return;
2950
+ if (this.config.allowedUsers?.length) {
2951
+ const aadId = activity.from?.aadObjectId;
2952
+ if (aadId && !this.config.allowedUsers.includes(aadId)) return;
2953
+ }
2954
+ const conversationId = activity.conversation?.id ?? "";
2955
+ const userId = activity.from?.id ?? "";
2956
+ const inbound = {
2957
+ id: activity.id ?? `teams-${Date.now()}`,
2958
+ channelId: conversationId,
2959
+ from: {
2960
+ channelId: conversationId,
2961
+ userId,
2962
+ groupId: activity.conversation?.isGroup ? conversationId : void 0
2963
+ },
2964
+ text: activity.text,
2965
+ timestamp: activity.timestamp ? new Date(activity.timestamp) : /* @__PURE__ */ new Date(),
2966
+ raw: activity
2967
+ };
2968
+ this.messageHandler(inbound);
2969
+ }
2970
+ // ---------------------------------------------------------------------------
2971
+ // OAuth2 token management
2972
+ // ---------------------------------------------------------------------------
2973
+ async getAccessToken() {
2974
+ if (this.accessToken && Date.now() < this.tokenExpiry - 6e4) {
2975
+ return this.accessToken;
2976
+ }
2977
+ if (!this.config) {
2978
+ throw new Error("TeamsChannel not configured.");
2979
+ }
2980
+ const body = new URLSearchParams({
2981
+ grant_type: "client_credentials",
2982
+ client_id: this.config.appId,
2983
+ client_secret: this.config.appPassword,
2984
+ scope: BOT_FRAMEWORK_SCOPE
2985
+ });
2986
+ const response = await fetch(TOKEN_URL, {
2987
+ method: "POST",
2988
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2989
+ body: body.toString()
2990
+ });
2991
+ if (!response.ok) {
2992
+ const errText = await response.text().catch(() => "");
2993
+ throw new Error(`Token request failed (${response.status}): ${errText}`);
2994
+ }
2995
+ const data = await response.json();
2996
+ this.accessToken = data.access_token;
2997
+ this.tokenExpiry = Date.now() + data.expires_in * 1e3;
2998
+ return this.accessToken;
2999
+ }
3000
+ };
3001
+ var ZALO_MAX_MESSAGE_LEN = 2e3;
3002
+ var OA_API_BASE = "https://openapi.zalo.me/v3.0/oa";
3003
+ var GRAPH_API_BASE = "https://graph.zalo.me/v2.0";
3004
+ var ZaloChannel = class {
3005
+ id = "zalo";
3006
+ name = "Zalo";
3007
+ config = null;
3008
+ messageHandler = null;
3009
+ // Access token cache (tokens expire after ~1 hour).
3010
+ currentAccessToken = null;
3011
+ tokenExpiry = 0;
3012
+ // ---------------------------------------------------------------------------
3013
+ // IChannel lifecycle
3014
+ // ---------------------------------------------------------------------------
3015
+ async start(config) {
3016
+ const cfg = config;
3017
+ if (!cfg.oaId || typeof cfg.oaId !== "string") {
3018
+ throw new Error("ZaloChannel requires oaId in config.");
3019
+ }
3020
+ if (!cfg.oaSecretKey || typeof cfg.oaSecretKey !== "string") {
3021
+ throw new Error("ZaloChannel requires oaSecretKey in config.");
3022
+ }
3023
+ if (!cfg.accessToken || typeof cfg.accessToken !== "string") {
3024
+ throw new Error("ZaloChannel requires accessToken in config.");
3025
+ }
3026
+ if (!cfg.appId || typeof cfg.appId !== "string") {
3027
+ throw new Error("ZaloChannel requires appId in config.");
3028
+ }
3029
+ this.config = cfg;
3030
+ this.currentAccessToken = cfg.accessToken;
3031
+ this.tokenExpiry = Date.now() + 3600 * 1e3;
3032
+ }
3033
+ async stop() {
3034
+ this.messageHandler = null;
3035
+ this.currentAccessToken = null;
3036
+ this.tokenExpiry = 0;
3037
+ this.config = null;
3038
+ }
3039
+ onMessage(handler) {
3040
+ this.messageHandler = handler;
3041
+ }
3042
+ async send(to, message) {
3043
+ if (!this.config) {
3044
+ return { success: false, error: "ZaloChannel not started." };
3045
+ }
3046
+ const userId = to.userId;
3047
+ if (!userId) {
3048
+ return { success: false, error: "Recipient must have userId for Zalo." };
3049
+ }
3050
+ try {
3051
+ const token = await this.getAccessToken();
3052
+ const chunks = splitMessage(message.text ?? "", ZALO_MAX_MESSAGE_LEN);
3053
+ let lastId;
3054
+ for (const chunk of chunks) {
3055
+ const body = {
3056
+ recipient: { user_id: userId },
3057
+ message: { text: chunk }
3058
+ };
3059
+ const response = await fetch(`${OA_API_BASE}/message/cs`, {
3060
+ method: "POST",
3061
+ headers: {
3062
+ "Content-Type": "application/json",
3063
+ "access_token": token
3064
+ },
3065
+ body: JSON.stringify(body)
3066
+ });
3067
+ if (!response.ok) {
3068
+ const errText = await response.text().catch(() => "");
3069
+ return { success: false, error: `Zalo API ${response.status}: ${errText}` };
3070
+ }
3071
+ const result = await response.json();
3072
+ if (result.error !== 0) {
3073
+ return { success: false, error: `Zalo API error ${result.error}: ${result.message}` };
3074
+ }
3075
+ lastId = result.data?.message_id ?? lastId;
3076
+ }
3077
+ return { success: true, messageId: lastId };
3078
+ } catch (err) {
3079
+ return { success: false, error: `Zalo send failed: ${err.message}` };
3080
+ }
3081
+ }
3082
+ async isHealthy() {
3083
+ if (!this.config) return false;
3084
+ try {
3085
+ const token = await this.getAccessToken();
3086
+ const response = await fetch(`${OA_API_BASE}/getoa`, {
3087
+ method: "GET",
3088
+ headers: { "access_token": token }
3089
+ });
3090
+ if (!response.ok) return false;
3091
+ const result = await response.json();
3092
+ return result.error === 0;
3093
+ } catch {
3094
+ return false;
3095
+ }
3096
+ }
3097
+ // ---------------------------------------------------------------------------
3098
+ // Inbound webhook handling
3099
+ // ---------------------------------------------------------------------------
3100
+ /**
3101
+ * Verify a Zalo webhook MAC signature.
3102
+ *
3103
+ * Zalo signs webhooks using SHA-256:
3104
+ * mac = SHA256(appId + rawBody + timestamp + oaSecretKey)
3105
+ *
3106
+ * The signature is sent in the `mac` field of the event payload.
3107
+ */
3108
+ async verifyWebhookMac(rawBody, mac) {
3109
+ if (!this.config) return false;
3110
+ try {
3111
+ const { createHash } = await import("crypto");
3112
+ const parsed = JSON.parse(rawBody);
3113
+ const appId = parsed.app_id ?? this.config.appId;
3114
+ const timestamp = parsed.timestamp ?? "";
3115
+ const baseString = `${appId}${rawBody}${timestamp}${this.config.oaSecretKey}`;
3116
+ const expected = createHash("sha256").update(baseString).digest("hex");
3117
+ return expected === mac;
3118
+ } catch {
3119
+ return false;
3120
+ }
3121
+ }
3122
+ /**
3123
+ * Process an inbound webhook event from Zalo.
3124
+ * Called by the gateway's channel webhook route.
3125
+ */
3126
+ handleIncomingEvent(event) {
3127
+ if (!this.config || !this.messageHandler) return;
3128
+ if (event.event_name !== "user_send_text") return;
3129
+ const text = event.message?.text;
3130
+ if (!text) return;
3131
+ const senderId = event.sender?.id ?? "";
3132
+ if (!senderId) return;
3133
+ if (this.config.allowedUsers?.length) {
3134
+ if (!this.config.allowedUsers.includes(senderId)) return;
3135
+ }
3136
+ const inbound = {
3137
+ id: event.message?.msg_id ?? `zalo-${Date.now()}`,
3138
+ channelId: this.id,
3139
+ from: {
3140
+ channelId: this.id,
3141
+ userId: senderId
3142
+ },
3143
+ text,
3144
+ timestamp: event.timestamp ? new Date(parseInt(event.timestamp, 10)) : /* @__PURE__ */ new Date(),
3145
+ raw: event
3146
+ };
3147
+ this.messageHandler(inbound);
3148
+ }
3149
+ // ---------------------------------------------------------------------------
3150
+ // Access token management
3151
+ // ---------------------------------------------------------------------------
3152
+ async getAccessToken() {
3153
+ if (this.currentAccessToken && Date.now() < this.tokenExpiry - 6e4) {
3154
+ return this.currentAccessToken;
3155
+ }
3156
+ if (this.config?.refreshToken && this.config.appSecret) {
3157
+ try {
3158
+ return await this.refreshAccessToken();
3159
+ } catch {
3160
+ }
3161
+ }
3162
+ if (this.currentAccessToken) {
3163
+ return this.currentAccessToken;
3164
+ }
3165
+ throw new Error("ZaloChannel: no valid access token available.");
3166
+ }
3167
+ async refreshAccessToken() {
3168
+ if (!this.config?.refreshToken || !this.config.appSecret) {
3169
+ throw new Error("Cannot refresh: missing refreshToken or appSecret.");
3170
+ }
3171
+ const response = await fetch(`${GRAPH_API_BASE}/me/token`, {
3172
+ method: "POST",
3173
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3174
+ body: new URLSearchParams({
3175
+ app_id: this.config.appId,
3176
+ grant_type: "refresh_token",
3177
+ refresh_token: this.config.refreshToken,
3178
+ app_secret: this.config.appSecret
3179
+ }).toString()
3180
+ });
3181
+ if (!response.ok) {
3182
+ const errText = await response.text().catch(() => "");
3183
+ throw new Error(`Token refresh failed (${response.status}): ${errText}`);
3184
+ }
3185
+ const data = await response.json();
3186
+ if (data.error && data.error !== 0) {
3187
+ throw new Error(`Token refresh error ${data.error}: ${data.message ?? "unknown"}`);
3188
+ }
3189
+ if (!data.access_token) {
3190
+ throw new Error("Token refresh returned no access_token.");
3191
+ }
3192
+ this.currentAccessToken = data.access_token;
3193
+ this.tokenExpiry = Date.now() + (data.expires_in ?? 3600) * 1e3;
3194
+ if (data.refresh_token) {
3195
+ this.config.refreshToken = data.refresh_token;
3196
+ }
3197
+ return this.currentAccessToken;
3198
+ }
3199
+ };
3200
+ var SEND_TIMEOUT_MS = 15e3;
3201
+ var HEALTH_TIMEOUT_MS = 5e3;
3202
+ var ZaloPersonalChannel = class {
3203
+ id = "zalo-personal";
3204
+ name = "Zalo Personal";
3205
+ config = null;
3206
+ messageHandler = null;
3207
+ // ---------------------------------------------------------------------------
3208
+ // IChannel lifecycle
3209
+ // ---------------------------------------------------------------------------
3210
+ async start(config) {
3211
+ const cfg = config;
3212
+ if (!cfg.bridgeUrl || typeof cfg.bridgeUrl !== "string") {
3213
+ throw new Error("ZaloPersonalChannel requires bridgeUrl in config.");
3214
+ }
3215
+ cfg.bridgeUrl = cfg.bridgeUrl.replace(/\/+$/, "");
3216
+ this.config = cfg;
3217
+ console.warn(
3218
+ "\n\u26A0\uFE0F Zalo Personal channel uses an unofficial API.\n Using this channel may violate Zalo's Terms of Service.\n By enabling this, you accept full responsibility.\n"
3219
+ );
3220
+ try {
3221
+ const healthy = await this.isHealthy();
3222
+ if (!healthy) {
3223
+ console.warn("\u26A0\uFE0F Zalo Personal bridge is not responding at: " + cfg.bridgeUrl);
3224
+ }
3225
+ } catch {
3226
+ console.warn("\u26A0\uFE0F Could not reach Zalo Personal bridge at: " + cfg.bridgeUrl);
3227
+ }
3228
+ }
3229
+ async stop() {
3230
+ this.messageHandler = null;
3231
+ this.config = null;
3232
+ }
3233
+ onMessage(handler) {
3234
+ this.messageHandler = handler;
3235
+ }
3236
+ async send(to, message) {
3237
+ if (!this.config) {
3238
+ return { success: false, error: "ZaloPersonalChannel not started." };
3239
+ }
3240
+ const target = to.userId ?? to.channelId;
3241
+ if (!target) {
3242
+ return { success: false, error: "No target specified." };
3243
+ }
3244
+ try {
3245
+ const url = `${this.config.bridgeUrl}/send`;
3246
+ const headers = {
3247
+ "Content-Type": "application/json"
3248
+ };
3249
+ if (this.config.bridgeToken) {
3250
+ headers["Authorization"] = `Bearer ${this.config.bridgeToken}`;
3251
+ }
3252
+ const controller = new AbortController();
3253
+ const timeoutId = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
3254
+ try {
3255
+ const response = await fetch(url, {
3256
+ method: "POST",
3257
+ headers,
3258
+ body: JSON.stringify({ to: target, text: message.text }),
3259
+ signal: controller.signal
3260
+ });
3261
+ if (!response.ok) {
3262
+ const errText = await response.text().catch(() => "");
3263
+ return { success: false, error: `Bridge send failed (${response.status}): ${errText}` };
3264
+ }
3265
+ const result = await response.json().catch(() => ({}));
3266
+ return { success: true, messageId: result.messageId ?? `zp-${Date.now()}` };
3267
+ } finally {
3268
+ clearTimeout(timeoutId);
3269
+ }
3270
+ } catch (err) {
3271
+ if (err.name === "AbortError") {
3272
+ return { success: false, error: `Bridge send timed out after ${SEND_TIMEOUT_MS}ms.` };
3273
+ }
3274
+ return { success: false, error: `Bridge send failed: ${err.message}` };
3275
+ }
3276
+ }
3277
+ async isHealthy() {
3278
+ if (!this.config) return false;
3279
+ try {
3280
+ const url = `${this.config.bridgeUrl}/health`;
3281
+ const headers = {};
3282
+ if (this.config.bridgeToken) {
3283
+ headers["Authorization"] = `Bearer ${this.config.bridgeToken}`;
3284
+ }
3285
+ const controller = new AbortController();
3286
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
3287
+ try {
3288
+ const response = await fetch(url, { headers, signal: controller.signal });
3289
+ return response.ok;
3290
+ } finally {
3291
+ clearTimeout(timeoutId);
3292
+ }
3293
+ } catch {
3294
+ return false;
3295
+ }
3296
+ }
3297
+ // ---------------------------------------------------------------------------
3298
+ // Inbound event handling
3299
+ // ---------------------------------------------------------------------------
3300
+ /**
3301
+ * Process an inbound event from the user's Zalo bridge.
3302
+ * Called by the gateway's channel webhook route.
3303
+ */
3304
+ handleIncomingEvent(event) {
3305
+ if (!this.config || !this.messageHandler) return;
3306
+ if (!event.sender || !event.text) return;
3307
+ if (this.config.allowedUsers?.length) {
3308
+ if (!this.config.allowedUsers.includes(event.sender)) return;
3309
+ }
3310
+ const inbound = {
3311
+ id: event.messageId ?? `zp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3312
+ channelId: event.threadId ?? event.sender,
3313
+ from: {
3314
+ channelId: event.threadId ?? event.sender,
3315
+ userId: event.sender,
3316
+ groupId: event.threadId
3317
+ },
3318
+ text: event.text,
3319
+ timestamp: event.timestamp ? new Date(event.timestamp) : /* @__PURE__ */ new Date(),
3320
+ raw: event
3321
+ };
3322
+ this.messageHandler(inbound);
3323
+ }
3324
+ };
3325
+ var API_TIMEOUT_MS = 15e3;
3326
+ var HEALTH_TIMEOUT_MS2 = 5e3;
3327
+ var BlueBubblesChannel = class {
3328
+ id = "bluebubbles";
3329
+ name = "BlueBubbles";
3330
+ config = null;
3331
+ messageHandler = null;
3332
+ // ---------------------------------------------------------------------------
3333
+ // IChannel lifecycle
3334
+ // ---------------------------------------------------------------------------
3335
+ async start(config) {
3336
+ const cfg = config;
3337
+ if (!cfg.host || typeof cfg.host !== "string") {
3338
+ throw new Error("BlueBubblesChannel requires host in config.");
3339
+ }
3340
+ if (!cfg.password || typeof cfg.password !== "string") {
3341
+ throw new Error("BlueBubblesChannel requires password in config.");
3342
+ }
3343
+ cfg.host = cfg.host.replace(/\/+$/, "");
3344
+ this.config = cfg;
3345
+ await this.verifyConnection();
3346
+ }
3347
+ async stop() {
3348
+ this.messageHandler = null;
3349
+ this.config = null;
3350
+ }
3351
+ onMessage(handler) {
3352
+ this.messageHandler = handler;
3353
+ }
3354
+ async send(to, message) {
3355
+ if (!this.config) {
3356
+ return { success: false, error: "BlueBubblesChannel not started." };
3357
+ }
3358
+ const chatGuid = to.groupId ?? to.channelId;
3359
+ if (!chatGuid) {
3360
+ return { success: false, error: "No chat GUID specified." };
3361
+ }
3362
+ try {
3363
+ const url = this.apiUrl(`/api/v1/message/text`);
3364
+ const controller = new AbortController();
3365
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
3366
+ try {
3367
+ const response = await fetch(url, {
3368
+ method: "POST",
3369
+ headers: { "Content-Type": "application/json" },
3370
+ body: JSON.stringify({
3371
+ chatGuid,
3372
+ message: message.text,
3373
+ tempGuid: `temp-${Date.now()}`
3374
+ }),
3375
+ signal: controller.signal
3376
+ });
3377
+ if (!response.ok) {
3378
+ const errText = await response.text().catch(() => "");
3379
+ return { success: false, error: `BlueBubbles API ${response.status}: ${errText}` };
3380
+ }
3381
+ const result = await response.json();
3382
+ if (result.status !== 200) {
3383
+ return { success: false, error: result.error?.message ?? result.message };
3384
+ }
3385
+ return { success: true, messageId: result.data?.guid };
3386
+ } finally {
3387
+ clearTimeout(timeoutId);
3388
+ }
3389
+ } catch (err) {
3390
+ if (err.name === "AbortError") {
3391
+ return { success: false, error: `BlueBubbles send timed out after ${API_TIMEOUT_MS}ms.` };
3392
+ }
3393
+ return { success: false, error: `BlueBubbles send failed: ${err.message}` };
3394
+ }
3395
+ }
3396
+ async isHealthy() {
3397
+ if (!this.config) return false;
3398
+ try {
3399
+ const url = this.apiUrl("/api/v1/server/info");
3400
+ const controller = new AbortController();
3401
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS2);
3402
+ try {
3403
+ const response = await fetch(url, { signal: controller.signal });
3404
+ if (!response.ok) return false;
3405
+ const data = await response.json();
3406
+ return data.status === 200;
3407
+ } finally {
3408
+ clearTimeout(timeoutId);
3409
+ }
3410
+ } catch {
3411
+ return false;
3412
+ }
3413
+ }
3414
+ // ---------------------------------------------------------------------------
3415
+ // Inbound event handling
3416
+ // ---------------------------------------------------------------------------
3417
+ /**
3418
+ * Process an inbound webhook event from BlueBubbles server.
3419
+ * Called by the gateway's channel webhook route.
3420
+ */
3421
+ handleIncomingEvent(event) {
3422
+ if (!this.config || !this.messageHandler) return;
3423
+ if (event.type !== "new-message") return;
3424
+ const data = event.data;
3425
+ if (!data || !data.text) return;
3426
+ if (data.isFromMe) return;
3427
+ const senderAddress = data.handle?.address ?? data.handle?.id ?? "";
3428
+ if (!senderAddress) return;
3429
+ if (this.config.allowedAddresses?.length) {
3430
+ if (!this.config.allowedAddresses.includes(senderAddress)) return;
3431
+ }
3432
+ const chat = data.chats?.[0];
3433
+ const chatGuid = chat?.guid ?? senderAddress;
3434
+ const inbound = {
3435
+ id: data.guid ?? `bb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3436
+ channelId: chatGuid,
3437
+ from: {
3438
+ channelId: chatGuid,
3439
+ userId: senderAddress,
3440
+ groupId: chat?.chatIdentifier?.startsWith("chat") ? chatGuid : void 0
3441
+ },
3442
+ text: data.text,
3443
+ timestamp: data.dateCreated ? new Date(data.dateCreated) : /* @__PURE__ */ new Date(),
3444
+ raw: event
3445
+ };
3446
+ this.messageHandler(inbound);
3447
+ }
3448
+ // ---------------------------------------------------------------------------
3449
+ // Internal helpers
3450
+ // ---------------------------------------------------------------------------
3451
+ /**
3452
+ * Build a full API URL with the password query parameter.
3453
+ */
3454
+ apiUrl(path) {
3455
+ const base = `${this.config.host}${path}`;
3456
+ const separator = base.includes("?") ? "&" : "?";
3457
+ return `${base}${separator}password=${encodeURIComponent(this.config.password)}`;
3458
+ }
3459
+ /**
3460
+ * Verify the BlueBubbles server is reachable on startup.
3461
+ */
3462
+ async verifyConnection() {
3463
+ const healthy = await this.isHealthy();
3464
+ if (!healthy) {
3465
+ throw new Error(
3466
+ `Cannot connect to BlueBubbles server at ${this.config.host}. Ensure the server is running and the password is correct.`
3467
+ );
3468
+ }
3469
+ }
3470
+ };
3471
+ var GOOGLE_CHAT_MAX_MESSAGE_LEN = 4e3;
3472
+ var CHAT_API_BASE = "https://chat.googleapis.com/v1";
3473
+ var TOKEN_URL2 = "https://oauth2.googleapis.com/token";
3474
+ var CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
3475
+ var API_TIMEOUT_MS2 = 15e3;
3476
+ var JWT_LIFETIME_S = 3600;
3477
+ var GoogleChatChannel = class {
3478
+ id = "googlechat";
3479
+ name = "Google Chat";
3480
+ config = null;
3481
+ messageHandler = null;
3482
+ // Service account credentials (parsed once on start).
3483
+ serviceAccount = null;
3484
+ // OAuth token cache.
3485
+ accessToken = null;
3486
+ tokenExpiry = 0;
3487
+ // ---------------------------------------------------------------------------
3488
+ // IChannel lifecycle
3489
+ // ---------------------------------------------------------------------------
3490
+ async start(config) {
3491
+ const cfg = config;
3492
+ if (!cfg.serviceAccountKey || typeof cfg.serviceAccountKey !== "string") {
3493
+ throw new Error("GoogleChatChannel requires serviceAccountKey in config.");
3494
+ }
3495
+ try {
3496
+ this.serviceAccount = JSON.parse(cfg.serviceAccountKey);
3497
+ } catch {
3498
+ throw new Error("GoogleChatChannel: serviceAccountKey must be valid JSON.");
3499
+ }
3500
+ if (!this.serviceAccount.client_email || !this.serviceAccount.private_key) {
3501
+ throw new Error("GoogleChatChannel: serviceAccountKey must contain client_email and private_key.");
3502
+ }
3503
+ this.config = cfg;
3504
+ await this.getAccessToken();
3505
+ }
3506
+ async stop() {
3507
+ this.messageHandler = null;
3508
+ this.accessToken = null;
3509
+ this.tokenExpiry = 0;
3510
+ this.serviceAccount = null;
3511
+ this.config = null;
3512
+ }
3513
+ onMessage(handler) {
3514
+ this.messageHandler = handler;
3515
+ }
3516
+ async send(to, message) {
3517
+ if (!this.config || !this.serviceAccount) {
3518
+ return { success: false, error: "GoogleChatChannel not started." };
3519
+ }
3520
+ const spaceName = to.groupId ?? to.channelId;
3521
+ if (!spaceName) {
3522
+ return { success: false, error: "No space specified." };
3523
+ }
3524
+ try {
3525
+ const token = await this.getAccessToken();
3526
+ const url = `${CHAT_API_BASE}/${spaceName}/messages`;
3527
+ const chunks = splitMessage(message.text ?? "", GOOGLE_CHAT_MAX_MESSAGE_LEN);
3528
+ let lastId;
3529
+ for (const chunk of chunks) {
3530
+ const body = { text: chunk };
3531
+ if (to.threadId) {
3532
+ body.thread = { name: to.threadId };
3533
+ }
3534
+ const controller = new AbortController();
3535
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS2);
3536
+ try {
3537
+ const response = await fetch(url, {
3538
+ method: "POST",
3539
+ headers: {
3540
+ "Content-Type": "application/json",
3541
+ "Authorization": `Bearer ${token}`
3542
+ },
3543
+ body: JSON.stringify(body),
3544
+ signal: controller.signal
3545
+ });
3546
+ if (!response.ok) {
3547
+ const errText = await response.text().catch(() => "");
3548
+ return { success: false, error: `Google Chat API ${response.status}: ${errText}` };
3549
+ }
3550
+ const result = await response.json();
3551
+ if (result.error) {
3552
+ return { success: false, error: `Google Chat API error: ${result.error.message}` };
3553
+ }
3554
+ lastId = result.name;
3555
+ } finally {
3556
+ clearTimeout(timeoutId);
3557
+ }
3558
+ }
3559
+ return { success: true, messageId: lastId };
3560
+ } catch (err) {
3561
+ if (err.name === "AbortError") {
3562
+ return { success: false, error: `Google Chat send timed out after ${API_TIMEOUT_MS2}ms.` };
3563
+ }
3564
+ return { success: false, error: `Google Chat send failed: ${err.message}` };
3565
+ }
3566
+ }
3567
+ async editMessage(_to, messageId, message) {
3568
+ if (!this.config || !this.serviceAccount) {
3569
+ return { success: false, error: "GoogleChatChannel not started." };
3570
+ }
3571
+ try {
3572
+ const token = await this.getAccessToken();
3573
+ const url = `${CHAT_API_BASE}/${messageId}?updateMask=text`;
3574
+ const controller = new AbortController();
3575
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS2);
3576
+ try {
3577
+ const response = await fetch(url, {
3578
+ method: "PUT",
3579
+ headers: {
3580
+ "Content-Type": "application/json",
3581
+ "Authorization": `Bearer ${token}`
3582
+ },
3583
+ body: JSON.stringify({ text: truncateMessage(message.text ?? "", GOOGLE_CHAT_MAX_MESSAGE_LEN) }),
3584
+ signal: controller.signal
3585
+ });
3586
+ if (!response.ok) {
3587
+ const errText = await response.text().catch(() => "");
3588
+ return { success: false, error: `Google Chat edit API ${response.status}: ${errText}` };
3589
+ }
3590
+ return { success: true, messageId };
3591
+ } finally {
3592
+ clearTimeout(timeoutId);
3593
+ }
3594
+ } catch (err) {
3595
+ if (err.name === "AbortError") {
3596
+ return { success: false, error: `Google Chat edit timed out after ${API_TIMEOUT_MS2}ms.` };
3597
+ }
3598
+ return { success: false, error: `Google Chat edit failed: ${err.message}` };
3599
+ }
3600
+ }
3601
+ async isHealthy() {
3602
+ if (!this.config || !this.serviceAccount) return false;
3603
+ try {
3604
+ await this.getAccessToken();
3605
+ return true;
3606
+ } catch {
3607
+ return false;
3608
+ }
3609
+ }
3610
+ // ---------------------------------------------------------------------------
3611
+ // Inbound event handling
3612
+ // ---------------------------------------------------------------------------
3613
+ /**
3614
+ * Process an inbound event from Google Chat webhook.
3615
+ * Called by the gateway's channel webhook route.
3616
+ */
3617
+ handleIncomingEvent(event) {
3618
+ if (!this.config || !this.messageHandler) return;
3619
+ if (this.config.verificationToken && event.token) {
3620
+ if (event.token !== this.config.verificationToken) return;
3621
+ }
3622
+ if (event.type !== "MESSAGE") return;
3623
+ const msg = event.message;
3624
+ if (!msg?.text && !msg?.argumentText) return;
3625
+ if (msg.sender?.type === "BOT") return;
3626
+ const senderEmail = msg.sender?.email ?? "";
3627
+ if (this.config.allowedUsers?.length) {
3628
+ if (senderEmail && !this.config.allowedUsers.includes(senderEmail)) return;
3629
+ }
3630
+ const spaceName = msg.space?.name ?? event.space?.name ?? "";
3631
+ if (this.config.allowedSpaces?.length) {
3632
+ if (spaceName && !this.config.allowedSpaces.includes(spaceName)) return;
3633
+ }
3634
+ const text = msg.argumentText ?? msg.text ?? "";
3635
+ const senderId = msg.sender?.name ?? senderEmail;
3636
+ const inbound = {
3637
+ id: msg.name ?? `gc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3638
+ channelId: spaceName,
3639
+ from: {
3640
+ channelId: spaceName,
3641
+ userId: senderId,
3642
+ groupId: msg.space?.type === "ROOM" ? spaceName : void 0,
3643
+ threadId: msg.thread?.name
3644
+ },
3645
+ text,
3646
+ timestamp: msg.createTime ? new Date(msg.createTime) : /* @__PURE__ */ new Date(),
3647
+ raw: event
3648
+ };
3649
+ this.messageHandler(inbound);
3650
+ }
3651
+ // ---------------------------------------------------------------------------
3652
+ // JWT token management
3653
+ // ---------------------------------------------------------------------------
3654
+ /** Get a valid access token, refreshing if needed. */
3655
+ async getAccessToken() {
3656
+ if (this.accessToken && Date.now() < this.tokenExpiry - 6e4) {
3657
+ return this.accessToken;
3658
+ }
3659
+ if (!this.serviceAccount) {
3660
+ throw new Error("GoogleChatChannel not configured.");
3661
+ }
3662
+ const jwt = this.createJwt();
3663
+ const body = new URLSearchParams({
3664
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
3665
+ assertion: jwt
3666
+ });
3667
+ const tokenUrl = this.serviceAccount.token_uri ?? TOKEN_URL2;
3668
+ const response = await fetch(tokenUrl, {
3669
+ method: "POST",
3670
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3671
+ body: body.toString()
3672
+ });
3673
+ if (!response.ok) {
3674
+ const errText = await response.text().catch(() => "");
3675
+ throw new Error(`Google token request failed (${response.status}): ${errText}`);
3676
+ }
3677
+ const data = await response.json();
3678
+ this.accessToken = data.access_token;
3679
+ this.tokenExpiry = Date.now() + data.expires_in * 1e3;
3680
+ return this.accessToken;
3681
+ }
3682
+ /** Create a self-signed JWT for the service account. */
3683
+ createJwt() {
3684
+ const now = Math.floor(Date.now() / 1e3);
3685
+ const header = {
3686
+ alg: "RS256",
3687
+ typ: "JWT"
3688
+ };
3689
+ const payload = {
3690
+ iss: this.serviceAccount.client_email,
3691
+ scope: CHAT_SCOPE,
3692
+ aud: this.serviceAccount.token_uri ?? TOKEN_URL2,
3693
+ iat: now,
3694
+ exp: now + JWT_LIFETIME_S
3695
+ };
3696
+ const headerB64 = base64urlEncode(JSON.stringify(header));
3697
+ const payloadB64 = base64urlEncode(JSON.stringify(payload));
3698
+ const signingInput = `${headerB64}.${payloadB64}`;
3699
+ const signer = createSign("RSA-SHA256");
3700
+ signer.update(signingInput);
3701
+ const signature = signer.sign(this.serviceAccount.private_key, "base64");
3702
+ const signatureB64 = signature.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3703
+ return `${signingInput}.${signatureB64}`;
3704
+ }
3705
+ };
3706
+ function base64urlEncode(str) {
3707
+ return Buffer.from(str, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3708
+ }
3709
+ var WebChatChannel = class {
3710
+ id = "webchat";
3711
+ name = "WebChat";
3712
+ config = null;
3713
+ messageHandler = null;
3714
+ // Map of userId → Set<WebSocket> (supports multiple browser tabs).
3715
+ clients = /* @__PURE__ */ new Map();
3716
+ // Counter for generating anonymous user IDs.
3717
+ anonCounter = 0;
3718
+ // ---------------------------------------------------------------------------
3719
+ // IChannel lifecycle
3720
+ // ---------------------------------------------------------------------------
3721
+ async start(config) {
3722
+ this.config = config;
3723
+ }
3724
+ async stop() {
3725
+ for (const [, sockets] of this.clients) {
3726
+ for (const ws of sockets) {
3727
+ try {
3728
+ ws.close(1001, "Channel stopping");
3729
+ } catch {
3730
+ }
3731
+ }
3732
+ }
3733
+ this.clients.clear();
3734
+ this.messageHandler = null;
3735
+ this.config = null;
3736
+ }
3737
+ onMessage(handler) {
3738
+ this.messageHandler = handler;
3739
+ }
3740
+ async send(to, message) {
3741
+ if (!this.config) {
3742
+ return { success: false, error: "WebChatChannel not started." };
3743
+ }
3744
+ const userId = to.userId ?? to.channelId;
3745
+ const sockets = this.clients.get(userId);
3746
+ if (!sockets || sockets.size === 0) {
3747
+ return { success: false, error: `No WebSocket connection for user ${userId}` };
3748
+ }
3749
+ const messageId = `wc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3750
+ const payload = {
3751
+ type: "message",
3752
+ id: messageId,
3753
+ text: message.text
3754
+ };
3755
+ const json = JSON.stringify(payload);
3756
+ let sent = false;
3757
+ for (const ws of sockets) {
3758
+ try {
3759
+ if (ws.readyState === 1) {
3760
+ ws.send(json);
3761
+ sent = true;
3762
+ }
3763
+ } catch {
3764
+ sockets.delete(ws);
3765
+ }
3766
+ }
3767
+ return sent ? { success: true, messageId } : { success: false, error: "All WebSocket connections are closed." };
3768
+ }
3769
+ async editMessage(to, messageId, message) {
3770
+ if (!this.config) {
3771
+ return { success: false, error: "WebChatChannel not started." };
3772
+ }
3773
+ const userId = to.userId ?? to.channelId;
3774
+ const sockets = this.clients.get(userId);
3775
+ if (!sockets || sockets.size === 0) {
3776
+ return { success: false, error: `No WebSocket connection for user ${userId}` };
3777
+ }
3778
+ const payload = {
3779
+ type: "edit",
3780
+ messageId,
3781
+ text: message.text
3782
+ };
3783
+ const json = JSON.stringify(payload);
3784
+ let sent = false;
3785
+ for (const ws of sockets) {
3786
+ try {
3787
+ if (ws.readyState === 1) {
3788
+ ws.send(json);
3789
+ sent = true;
3790
+ }
3791
+ } catch {
3792
+ sockets.delete(ws);
3793
+ }
3794
+ }
3795
+ return sent ? { success: true, messageId } : { success: false, error: "All WebSocket connections are closed." };
3796
+ }
3797
+ async isHealthy() {
3798
+ return this.config !== null;
3799
+ }
3800
+ // ---------------------------------------------------------------------------
3801
+ // WebSocket connection management
3802
+ // ---------------------------------------------------------------------------
3803
+ /**
3804
+ * Register a new WebSocket connection.
3805
+ * Called by the gateway's handleUpgrade() when a client connects to /webchat.
3806
+ */
3807
+ handleConnection(ws) {
3808
+ if (!this.config) {
3809
+ ws.close(1013, "Channel not started");
3810
+ return;
3811
+ }
3812
+ let userId = null;
3813
+ const requireAuth = this.config.requireAuth ?? false;
3814
+ ws.on("message", (data) => {
3815
+ try {
3816
+ const msg = JSON.parse(typeof data === "string" ? data : data.toString());
3817
+ if (msg.type === "auth") {
3818
+ return;
3819
+ }
3820
+ if (msg.type === "message") {
3821
+ if (!userId) {
3822
+ if (requireAuth && !msg.userId) {
3823
+ this.sendError(ws, "Authentication required.");
3824
+ return;
3825
+ }
3826
+ userId = msg.userId ?? `anon-${++this.anonCounter}`;
3827
+ this.addClient(userId, ws);
3828
+ }
3829
+ if (!msg.text) return;
3830
+ if (!this.messageHandler) return;
3831
+ const inbound = {
3832
+ id: `wc-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3833
+ channelId: this.id,
3834
+ from: {
3835
+ channelId: this.id,
3836
+ userId
3837
+ },
3838
+ text: msg.text,
3839
+ timestamp: /* @__PURE__ */ new Date()
3840
+ };
3841
+ this.messageHandler(inbound);
3842
+ }
3843
+ } catch {
3844
+ this.sendError(ws, "Invalid message format.");
3845
+ }
3846
+ });
3847
+ ws.on("close", () => {
3848
+ if (userId) {
3849
+ this.removeClient(userId, ws);
3850
+ }
3851
+ });
3852
+ ws.on("error", () => {
3853
+ if (userId) {
3854
+ this.removeClient(userId, ws);
3855
+ }
3856
+ });
3857
+ }
3858
+ /**
3859
+ * Register a WebSocket for a specific userId.
3860
+ * Called when the user is already authenticated via the gateway.
3861
+ */
3862
+ handleAuthenticatedConnection(ws, authenticatedUserId) {
3863
+ if (!this.config) {
3864
+ ws.close(1013, "Channel not started");
3865
+ return;
3866
+ }
3867
+ this.addClient(authenticatedUserId, ws);
3868
+ ws.on("message", (data) => {
3869
+ try {
3870
+ const msg = JSON.parse(typeof data === "string" ? data : data.toString());
3871
+ if (msg.type === "message" && msg.text) {
3872
+ if (!this.messageHandler) return;
3873
+ const inbound = {
3874
+ id: `wc-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3875
+ channelId: this.id,
3876
+ from: {
3877
+ channelId: this.id,
3878
+ userId: authenticatedUserId
3879
+ },
3880
+ text: msg.text,
3881
+ timestamp: /* @__PURE__ */ new Date()
3882
+ };
3883
+ this.messageHandler(inbound);
3884
+ }
3885
+ } catch {
3886
+ this.sendError(ws, "Invalid message format.");
3887
+ }
3888
+ });
3889
+ ws.on("close", () => {
3890
+ this.removeClient(authenticatedUserId, ws);
3891
+ });
3892
+ ws.on("error", () => {
3893
+ this.removeClient(authenticatedUserId, ws);
3894
+ });
3895
+ }
3896
+ // ---------------------------------------------------------------------------
3897
+ // Private helpers
3898
+ // ---------------------------------------------------------------------------
3899
+ addClient(userId, ws) {
3900
+ let sockets = this.clients.get(userId);
3901
+ if (!sockets) {
3902
+ sockets = /* @__PURE__ */ new Set();
3903
+ this.clients.set(userId, sockets);
3904
+ }
3905
+ sockets.add(ws);
3906
+ }
3907
+ removeClient(userId, ws) {
3908
+ const sockets = this.clients.get(userId);
3909
+ if (sockets) {
3910
+ sockets.delete(ws);
3911
+ if (sockets.size === 0) {
3912
+ this.clients.delete(userId);
3913
+ }
3914
+ }
3915
+ }
3916
+ sendError(ws, error) {
3917
+ try {
3918
+ const payload = { type: "error", error };
3919
+ ws.send(JSON.stringify(payload));
3920
+ } catch {
3921
+ }
3922
+ }
3923
+ };
3924
+ var DEFAULT_PORT = 6697;
3925
+ var DEFAULT_NICK = "ch4p";
3926
+ var DEFAULT_RECONNECT_DELAY = 5e3;
3927
+ var MAX_IRC_LINE = 512;
3928
+ var MAX_PRIVMSG_TEXT = 400;
3929
+ var IrcChannel = class {
3930
+ id = "irc";
3931
+ name = "IRC";
3932
+ config = null;
3933
+ messageHandler = null;
3934
+ socket = null;
3935
+ buffer = "";
3936
+ registered = false;
3937
+ stopping = false;
3938
+ reconnectTimer = null;
3939
+ // ---------------------------------------------------------------------------
3940
+ // IChannel lifecycle
3941
+ // ---------------------------------------------------------------------------
3942
+ async start(config) {
3943
+ const cfg = config;
3944
+ if (!cfg.server || typeof cfg.server !== "string") {
3945
+ throw new Error("IrcChannel requires server in config.");
3946
+ }
3947
+ this.config = cfg;
3948
+ this.stopping = false;
3949
+ await this.connect();
3950
+ }
3951
+ async stop() {
3952
+ this.stopping = true;
3953
+ if (this.reconnectTimer) {
3954
+ clearTimeout(this.reconnectTimer);
3955
+ this.reconnectTimer = null;
3956
+ }
3957
+ if (this.socket) {
3958
+ try {
3959
+ this.rawSend("QUIT :Goodbye");
3960
+ } catch {
3961
+ }
3962
+ this.socket.destroy();
3963
+ this.socket = null;
3964
+ }
3965
+ this.registered = false;
3966
+ this.buffer = "";
3967
+ this.messageHandler = null;
3968
+ this.config = null;
3969
+ }
3970
+ onMessage(handler) {
3971
+ this.messageHandler = handler;
3972
+ }
3973
+ async send(to, message) {
3974
+ if (!this.config || !this.socket || !this.registered) {
3975
+ return { success: false, error: "IrcChannel not connected." };
3976
+ }
3977
+ const target = to.groupId ?? to.userId ?? to.channelId;
3978
+ if (!target) {
3979
+ return { success: false, error: "No target specified." };
3980
+ }
3981
+ try {
3982
+ const lines = splitMessage2(message.text, MAX_PRIVMSG_TEXT);
3983
+ for (const line of lines) {
3984
+ this.rawSend(`PRIVMSG ${target} :${line}`);
3985
+ }
3986
+ return { success: true, messageId: `irc-${Date.now()}` };
3987
+ } catch (err) {
3988
+ return { success: false, error: `IRC send failed: ${err.message}` };
3989
+ }
3990
+ }
3991
+ async isHealthy() {
3992
+ return this.registered && this.socket !== null && !this.socket.destroyed;
3993
+ }
3994
+ // ---------------------------------------------------------------------------
3995
+ // Connection management
3996
+ // ---------------------------------------------------------------------------
3997
+ async connect() {
3998
+ if (!this.config) throw new Error("Not configured.");
3999
+ const cfg = this.config;
4000
+ const port = cfg.port ?? DEFAULT_PORT;
4001
+ const useSsl = cfg.ssl !== false;
4002
+ return new Promise((resolve, reject) => {
4003
+ let settled = false;
4004
+ const registrationTimeout = setTimeout(() => {
4005
+ if (!settled) {
4006
+ settled = true;
4007
+ this.socket?.destroy();
4008
+ this.socket = null;
4009
+ reject(new Error("IRC registration timeout \u2014 no WELCOME received within 30s"));
4010
+ }
4011
+ }, 3e4);
4012
+ const onConnect = () => {
4013
+ if (cfg.password) {
4014
+ this.rawSend(`PASS ${cfg.password}`);
4015
+ }
4016
+ const nick = cfg.nick ?? DEFAULT_NICK;
4017
+ this.rawSend(`NICK ${nick}`);
4018
+ this.rawSend(`USER ${nick} 0 * :ch4p bot`);
4019
+ clearTimeout(registrationTimeout);
4020
+ settled = true;
4021
+ resolve();
4022
+ };
4023
+ const onError = (err) => {
4024
+ if (!settled) {
4025
+ settled = true;
4026
+ clearTimeout(registrationTimeout);
4027
+ reject(new Error(`IRC connection failed: ${err.message}`));
4028
+ }
4029
+ };
4030
+ if (useSsl) {
4031
+ this.socket = tlsConnect(
4032
+ { host: cfg.server, port, rejectUnauthorized: true },
4033
+ onConnect
4034
+ );
4035
+ } else {
4036
+ this.socket = netConnect({ host: cfg.server, port }, onConnect);
4037
+ }
4038
+ this.socket.setEncoding("utf8");
4039
+ this.socket.on("data", (data) => this.handleData(data));
4040
+ this.socket.on("error", onError);
4041
+ this.socket.on("close", () => this.handleDisconnect());
4042
+ });
4043
+ }
4044
+ scheduleReconnect() {
4045
+ if (this.stopping || !this.config) return;
4046
+ const delay = this.config.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
4047
+ this.reconnectTimer = setTimeout(() => {
4048
+ this.reconnectTimer = null;
4049
+ if (this.stopping) return;
4050
+ this.registered = false;
4051
+ this.buffer = "";
4052
+ this.connect().catch(() => {
4053
+ this.scheduleReconnect();
4054
+ });
4055
+ }, delay);
4056
+ this.reconnectTimer.unref();
4057
+ }
4058
+ handleDisconnect() {
4059
+ this.registered = false;
4060
+ this.socket = null;
4061
+ if (!this.stopping) {
4062
+ this.scheduleReconnect();
4063
+ }
4064
+ }
4065
+ // ---------------------------------------------------------------------------
4066
+ // IRC protocol parsing
4067
+ // ---------------------------------------------------------------------------
4068
+ handleData(data) {
4069
+ this.buffer += data;
4070
+ let newlineIdx;
4071
+ while ((newlineIdx = this.buffer.indexOf("\r\n")) !== -1) {
4072
+ const line = this.buffer.slice(0, newlineIdx);
4073
+ this.buffer = this.buffer.slice(newlineIdx + 2);
4074
+ if (line.length > 0) {
4075
+ this.parseLine(line);
4076
+ }
4077
+ }
4078
+ }
4079
+ parseLine(line) {
4080
+ let prefix = "";
4081
+ let rest = line;
4082
+ if (rest.startsWith(":")) {
4083
+ const spaceIdx = rest.indexOf(" ");
4084
+ if (spaceIdx === -1) return;
4085
+ prefix = rest.slice(1, spaceIdx);
4086
+ rest = rest.slice(spaceIdx + 1);
4087
+ }
4088
+ const parts = rest.split(" ");
4089
+ const command = parts[0];
4090
+ if (command === "PING") {
4091
+ const server = parts.slice(1).join(" ");
4092
+ this.rawSend(`PONG ${server}`);
4093
+ return;
4094
+ }
4095
+ if (command === "001") {
4096
+ this.registered = true;
4097
+ const channels = this.config?.channels ?? [];
4098
+ for (const ch of channels) {
4099
+ this.rawSend(`JOIN ${ch}`);
4100
+ }
4101
+ return;
4102
+ }
4103
+ if (command === "PRIVMSG" && parts.length >= 2) {
4104
+ const target = parts[1];
4105
+ const msgStart = rest.indexOf(" :");
4106
+ if (msgStart === -1) return;
4107
+ const text = rest.slice(msgStart + 2);
4108
+ const nick = prefix.split("!")[0] ?? "";
4109
+ if (this.config?.allowedUsers?.length) {
4110
+ if (!this.config.allowedUsers.includes(nick)) return;
4111
+ }
4112
+ if (!this.messageHandler) return;
4113
+ const isChannel = target.startsWith("#") || target.startsWith("&");
4114
+ const inbound = {
4115
+ id: `irc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4116
+ channelId: isChannel ? target : nick,
4117
+ from: {
4118
+ channelId: isChannel ? target : nick,
4119
+ userId: nick,
4120
+ groupId: isChannel ? target : void 0
4121
+ },
4122
+ text,
4123
+ timestamp: /* @__PURE__ */ new Date(),
4124
+ raw: { prefix, command, target, text }
4125
+ };
4126
+ this.messageHandler(inbound);
4127
+ }
4128
+ }
4129
+ // ---------------------------------------------------------------------------
4130
+ // Raw socket I/O
4131
+ // ---------------------------------------------------------------------------
4132
+ rawSend(line) {
4133
+ if (!this.socket || this.socket.destroyed) return;
4134
+ const truncated = line.length > MAX_IRC_LINE - 2 ? line.slice(0, MAX_IRC_LINE - 2) : line;
4135
+ this.socket.write(`${truncated}\r
4136
+ `);
4137
+ }
4138
+ };
4139
+ function splitMessage2(text, maxLen) {
4140
+ if (text.length <= maxLen) return [text];
4141
+ const chunks = [];
4142
+ let remaining = text;
4143
+ while (remaining.length > 0) {
4144
+ chunks.push(remaining.slice(0, maxLen));
4145
+ remaining = remaining.slice(maxLen);
4146
+ }
4147
+ return chunks;
4148
+ }
4149
+ var execFile2 = promisify2(execFileCb2);
4150
+ var MacOSChannel = class {
4151
+ id = "macos";
4152
+ name = "macOS Native";
4153
+ messageHandler = null;
4154
+ running = false;
4155
+ mode = "dialog";
4156
+ dialogDelay = 500;
4157
+ title = "ch4p";
4158
+ sound = "Submarine";
4159
+ pendingDialog = null;
4160
+ dialogTimer = null;
4161
+ waitingForResponse = false;
4162
+ // -----------------------------------------------------------------------
4163
+ // IChannel implementation
4164
+ // -----------------------------------------------------------------------
4165
+ async start(config) {
4166
+ if (this.running) return;
4167
+ if (process.platform !== "darwin") {
4168
+ throw new Error(
4169
+ `MacOSChannel is macOS-only. Current platform "${process.platform}" is not supported.`
4170
+ );
4171
+ }
4172
+ try {
4173
+ await execFile2("which", ["osascript"]);
4174
+ } catch {
4175
+ throw new Error(
4176
+ "osascript not found on PATH. This should always be available on macOS."
4177
+ );
4178
+ }
4179
+ const cfg = config;
4180
+ this.mode = cfg.mode ?? "dialog";
4181
+ this.dialogDelay = cfg.dialogDelay ?? 500;
4182
+ this.title = cfg.title ?? "ch4p";
4183
+ this.sound = cfg.sound ?? "Submarine";
4184
+ this.running = true;
4185
+ this.scheduleInputDialog();
4186
+ }
4187
+ async stop() {
4188
+ this.running = false;
4189
+ if (this.dialogTimer) {
4190
+ clearTimeout(this.dialogTimer);
4191
+ this.dialogTimer = null;
4192
+ }
4193
+ if (this.pendingDialog) {
4194
+ try {
4195
+ this.pendingDialog.kill("SIGTERM");
4196
+ } catch {
4197
+ }
4198
+ this.pendingDialog = null;
4199
+ }
4200
+ }
4201
+ async send(_to, message) {
4202
+ if (!this.running) {
4203
+ return { success: false, error: "Channel is not running." };
4204
+ }
4205
+ try {
4206
+ const text = message.text || "(no text)";
4207
+ if (this.mode === "notification") {
4208
+ await this.showNotification(text);
4209
+ } else {
4210
+ await this.showNotification(text);
4211
+ }
4212
+ this.waitingForResponse = false;
4213
+ this.scheduleInputDialog();
4214
+ return {
4215
+ success: true,
4216
+ messageId: generateId()
4217
+ };
4218
+ } catch (err) {
4219
+ return {
4220
+ success: false,
4221
+ error: err instanceof Error ? err.message : String(err)
4222
+ };
4223
+ }
4224
+ }
4225
+ onMessage(handler) {
4226
+ this.messageHandler = handler;
4227
+ }
4228
+ onPresence(_handler) {
4229
+ }
4230
+ async isHealthy() {
4231
+ if (!this.running) return false;
4232
+ if (process.platform !== "darwin") return false;
4233
+ try {
4234
+ await execFile2("osascript", ["-e", "1"]);
4235
+ return true;
4236
+ } catch {
4237
+ return false;
4238
+ }
4239
+ }
4240
+ // -----------------------------------------------------------------------
4241
+ // Input dialog
4242
+ // -----------------------------------------------------------------------
4243
+ scheduleInputDialog() {
4244
+ if (!this.running || !this.messageHandler || this.waitingForResponse) return;
4245
+ if (this.dialogTimer) {
4246
+ clearTimeout(this.dialogTimer);
4247
+ }
4248
+ this.dialogTimer = setTimeout(() => {
4249
+ this.dialogTimer = null;
4250
+ void this.showInputDialog();
4251
+ }, this.dialogDelay);
4252
+ }
4253
+ async showInputDialog() {
4254
+ if (!this.running || !this.messageHandler) return;
4255
+ try {
4256
+ const script = [
4257
+ `set dialogResult to display dialog "Message for ch4p:" default answer "" with title "${this.escapeAppleScript(this.title)}" buttons {"Cancel", "Send"} default button "Send"`,
4258
+ "return text returned of dialogResult"
4259
+ ].join("\n");
4260
+ const { stdout } = await execFile2("osascript", ["-e", script]);
4261
+ const text = stdout.trim();
4262
+ if (text && this.messageHandler && this.running) {
4263
+ this.waitingForResponse = true;
4264
+ const inbound = {
4265
+ id: generateId(),
4266
+ channelId: this.id,
4267
+ from: {
4268
+ channelId: this.id,
4269
+ userId: "local-user"
4270
+ },
4271
+ text,
4272
+ timestamp: /* @__PURE__ */ new Date()
4273
+ };
4274
+ this.messageHandler(inbound);
4275
+ } else {
4276
+ this.scheduleInputDialog();
4277
+ }
4278
+ } catch (err) {
4279
+ const errMsg = err instanceof Error ? err.message : String(err);
4280
+ if (errMsg.includes("-128") || errMsg.includes("User canceled")) {
4281
+ if (this.running) {
4282
+ this.dialogTimer = setTimeout(() => {
4283
+ this.dialogTimer = null;
4284
+ void this.showInputDialog();
4285
+ }, 5e3);
4286
+ }
4287
+ return;
4288
+ }
4289
+ if (this.running) {
4290
+ this.scheduleInputDialog();
4291
+ }
4292
+ }
4293
+ }
4294
+ // -----------------------------------------------------------------------
4295
+ // Notification
4296
+ // -----------------------------------------------------------------------
4297
+ async showNotification(text) {
4298
+ const displayText = text.length > 500 ? text.slice(0, 497) + "..." : text;
4299
+ const escaped = this.escapeAppleScript(displayText);
4300
+ const escapedTitle = this.escapeAppleScript(this.title);
4301
+ const script = `display notification "${escaped}" with title "${escapedTitle}" sound name "${this.sound}"`;
4302
+ await execFile2("osascript", ["-e", script]);
4303
+ }
4304
+ // -----------------------------------------------------------------------
4305
+ // Helpers
4306
+ // -----------------------------------------------------------------------
4307
+ /** Escape a string for embedding in an AppleScript string literal. */
4308
+ escapeAppleScript(text) {
4309
+ return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
4310
+ }
4311
+ };
4312
+ var ChannelRegistry = class {
4313
+ channels = /* @__PURE__ */ new Map();
4314
+ /** Register a channel instance. Overwrites if id already exists. */
4315
+ register(channel) {
4316
+ this.channels.set(channel.id, channel);
4317
+ }
4318
+ /** Retrieve a channel by id. */
4319
+ get(id) {
4320
+ return this.channels.get(id);
4321
+ }
4322
+ /** Check whether a channel with the given id is registered. */
4323
+ has(id) {
4324
+ return this.channels.has(id);
4325
+ }
4326
+ /** Return all registered channels. */
4327
+ list() {
4328
+ return [...this.channels.values()];
4329
+ }
4330
+ /** Remove all registered channels. */
4331
+ clear() {
4332
+ this.channels.clear();
4333
+ }
4334
+ /**
4335
+ * Look up a channel by id, call `start()` with the supplied config,
4336
+ * and return the started channel.
4337
+ *
4338
+ * Throws if the channel id is not registered.
4339
+ */
4340
+ async createFromConfig(id, config) {
4341
+ const channel = this.channels.get(id);
4342
+ if (!channel) {
4343
+ throw new Error(`Channel "${id}" is not registered`);
4344
+ }
4345
+ await channel.start(config);
4346
+ return channel;
4347
+ }
4348
+ };
4349
+
4350
+ export {
4351
+ CliChannel,
4352
+ TelegramChannel,
4353
+ DiscordChannel,
4354
+ SlackChannel,
4355
+ MatrixChannel,
4356
+ WhatsAppChannel,
4357
+ SignalChannel,
4358
+ IMessageChannel,
4359
+ TeamsChannel,
4360
+ ZaloChannel,
4361
+ ZaloPersonalChannel,
4362
+ BlueBubblesChannel,
4363
+ GoogleChatChannel,
4364
+ WebChatChannel,
4365
+ IrcChannel,
4366
+ MacOSChannel,
4367
+ ChannelRegistry
4368
+ };