@ch4p/cli 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4412 @@
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 deadline = AbortSignal.timeout(timeoutMs + 5e3);
1370
+ const signal = this.abortController ? AbortSignal.any([this.abortController.signal, deadline]) : deadline;
1371
+ const data = await this.api(
1372
+ "GET",
1373
+ `/_matrix/client/v3/sync?${params.toString()}`,
1374
+ void 0,
1375
+ signal
1376
+ );
1377
+ this.nextBatch = data.next_batch;
1378
+ if (data.rooms?.invite) {
1379
+ for (const roomId of Object.keys(data.rooms.invite)) {
1380
+ this.emit("room.invite", roomId);
1381
+ }
1382
+ }
1383
+ if (data.rooms?.join) {
1384
+ for (const [roomId, room] of Object.entries(data.rooms.join)) {
1385
+ for (const event of room.timeline?.events ?? []) {
1386
+ event.room_id = roomId;
1387
+ if (event.type === "m.room.message") {
1388
+ this.emit("room.message", roomId, event);
1389
+ }
1390
+ this.emit("room.event", roomId, event);
1391
+ }
1392
+ for (const event of room.ephemeral?.events ?? []) {
1393
+ event.room_id = roomId;
1394
+ this.emit("room.event", roomId, event);
1395
+ }
1396
+ }
1397
+ }
1398
+ }
1399
+ // -----------------------------------------------------------------------
1400
+ // HTTP helpers
1401
+ // -----------------------------------------------------------------------
1402
+ async api(method, path, body, signal) {
1403
+ const url = `${this.homeserverUrl}${path}`;
1404
+ const headers = {
1405
+ Authorization: `Bearer ${this.accessToken}`
1406
+ };
1407
+ const init = {
1408
+ method,
1409
+ headers,
1410
+ signal: signal ?? this.abortController?.signal
1411
+ };
1412
+ if (body) {
1413
+ headers["Content-Type"] = "application/json";
1414
+ init.body = JSON.stringify(body);
1415
+ }
1416
+ const res = await fetch(url, init);
1417
+ if (!res.ok) {
1418
+ const text = await res.text().catch(() => "");
1419
+ throw new Error(`Matrix API ${method} ${path} failed (${res.status}): ${text}`);
1420
+ }
1421
+ return await res.json();
1422
+ }
1423
+ sleep(ms) {
1424
+ return new Promise((resolve) => setTimeout(resolve, ms));
1425
+ }
1426
+ };
1427
+ var MatrixChannel = class _MatrixChannel {
1428
+ id = "matrix";
1429
+ name = "Matrix";
1430
+ client = null;
1431
+ messageHandler = null;
1432
+ presenceHandler = null;
1433
+ running = false;
1434
+ allowedRooms = /* @__PURE__ */ new Set();
1435
+ allowedUsers = /* @__PURE__ */ new Set();
1436
+ botUserId = null;
1437
+ lastEditTimestamps = /* @__PURE__ */ new Map();
1438
+ static EDIT_TS_MAX_ENTRIES = 500;
1439
+ // -----------------------------------------------------------------------
1440
+ // IChannel implementation
1441
+ // -----------------------------------------------------------------------
1442
+ async start(config) {
1443
+ if (this.running) return;
1444
+ const cfg = config;
1445
+ if (!cfg.homeserverUrl) {
1446
+ throw new Error('Matrix channel requires a "homeserverUrl" in config');
1447
+ }
1448
+ if (!cfg.accessToken) {
1449
+ throw new Error('Matrix channel requires an "accessToken" in config');
1450
+ }
1451
+ this.allowedRooms = new Set(cfg.allowedRooms ?? []);
1452
+ this.allowedUsers = new Set(cfg.allowedUsers ?? []);
1453
+ if (this.client) {
1454
+ this.client.removeAllListeners();
1455
+ this.client.stop();
1456
+ this.client = null;
1457
+ }
1458
+ this.client = new MinimalMatrixClient(cfg.homeserverUrl, cfg.accessToken);
1459
+ const autoJoin = cfg.autoJoin ?? true;
1460
+ if (autoJoin) {
1461
+ this.client.on("room.invite", (roomId) => {
1462
+ void this.client?.joinRoom(roomId).catch(() => {
1463
+ });
1464
+ });
1465
+ }
1466
+ this.botUserId = await this.client.getUserId();
1467
+ this.client.on("room.message", (roomId, event) => {
1468
+ this.processEvent(roomId, event);
1469
+ });
1470
+ this.client.on("room.event", (roomId, event) => {
1471
+ if (event.type === "m.typing") {
1472
+ this.handleTypingEvent(roomId, event);
1473
+ }
1474
+ });
1475
+ await this.client.start();
1476
+ this.running = true;
1477
+ }
1478
+ async stop() {
1479
+ this.running = false;
1480
+ if (this.client) {
1481
+ this.client.removeAllListeners();
1482
+ this.client.stop();
1483
+ this.client = null;
1484
+ }
1485
+ }
1486
+ async send(to, message) {
1487
+ const roomId = to.groupId ?? to.userId;
1488
+ if (!roomId) {
1489
+ return { success: false, error: "Recipient must have groupId (room ID) or userId" };
1490
+ }
1491
+ if (!this.client) {
1492
+ return { success: false, error: "Matrix client is not running" };
1493
+ }
1494
+ try {
1495
+ const content = {};
1496
+ if (message.format === "markdown" || message.format === "html") {
1497
+ content.msgtype = "m.notice";
1498
+ content.body = message.text;
1499
+ content.format = "org.matrix.custom.html";
1500
+ content.formatted_body = message.format === "html" ? message.text : this.markdownToSimpleHtml(message.text);
1501
+ } else {
1502
+ content.msgtype = "m.text";
1503
+ content.body = message.text;
1504
+ }
1505
+ if (message.replyTo) {
1506
+ content["m.relates_to"] = {
1507
+ "m.in_reply_to": { event_id: message.replyTo }
1508
+ };
1509
+ }
1510
+ const eventId = await this.client.sendMessage(roomId, content);
1511
+ if (message.attachments?.length) {
1512
+ for (const att of message.attachments) {
1513
+ await this.sendAttachment(roomId, att);
1514
+ }
1515
+ }
1516
+ return {
1517
+ success: true,
1518
+ messageId: eventId ?? generateId()
1519
+ };
1520
+ } catch (err) {
1521
+ return {
1522
+ success: false,
1523
+ error: err instanceof Error ? err.message : String(err)
1524
+ };
1525
+ }
1526
+ }
1527
+ /** Edit a previously sent message using Matrix m.replace relation. */
1528
+ async editMessage(to, messageId, message) {
1529
+ const roomId = to.groupId ?? to.userId;
1530
+ if (!roomId) {
1531
+ return { success: false, error: "Recipient must have groupId (room ID) or userId" };
1532
+ }
1533
+ if (!this.client) {
1534
+ return { success: false, error: "Matrix client is not running" };
1535
+ }
1536
+ const lastEdit = this.lastEditTimestamps.get(messageId);
1537
+ const now = Date.now();
1538
+ const MATRIX_EDIT_RATE_LIMIT_MS = 1e3;
1539
+ if (lastEdit && now - lastEdit < MATRIX_EDIT_RATE_LIMIT_MS) {
1540
+ return { success: true, messageId };
1541
+ }
1542
+ try {
1543
+ const content = {
1544
+ msgtype: "m.text",
1545
+ body: `* ${message.text}`
1546
+ };
1547
+ const newEventId = await this.client.editMessage(roomId, messageId, content);
1548
+ this.lastEditTimestamps.set(messageId, now);
1549
+ evictOldTimestamps(this.lastEditTimestamps, _MatrixChannel.EDIT_TS_MAX_ENTRIES);
1550
+ return { success: true, messageId: newEventId ?? messageId };
1551
+ } catch (err) {
1552
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1553
+ }
1554
+ }
1555
+ onMessage(handler) {
1556
+ this.messageHandler = handler;
1557
+ }
1558
+ onPresence(handler) {
1559
+ this.presenceHandler = handler;
1560
+ }
1561
+ async isHealthy() {
1562
+ if (!this.running || !this.client) return false;
1563
+ try {
1564
+ await this.client.getUserId();
1565
+ return true;
1566
+ } catch {
1567
+ return false;
1568
+ }
1569
+ }
1570
+ // -----------------------------------------------------------------------
1571
+ // Event processing
1572
+ // -----------------------------------------------------------------------
1573
+ processEvent(roomId, event) {
1574
+ if (!this.messageHandler) return;
1575
+ if (event.type !== "m.room.message") return;
1576
+ const sender = event.sender;
1577
+ if (sender === this.botUserId) return;
1578
+ if (this.allowedRooms.size > 0 && !this.allowedRooms.has(roomId)) {
1579
+ return;
1580
+ }
1581
+ if (this.allowedUsers.size > 0 && !this.allowedUsers.has(sender)) {
1582
+ return;
1583
+ }
1584
+ const content = event.content;
1585
+ const msgtype = content.msgtype;
1586
+ const text = content.body ?? "";
1587
+ const attachments = [];
1588
+ if (msgtype === "m.image" || msgtype === "m.audio" || msgtype === "m.video" || msgtype === "m.file") {
1589
+ attachments.push({
1590
+ type: this.classifyMsgtype(msgtype),
1591
+ url: content.url,
1592
+ mimeType: content.info?.mimetype,
1593
+ filename: content.filename ?? content.body
1594
+ });
1595
+ }
1596
+ if (!text && attachments.length === 0) return;
1597
+ const replyTo = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
1598
+ const inbound = {
1599
+ id: event.event_id,
1600
+ channelId: this.id,
1601
+ from: {
1602
+ channelId: this.id,
1603
+ userId: sender,
1604
+ groupId: roomId
1605
+ },
1606
+ text,
1607
+ attachments: attachments.length > 0 ? attachments : void 0,
1608
+ replyTo,
1609
+ timestamp: new Date(event.origin_server_ts),
1610
+ raw: event
1611
+ };
1612
+ this.messageHandler(inbound);
1613
+ }
1614
+ handleTypingEvent(roomId, event) {
1615
+ if (!this.presenceHandler) return;
1616
+ const content = event.content;
1617
+ const typingUsers = content.user_ids ?? [];
1618
+ for (const userId of typingUsers) {
1619
+ if (userId === this.botUserId) continue;
1620
+ this.presenceHandler({
1621
+ userId,
1622
+ status: "typing",
1623
+ channelId: roomId
1624
+ });
1625
+ }
1626
+ }
1627
+ // -----------------------------------------------------------------------
1628
+ // Helpers
1629
+ // -----------------------------------------------------------------------
1630
+ async sendAttachment(roomId, att) {
1631
+ if (!this.client) return;
1632
+ const msgtype = att.type === "image" ? "m.image" : att.type === "audio" ? "m.audio" : att.type === "video" ? "m.video" : "m.file";
1633
+ const content = {
1634
+ msgtype,
1635
+ body: att.filename ?? "attachment",
1636
+ url: att.url ?? ""
1637
+ };
1638
+ if (att.mimeType) {
1639
+ content.info = { mimetype: att.mimeType };
1640
+ }
1641
+ await this.client.sendMessage(roomId, content);
1642
+ }
1643
+ /**
1644
+ * Classify a Matrix msgtype to an Attachment type.
1645
+ * m.image -> 'image'
1646
+ * m.audio -> 'audio'
1647
+ * m.video -> 'video'
1648
+ * m.file -> 'file'
1649
+ */
1650
+ classifyMsgtype(msgtype) {
1651
+ switch (msgtype) {
1652
+ case "m.image":
1653
+ return "image";
1654
+ case "m.audio":
1655
+ return "audio";
1656
+ case "m.video":
1657
+ return "video";
1658
+ default:
1659
+ return "file";
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Convert simple markdown to basic HTML for formatted_body.
1664
+ * Handles bold, italic, code, and line breaks.
1665
+ */
1666
+ markdownToSimpleHtml(text) {
1667
+ 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>");
1668
+ }
1669
+ };
1670
+ var WA_MAX_MESSAGE_LEN = 4096;
1671
+ var WA_EDIT_RATE_LIMIT_MS = 1e3;
1672
+ var WhatsAppChannel = class _WhatsAppChannel {
1673
+ id = "whatsapp";
1674
+ name = "WhatsApp";
1675
+ accessToken = "";
1676
+ phoneNumberId = "";
1677
+ verifyToken = "";
1678
+ appSecret = "";
1679
+ apiVersion = "v21.0";
1680
+ allowedNumbers = /* @__PURE__ */ new Set();
1681
+ messageHandler = null;
1682
+ running = false;
1683
+ lastEditTimestamps = /* @__PURE__ */ new Map();
1684
+ static EDIT_TS_MAX_ENTRIES = 500;
1685
+ // -----------------------------------------------------------------------
1686
+ // IChannel implementation
1687
+ // -----------------------------------------------------------------------
1688
+ async start(config) {
1689
+ if (this.running) return;
1690
+ const cfg = config;
1691
+ if (!cfg.accessToken) {
1692
+ throw new Error('WhatsApp channel requires an "accessToken" in config');
1693
+ }
1694
+ if (!cfg.phoneNumberId) {
1695
+ throw new Error('WhatsApp channel requires a "phoneNumberId" in config');
1696
+ }
1697
+ if (!cfg.verifyToken) {
1698
+ throw new Error('WhatsApp channel requires a "verifyToken" in config');
1699
+ }
1700
+ this.accessToken = cfg.accessToken;
1701
+ this.phoneNumberId = cfg.phoneNumberId;
1702
+ this.verifyToken = cfg.verifyToken;
1703
+ this.appSecret = cfg.appSecret ?? "";
1704
+ this.apiVersion = cfg.apiVersion ?? "v21.0";
1705
+ this.allowedNumbers = new Set(cfg.allowedNumbers ?? []);
1706
+ this.running = true;
1707
+ }
1708
+ async stop() {
1709
+ this.running = false;
1710
+ }
1711
+ async send(to, message) {
1712
+ const recipient = to.userId;
1713
+ if (!recipient) {
1714
+ return { success: false, error: "Recipient must have a userId (phone number)" };
1715
+ }
1716
+ try {
1717
+ if (message.attachments?.length) {
1718
+ for (const att of message.attachments) {
1719
+ await this.sendAttachment(recipient, att);
1720
+ }
1721
+ }
1722
+ if (message.text) {
1723
+ const chunks = splitMessage(message.text, WA_MAX_MESSAGE_LEN);
1724
+ let lastId;
1725
+ for (const chunk of chunks) {
1726
+ const body = {
1727
+ messaging_product: "whatsapp",
1728
+ to: recipient,
1729
+ type: "text",
1730
+ text: { body: chunk },
1731
+ // Only attach reply context to the first chunk.
1732
+ ...!lastId && message.replyTo ? { context: { message_id: message.replyTo } } : {}
1733
+ };
1734
+ const result = await this.graphApiCall(
1735
+ `${this.phoneNumberId}/messages`,
1736
+ "POST",
1737
+ body
1738
+ );
1739
+ lastId = result?.messages?.[0]?.id ?? lastId;
1740
+ }
1741
+ return {
1742
+ success: true,
1743
+ messageId: lastId ?? generateId()
1744
+ };
1745
+ }
1746
+ return { success: true, messageId: generateId() };
1747
+ } catch (err) {
1748
+ return {
1749
+ success: false,
1750
+ error: err instanceof Error ? err.message : String(err)
1751
+ };
1752
+ }
1753
+ }
1754
+ /** Edit a previously sent message using WhatsApp Cloud API. */
1755
+ async editMessage(to, messageId, message) {
1756
+ const recipient = to.userId;
1757
+ if (!recipient) {
1758
+ return { success: false, error: "Recipient must have a userId (phone number)" };
1759
+ }
1760
+ const lastEdit = this.lastEditTimestamps.get(messageId);
1761
+ const now = Date.now();
1762
+ if (lastEdit && now - lastEdit < WA_EDIT_RATE_LIMIT_MS) {
1763
+ return { success: true, messageId };
1764
+ }
1765
+ try {
1766
+ const safeText = truncateMessage(message.text ?? "", WA_MAX_MESSAGE_LEN);
1767
+ await this.graphApiCall(
1768
+ `${this.phoneNumberId}/messages`,
1769
+ "POST",
1770
+ {
1771
+ messaging_product: "whatsapp",
1772
+ to: recipient,
1773
+ type: "text",
1774
+ text: { body: safeText },
1775
+ context: { message_id: messageId }
1776
+ }
1777
+ );
1778
+ this.lastEditTimestamps.set(messageId, now);
1779
+ evictOldTimestamps(this.lastEditTimestamps, _WhatsAppChannel.EDIT_TS_MAX_ENTRIES);
1780
+ return { success: true, messageId };
1781
+ } catch (err) {
1782
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1783
+ }
1784
+ }
1785
+ onMessage(handler) {
1786
+ this.messageHandler = handler;
1787
+ }
1788
+ onPresence(_handler) {
1789
+ }
1790
+ async isHealthy() {
1791
+ if (!this.running) return false;
1792
+ try {
1793
+ const data = await this.graphApiCall(
1794
+ this.phoneNumberId,
1795
+ "GET"
1796
+ );
1797
+ return data?.id !== void 0;
1798
+ } catch {
1799
+ return false;
1800
+ }
1801
+ }
1802
+ // -----------------------------------------------------------------------
1803
+ // Webhook handlers (called by the gateway)
1804
+ // -----------------------------------------------------------------------
1805
+ /**
1806
+ * Handle the GET webhook verification challenge from Meta.
1807
+ * Call this from a gateway route handler when receiving GET /webhook/whatsapp.
1808
+ *
1809
+ * Returns the challenge string to echo back, or null if verification fails.
1810
+ */
1811
+ handleWebhookVerification(query) {
1812
+ const mode = query["hub.mode"];
1813
+ const token = query["hub.verify_token"];
1814
+ const challenge = query["hub.challenge"];
1815
+ if (mode === "subscribe" && token === this.verifyToken) {
1816
+ return challenge ?? null;
1817
+ }
1818
+ return null;
1819
+ }
1820
+ /**
1821
+ * Process an incoming webhook POST payload from WhatsApp Cloud API.
1822
+ * Call this from a gateway route handler when receiving POST /webhook/whatsapp.
1823
+ */
1824
+ handleWebhookPayload(body) {
1825
+ if (body.object !== "whatsapp_business_account") return;
1826
+ if (!body.entry) return;
1827
+ for (const entry of body.entry) {
1828
+ if (!entry.changes) continue;
1829
+ for (const change of entry.changes) {
1830
+ const value = change.value;
1831
+ if (!value?.messages) continue;
1832
+ const contactNames = /* @__PURE__ */ new Map();
1833
+ if (value.contacts) {
1834
+ for (const contact of value.contacts) {
1835
+ if (contact.wa_id && contact.profile?.name) {
1836
+ contactNames.set(contact.wa_id, contact.profile.name);
1837
+ }
1838
+ }
1839
+ }
1840
+ for (const msg of value.messages) {
1841
+ this.processMessage(msg, contactNames);
1842
+ }
1843
+ }
1844
+ }
1845
+ }
1846
+ /**
1847
+ * Verify webhook payload signature using HMAC-SHA256 with the app secret.
1848
+ * The signature header from Meta is formatted as "sha256=<hex>".
1849
+ *
1850
+ * Uses lazy import of node:crypto (same pattern as Slack adapter).
1851
+ */
1852
+ async verifySignature(rawBody, signature) {
1853
+ if (!this.appSecret) return true;
1854
+ const { createHmac } = await import("crypto");
1855
+ const hash = createHmac("sha256", this.appSecret).update(rawBody).digest("hex");
1856
+ const expected = `sha256=${hash}`;
1857
+ return expected === signature;
1858
+ }
1859
+ // -----------------------------------------------------------------------
1860
+ // Media helpers
1861
+ // -----------------------------------------------------------------------
1862
+ /**
1863
+ * Download media by its Cloud API media ID.
1864
+ * Two-step process: first GET the media URL, then download the binary.
1865
+ *
1866
+ * Returns the raw Buffer and mime type, or null on failure.
1867
+ */
1868
+ async downloadMedia(mediaId) {
1869
+ try {
1870
+ const meta = await this.graphApiCall(mediaId, "GET");
1871
+ if (!meta?.url) return null;
1872
+ const response = await fetch(meta.url, {
1873
+ headers: { Authorization: `Bearer ${this.accessToken}` }
1874
+ });
1875
+ if (!response.ok) return null;
1876
+ const arrayBuf = await response.arrayBuffer();
1877
+ return {
1878
+ data: Buffer.from(arrayBuf),
1879
+ mimeType: meta.mime_type ?? "application/octet-stream"
1880
+ };
1881
+ } catch {
1882
+ return null;
1883
+ }
1884
+ }
1885
+ // -----------------------------------------------------------------------
1886
+ // Message processing
1887
+ // -----------------------------------------------------------------------
1888
+ processMessage(msg, _contactNames) {
1889
+ if (!this.messageHandler) return;
1890
+ const sender = msg.from;
1891
+ if (this.allowedNumbers.size > 0 && !this.allowedNumbers.has(sender)) {
1892
+ return;
1893
+ }
1894
+ const text = this.extractText(msg);
1895
+ const attachments = this.extractAttachments(msg);
1896
+ if (!text && attachments.length === 0) return;
1897
+ const inbound = {
1898
+ id: msg.id,
1899
+ channelId: this.id,
1900
+ from: {
1901
+ channelId: this.id,
1902
+ userId: sender
1903
+ },
1904
+ text,
1905
+ attachments: attachments.length > 0 ? attachments : void 0,
1906
+ replyTo: msg.context?.message_id,
1907
+ timestamp: new Date(parseInt(msg.timestamp, 10) * 1e3),
1908
+ raw: msg
1909
+ };
1910
+ this.messageHandler(inbound);
1911
+ }
1912
+ extractText(msg) {
1913
+ switch (msg.type) {
1914
+ case "text":
1915
+ return msg.text?.body ?? "";
1916
+ case "image":
1917
+ return msg.image?.caption ?? "";
1918
+ case "video":
1919
+ return msg.video?.caption ?? "";
1920
+ case "document":
1921
+ return msg.document?.caption ?? "";
1922
+ case "location": {
1923
+ const loc = msg.location;
1924
+ if (!loc) return "";
1925
+ const label = loc.name ? `${loc.name}: ` : "";
1926
+ return `${label}${loc.latitude},${loc.longitude}`;
1927
+ }
1928
+ default:
1929
+ return "";
1930
+ }
1931
+ }
1932
+ extractAttachments(msg) {
1933
+ const attachments = [];
1934
+ if (msg.image) {
1935
+ attachments.push({
1936
+ type: "image",
1937
+ url: msg.image.id,
1938
+ // Cloud API media ID — resolve via downloadMedia()
1939
+ mimeType: msg.image.mime_type
1940
+ });
1941
+ }
1942
+ if (msg.audio) {
1943
+ attachments.push({
1944
+ type: "audio",
1945
+ url: msg.audio.id,
1946
+ mimeType: msg.audio.mime_type
1947
+ });
1948
+ }
1949
+ if (msg.video) {
1950
+ attachments.push({
1951
+ type: "video",
1952
+ url: msg.video.id,
1953
+ mimeType: msg.video.mime_type
1954
+ });
1955
+ }
1956
+ if (msg.document) {
1957
+ attachments.push({
1958
+ type: "file",
1959
+ url: msg.document.id,
1960
+ mimeType: msg.document.mime_type,
1961
+ filename: msg.document.filename
1962
+ });
1963
+ }
1964
+ if (msg.sticker) {
1965
+ attachments.push({
1966
+ type: "image",
1967
+ url: msg.sticker.id,
1968
+ mimeType: msg.sticker.mime_type
1969
+ });
1970
+ }
1971
+ return attachments;
1972
+ }
1973
+ // -----------------------------------------------------------------------
1974
+ // Outbound attachment sending
1975
+ // -----------------------------------------------------------------------
1976
+ async sendAttachment(recipient, att) {
1977
+ if (att.data) {
1978
+ const mediaId = await this.uploadMedia(att);
1979
+ if (!mediaId) return;
1980
+ const waType = this.attachmentTypeToWaType(att.type);
1981
+ const body = {
1982
+ messaging_product: "whatsapp",
1983
+ to: recipient,
1984
+ type: waType,
1985
+ [waType]: { id: mediaId }
1986
+ };
1987
+ await this.graphApiCall(`${this.phoneNumberId}/messages`, "POST", body);
1988
+ return;
1989
+ }
1990
+ if (att.url) {
1991
+ const waType = this.attachmentTypeToWaType(att.type);
1992
+ const mediaObj = { link: att.url };
1993
+ if (att.filename) mediaObj.filename = att.filename;
1994
+ const body = {
1995
+ messaging_product: "whatsapp",
1996
+ to: recipient,
1997
+ type: waType,
1998
+ [waType]: mediaObj
1999
+ };
2000
+ await this.graphApiCall(`${this.phoneNumberId}/messages`, "POST", body);
2001
+ }
2002
+ }
2003
+ attachmentTypeToWaType(type) {
2004
+ switch (type) {
2005
+ case "image":
2006
+ return "image";
2007
+ case "audio":
2008
+ return "audio";
2009
+ case "video":
2010
+ return "video";
2011
+ case "file":
2012
+ return "document";
2013
+ }
2014
+ }
2015
+ /**
2016
+ * Upload binary media to Meta's media endpoint.
2017
+ * Returns the media ID on success, or null on failure.
2018
+ */
2019
+ async uploadMedia(att) {
2020
+ if (!att.data) return null;
2021
+ try {
2022
+ const url = `https://graph.facebook.com/${this.apiVersion}/${this.phoneNumberId}/media`;
2023
+ const boundary = `----ch4p${Date.now()}${Math.random().toString(36).slice(2)}`;
2024
+ const mimeType = att.mimeType ?? "application/octet-stream";
2025
+ const filename = att.filename ?? "upload";
2026
+ const preamble = `--${boundary}\r
2027
+ Content-Disposition: form-data; name="messaging_product"\r
2028
+ \r
2029
+ whatsapp\r
2030
+ --${boundary}\r
2031
+ Content-Disposition: form-data; name="type"\r
2032
+ \r
2033
+ ${mimeType}\r
2034
+ --${boundary}\r
2035
+ Content-Disposition: form-data; name="file"; filename="${filename}"\r
2036
+ Content-Type: ${mimeType}\r
2037
+ \r
2038
+ `;
2039
+ const epilogue = `\r
2040
+ --${boundary}--\r
2041
+ `;
2042
+ const preambleBuf = Buffer.from(preamble, "utf-8");
2043
+ const epilogueBuf = Buffer.from(epilogue, "utf-8");
2044
+ const bodyBuf = Buffer.concat([preambleBuf, att.data, epilogueBuf]);
2045
+ const response = await fetch(url, {
2046
+ method: "POST",
2047
+ headers: {
2048
+ "Authorization": `Bearer ${this.accessToken}`,
2049
+ "Content-Type": `multipart/form-data; boundary=${boundary}`
2050
+ },
2051
+ body: bodyBuf
2052
+ });
2053
+ const data = await response.json();
2054
+ return data.id ?? null;
2055
+ } catch {
2056
+ return null;
2057
+ }
2058
+ }
2059
+ // -----------------------------------------------------------------------
2060
+ // Graph API helper
2061
+ // -----------------------------------------------------------------------
2062
+ async graphApiCall(endpoint, method, body) {
2063
+ const url = `https://graph.facebook.com/${this.apiVersion}/${endpoint}`;
2064
+ const headers = {
2065
+ "Authorization": `Bearer ${this.accessToken}`
2066
+ };
2067
+ const init = { method, headers };
2068
+ if (body && method === "POST") {
2069
+ headers["Content-Type"] = "application/json";
2070
+ init.body = JSON.stringify(body);
2071
+ }
2072
+ const response = await fetch(url, init);
2073
+ const data = await response.json();
2074
+ if (data.error) {
2075
+ const err = data.error;
2076
+ throw new Error(`WhatsApp API error: ${err.message ?? "Unknown error"} (code ${err.code})`);
2077
+ }
2078
+ return data;
2079
+ }
2080
+ };
2081
+ var SignalChannel = class _SignalChannel {
2082
+ id = "signal";
2083
+ name = "Signal";
2084
+ account = "";
2085
+ host = "localhost";
2086
+ port = 7583;
2087
+ reconnectInterval = 5e3;
2088
+ messageHandler = null;
2089
+ running = false;
2090
+ socket = null;
2091
+ reconnectTimer = null;
2092
+ allowedNumbers = /* @__PURE__ */ new Set();
2093
+ buffer = "";
2094
+ nextRequestId = 1;
2095
+ pendingRequests = /* @__PURE__ */ new Map();
2096
+ lastEditTimestamps = /* @__PURE__ */ new Map();
2097
+ static EDIT_TS_MAX_ENTRIES = 500;
2098
+ // -----------------------------------------------------------------------
2099
+ // IChannel implementation
2100
+ // -----------------------------------------------------------------------
2101
+ async start(config) {
2102
+ if (this.running) return;
2103
+ const cfg = config;
2104
+ if (!cfg.account) {
2105
+ throw new Error('Signal channel requires an "account" (phone number) in config');
2106
+ }
2107
+ this.account = cfg.account;
2108
+ this.host = cfg.host ?? "localhost";
2109
+ this.port = cfg.port ?? 7583;
2110
+ this.reconnectInterval = cfg.reconnectInterval ?? 5e3;
2111
+ this.allowedNumbers = new Set(cfg.allowedNumbers ?? []);
2112
+ await this.connect();
2113
+ this.running = true;
2114
+ try {
2115
+ await this.rpcCall("listContacts", { account: this.account });
2116
+ } catch {
2117
+ }
2118
+ }
2119
+ async stop() {
2120
+ this.running = false;
2121
+ if (this.reconnectTimer) {
2122
+ clearTimeout(this.reconnectTimer);
2123
+ this.reconnectTimer = null;
2124
+ }
2125
+ for (const [id, pending] of this.pendingRequests) {
2126
+ pending.reject(new Error("Channel stopped"));
2127
+ this.pendingRequests.delete(id);
2128
+ }
2129
+ if (this.socket) {
2130
+ try {
2131
+ this.socket.destroy();
2132
+ } catch {
2133
+ }
2134
+ this.socket = null;
2135
+ }
2136
+ this.buffer = "";
2137
+ }
2138
+ async send(to, message) {
2139
+ const recipient = to.userId ?? to.groupId;
2140
+ if (!recipient) {
2141
+ return { success: false, error: "Recipient must have userId (phone number) or groupId" };
2142
+ }
2143
+ try {
2144
+ const params = {
2145
+ account: this.account,
2146
+ message: message.text
2147
+ };
2148
+ if (to.groupId) {
2149
+ params.groupId = to.groupId;
2150
+ } else {
2151
+ params.recipient = [to.userId];
2152
+ }
2153
+ if (message.replyTo) {
2154
+ params.quoteTimestamp = Number(message.replyTo);
2155
+ }
2156
+ const result = await this.rpcCall("send", params);
2157
+ const resultObj = result;
2158
+ return {
2159
+ success: true,
2160
+ messageId: resultObj?.timestamp ? String(resultObj.timestamp) : generateId()
2161
+ };
2162
+ } catch (err) {
2163
+ return {
2164
+ success: false,
2165
+ error: err instanceof Error ? err.message : String(err)
2166
+ };
2167
+ }
2168
+ }
2169
+ /** Edit a previously sent message via signal-cli editMessage RPC. */
2170
+ async editMessage(to, messageId, message) {
2171
+ const recipient = to.userId ?? to.groupId;
2172
+ if (!recipient) {
2173
+ return { success: false, error: "Recipient must have userId or groupId" };
2174
+ }
2175
+ const lastEdit = this.lastEditTimestamps.get(messageId);
2176
+ const now = Date.now();
2177
+ const SIGNAL_EDIT_RATE_LIMIT_MS = 1e3;
2178
+ if (lastEdit && now - lastEdit < SIGNAL_EDIT_RATE_LIMIT_MS) {
2179
+ return { success: true, messageId };
2180
+ }
2181
+ try {
2182
+ const params = {
2183
+ account: this.account,
2184
+ targetTimestamp: Number(messageId),
2185
+ message: message.text
2186
+ };
2187
+ if (to.groupId) {
2188
+ params.groupId = to.groupId;
2189
+ } else {
2190
+ params.recipient = [to.userId];
2191
+ }
2192
+ await this.rpcCall("editMessage", params);
2193
+ this.lastEditTimestamps.set(messageId, now);
2194
+ evictOldTimestamps(this.lastEditTimestamps, _SignalChannel.EDIT_TS_MAX_ENTRIES);
2195
+ return { success: true, messageId };
2196
+ } catch (err) {
2197
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
2198
+ }
2199
+ }
2200
+ onMessage(handler) {
2201
+ this.messageHandler = handler;
2202
+ }
2203
+ onPresence(_handler) {
2204
+ }
2205
+ async isHealthy() {
2206
+ if (!this.running) return false;
2207
+ if (!this.socket || this.socket.destroyed) return false;
2208
+ try {
2209
+ await this.rpcCall("listContacts", { account: this.account });
2210
+ return true;
2211
+ } catch {
2212
+ return false;
2213
+ }
2214
+ }
2215
+ // -----------------------------------------------------------------------
2216
+ // TCP connection management
2217
+ // -----------------------------------------------------------------------
2218
+ connect() {
2219
+ return new Promise((resolve, reject) => {
2220
+ this.buffer = "";
2221
+ this.socket = new Socket();
2222
+ let connected = false;
2223
+ this.socket.on("connect", () => {
2224
+ connected = true;
2225
+ resolve();
2226
+ });
2227
+ this.socket.on("data", (chunk) => {
2228
+ this.onData(chunk);
2229
+ });
2230
+ this.socket.on("error", (err) => {
2231
+ if (!connected) {
2232
+ reject(new Error(`Failed to connect to signal-cli at ${this.host}:${this.port}: ${err.message}`));
2233
+ }
2234
+ });
2235
+ this.socket.on("close", () => {
2236
+ this.socket = null;
2237
+ for (const [id, pending] of this.pendingRequests) {
2238
+ pending.reject(new Error("Socket closed"));
2239
+ this.pendingRequests.delete(id);
2240
+ }
2241
+ if (this.running) {
2242
+ this.scheduleReconnect();
2243
+ }
2244
+ });
2245
+ this.socket.connect(this.port, this.host);
2246
+ });
2247
+ }
2248
+ scheduleReconnect() {
2249
+ if (this.reconnectTimer) return;
2250
+ this.reconnectTimer = setTimeout(async () => {
2251
+ this.reconnectTimer = null;
2252
+ if (!this.running) return;
2253
+ try {
2254
+ await this.connect();
2255
+ } catch {
2256
+ if (this.running) {
2257
+ this.scheduleReconnect();
2258
+ }
2259
+ }
2260
+ }, this.reconnectInterval);
2261
+ }
2262
+ // -----------------------------------------------------------------------
2263
+ // Line-delimited JSON-RPC handling
2264
+ // -----------------------------------------------------------------------
2265
+ onData(chunk) {
2266
+ this.buffer += chunk.toString("utf-8");
2267
+ let newlineIdx;
2268
+ while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
2269
+ const line = this.buffer.slice(0, newlineIdx).trim();
2270
+ this.buffer = this.buffer.slice(newlineIdx + 1);
2271
+ if (line.length === 0) continue;
2272
+ try {
2273
+ const parsed = JSON.parse(line);
2274
+ if ("id" in parsed && typeof parsed.id === "number") {
2275
+ this.handleResponse(parsed);
2276
+ } else if ("method" in parsed) {
2277
+ this.handleNotification(parsed);
2278
+ }
2279
+ } catch {
2280
+ }
2281
+ }
2282
+ }
2283
+ handleResponse(response) {
2284
+ const pending = this.pendingRequests.get(response.id);
2285
+ if (!pending) return;
2286
+ this.pendingRequests.delete(response.id);
2287
+ if (response.error) {
2288
+ pending.reject(new Error(
2289
+ `JSON-RPC error ${response.error.code}: ${response.error.message}`
2290
+ ));
2291
+ } else {
2292
+ pending.resolve(response.result);
2293
+ }
2294
+ }
2295
+ handleNotification(notification) {
2296
+ if (notification.method === "receive") {
2297
+ this.processReceiveNotification(notification.params);
2298
+ }
2299
+ }
2300
+ // -----------------------------------------------------------------------
2301
+ // Message processing
2302
+ // -----------------------------------------------------------------------
2303
+ processReceiveNotification(params) {
2304
+ if (!this.messageHandler) return;
2305
+ const envelope = params.envelope;
2306
+ if (!envelope) return;
2307
+ const sourceNumber = envelope.sourceNumber;
2308
+ if (!sourceNumber) return;
2309
+ if (this.allowedNumbers.size > 0 && !this.allowedNumbers.has(sourceNumber)) {
2310
+ return;
2311
+ }
2312
+ const dataMessage = envelope.dataMessage;
2313
+ if (!dataMessage) return;
2314
+ const text = dataMessage.message ?? "";
2315
+ if (!text && (!dataMessage.attachments || dataMessage.attachments.length === 0)) {
2316
+ return;
2317
+ }
2318
+ const attachments = [];
2319
+ if (dataMessage.attachments) {
2320
+ for (const att of dataMessage.attachments) {
2321
+ attachments.push({
2322
+ type: this.classifyAttachment(att.contentType ?? ""),
2323
+ url: att.id,
2324
+ filename: att.filename,
2325
+ mimeType: att.contentType
2326
+ });
2327
+ }
2328
+ }
2329
+ const timestamp = dataMessage.timestamp ?? envelope.timestamp ?? Date.now();
2330
+ const inbound = {
2331
+ id: String(timestamp),
2332
+ channelId: this.id,
2333
+ from: {
2334
+ channelId: this.id,
2335
+ userId: sourceNumber,
2336
+ groupId: dataMessage.groupInfo?.groupId
2337
+ },
2338
+ text,
2339
+ attachments: attachments.length > 0 ? attachments : void 0,
2340
+ replyTo: dataMessage.quote?.id ? String(dataMessage.quote.id) : void 0,
2341
+ timestamp: new Date(timestamp),
2342
+ raw: params
2343
+ };
2344
+ this.messageHandler(inbound);
2345
+ }
2346
+ // -----------------------------------------------------------------------
2347
+ // JSON-RPC helpers
2348
+ // -----------------------------------------------------------------------
2349
+ rpcCall(method, params) {
2350
+ return new Promise((resolve, reject) => {
2351
+ if (!this.socket || this.socket.destroyed) {
2352
+ reject(new Error("Not connected to signal-cli daemon"));
2353
+ return;
2354
+ }
2355
+ const id = this.nextRequestId++;
2356
+ const request = {
2357
+ jsonrpc: "2.0",
2358
+ id,
2359
+ method,
2360
+ params
2361
+ };
2362
+ this.pendingRequests.set(id, { resolve, reject });
2363
+ const payload = JSON.stringify(request) + "\n";
2364
+ this.socket.write(payload, "utf-8", (err) => {
2365
+ if (err) {
2366
+ this.pendingRequests.delete(id);
2367
+ reject(new Error(`Failed to write to signal-cli socket: ${err.message}`));
2368
+ }
2369
+ });
2370
+ });
2371
+ }
2372
+ classifyAttachment(contentType) {
2373
+ if (contentType.startsWith("image/")) return "image";
2374
+ if (contentType.startsWith("audio/")) return "audio";
2375
+ if (contentType.startsWith("video/")) return "video";
2376
+ return "file";
2377
+ }
2378
+ };
2379
+ var execFile = promisify(execFileCb);
2380
+ var IMESSAGE_EPOCH_MS = Date.UTC(2001, 0, 1);
2381
+ function imessageDateToJS(nanoseconds) {
2382
+ return new Date(IMESSAGE_EPOCH_MS + nanoseconds / 1e6);
2383
+ }
2384
+ function classifyMimeType(mime) {
2385
+ if (!mime) return "file";
2386
+ if (mime.startsWith("image/")) return "image";
2387
+ if (mime.startsWith("audio/")) return "audio";
2388
+ if (mime.startsWith("video/")) return "video";
2389
+ return "file";
2390
+ }
2391
+ var TAPBACK_SEND_MAP = {
2392
+ love: 0,
2393
+ heart: 0,
2394
+ like: 1,
2395
+ thumbsup: 1,
2396
+ dislike: 2,
2397
+ thumbsdown: 2,
2398
+ laugh: 3,
2399
+ haha: 3,
2400
+ emphasis: 4,
2401
+ exclamation: 4,
2402
+ question: 5
2403
+ };
2404
+ var TAPBACK_TYPES = {
2405
+ 2e3: { name: "love", isRemove: false },
2406
+ 2001: { name: "like", isRemove: false },
2407
+ 2002: { name: "dislike", isRemove: false },
2408
+ 2003: { name: "laugh", isRemove: false },
2409
+ 2004: { name: "emphasis", isRemove: false },
2410
+ 2005: { name: "question", isRemove: false },
2411
+ 3e3: { name: "love", isRemove: true },
2412
+ 3001: { name: "like", isRemove: true },
2413
+ 3002: { name: "dislike", isRemove: true },
2414
+ 3003: { name: "laugh", isRemove: true },
2415
+ 3004: { name: "emphasis", isRemove: true },
2416
+ 3005: { name: "question", isRemove: true }
2417
+ };
2418
+ function isReaction(associatedMessageType) {
2419
+ if (associatedMessageType === null || associatedMessageType === 0) return false;
2420
+ return associatedMessageType in TAPBACK_TYPES;
2421
+ }
2422
+ var JXA_SIDEBAR_PATH = "proc.windows[0].splitterGroups[0].scrollAreas[0]";
2423
+ var JXA_MSG_AREA_PATH = "proc.windows[0].splitterGroups[0].scrollAreas[1]";
2424
+ var JXA_MSG_AREA_ALT = "proc.windows[0].splitterGroups[0].scrollAreas[0]";
2425
+ var IMessageChannel = class {
2426
+ id = "imessage";
2427
+ name = "iMessage";
2428
+ messageHandler = null;
2429
+ running = false;
2430
+ pollTimer = null;
2431
+ pollInterval = 2e3;
2432
+ lastRowId = 0;
2433
+ dbPath = "";
2434
+ allowedHandles = /* @__PURE__ */ new Set();
2435
+ /** macOS version string (e.g. "15.1") for diagnostic error messages. Null if detection failed. */
2436
+ macOSVersion = null;
2437
+ // -----------------------------------------------------------------------
2438
+ // IChannel implementation
2439
+ // -----------------------------------------------------------------------
2440
+ async start(config) {
2441
+ if (this.running) return;
2442
+ if (process.platform !== "darwin") {
2443
+ throw new Error(
2444
+ `IMessageChannel is macOS-only. Current platform "${process.platform}" is not supported.`
2445
+ );
2446
+ }
2447
+ const cfg = config;
2448
+ try {
2449
+ await execFile("which", ["sqlite3"]);
2450
+ } catch {
2451
+ throw new Error(
2452
+ "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."
2453
+ );
2454
+ }
2455
+ this.pollInterval = Math.max(100, cfg.pollInterval ?? 2e3);
2456
+ this.allowedHandles = new Set(cfg.allowedHandles ?? []);
2457
+ this.dbPath = cfg.dbPath ?? `${homedir()}/Library/Messages/chat.db`;
2458
+ try {
2459
+ const { stdout } = await execFile("sqlite3", [
2460
+ "-json",
2461
+ this.dbPath,
2462
+ "SELECT MAX(ROWID) as max_id FROM message;"
2463
+ ]);
2464
+ const rows = JSON.parse(stdout || "[]");
2465
+ this.lastRowId = rows[0]?.max_id ?? 0;
2466
+ } catch (err) {
2467
+ const message = err instanceof Error ? err.message : String(err);
2468
+ if (message.includes("unable to open database") || message.includes("permission denied") || message.includes("not authorized")) {
2469
+ throw new Error(
2470
+ `Cannot read iMessage database. Grant Full Disk Access to your terminal:
2471
+ System Settings > Privacy & Security > Full Disk Access
2472
+ Database path: ${this.dbPath}`
2473
+ );
2474
+ }
2475
+ throw new Error(`Failed to query iMessage database: ${message}`);
2476
+ }
2477
+ this.running = true;
2478
+ try {
2479
+ const { stdout: versionOut } = await execFile("sw_vers", ["-productVersion"]);
2480
+ this.macOSVersion = versionOut.trim() || null;
2481
+ } catch {
2482
+ this.macOSVersion = null;
2483
+ }
2484
+ this.startPolling();
2485
+ }
2486
+ async stop() {
2487
+ this.running = false;
2488
+ if (this.pollTimer) {
2489
+ clearTimeout(this.pollTimer);
2490
+ this.pollTimer = null;
2491
+ }
2492
+ }
2493
+ async send(to, message) {
2494
+ const handle = to.userId;
2495
+ const group = to.groupId;
2496
+ if (!handle && !group) {
2497
+ return { success: false, error: "Recipient must have userId (handle) or groupId (chat name)" };
2498
+ }
2499
+ try {
2500
+ const escapedText = message.text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
2501
+ let jxa;
2502
+ if (group) {
2503
+ const escapedGroup = group.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2504
+ jxa = [
2505
+ 'const app = Application("Messages");',
2506
+ `const chat = app.chats.whose({name: "${escapedGroup}"})[0];`,
2507
+ `app.send("${escapedText}", {to: chat});`
2508
+ ].join("\n");
2509
+ } else {
2510
+ const escapedHandle = handle.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2511
+ jxa = [
2512
+ 'const app = Application("Messages");',
2513
+ `const buddy = app.buddies.whose({handle: "${escapedHandle}"})[0];`,
2514
+ `app.send("${escapedText}", {to: buddy});`
2515
+ ].join("\n");
2516
+ }
2517
+ await execFile("osascript", ["-l", "JavaScript", "-e", jxa]);
2518
+ return {
2519
+ success: true,
2520
+ messageId: generateId()
2521
+ };
2522
+ } catch (err) {
2523
+ const errMsg = err instanceof Error ? err.message : String(err);
2524
+ if (errMsg.includes("not authorized") || errMsg.includes("assistive access")) {
2525
+ return {
2526
+ success: false,
2527
+ error: "Automation permission denied. Allow your terminal to control Messages.app:\nSystem Settings > Privacy & Security > Automation"
2528
+ };
2529
+ }
2530
+ return {
2531
+ success: false,
2532
+ error: errMsg
2533
+ };
2534
+ }
2535
+ }
2536
+ /**
2537
+ * Send a tapback reaction to a specific message via JXA UI scripting.
2538
+ *
2539
+ * Uses System Events accessibility API to:
2540
+ * 1. Look up the message text + chat identifier from chat.db by GUID.
2541
+ * 2. Focus the conversation in Messages.app.
2542
+ * 3. Right-click the target message bubble.
2543
+ * 4. Select the tapback from the context menu.
2544
+ *
2545
+ * Requires macOS 13+ and Accessibility permission for your terminal in
2546
+ * System Settings > Privacy & Security > Accessibility.
2547
+ *
2548
+ * Valid reactionTypes: love, heart, like, thumbsup, dislike, thumbsdown,
2549
+ * laugh, haha, emphasis, exclamation, question.
2550
+ */
2551
+ async sendReaction(_to, messageGuid, reactionType) {
2552
+ const reactionIndex = TAPBACK_SEND_MAP[reactionType.toLowerCase()];
2553
+ if (reactionIndex === void 0) {
2554
+ const valid = Object.keys(TAPBACK_SEND_MAP).join(", ");
2555
+ return {
2556
+ success: false,
2557
+ error: `Unknown reaction type "${reactionType}". Valid types: ${valid}.`
2558
+ };
2559
+ }
2560
+ const info = await this.getMessageInfo(messageGuid);
2561
+ if (!info) {
2562
+ return {
2563
+ success: false,
2564
+ error: `Message with GUID "${messageGuid}" not found in chat.db.`
2565
+ };
2566
+ }
2567
+ const jxa = buildTapbackScript(info.chatIdentifier, info.text, reactionIndex);
2568
+ try {
2569
+ const { stdout: jxaOut } = await execFile("osascript", ["-l", "JavaScript", "-e", jxa]);
2570
+ const out = jxaOut.trim();
2571
+ if (out.startsWith("react_menu_error:") || out === "message_not_found") {
2572
+ return {
2573
+ success: false,
2574
+ error: `${out} (macOS ${this.macOSVersion ?? "unknown"})`
2575
+ };
2576
+ }
2577
+ return { success: true };
2578
+ } catch (err) {
2579
+ const errMsg = err instanceof Error ? err.message : String(err);
2580
+ if (errMsg.includes("not authorized") || errMsg.includes("assistive access")) {
2581
+ return {
2582
+ success: false,
2583
+ error: "Accessibility permission denied. Allow your terminal in System Settings > Privacy & Security > Accessibility."
2584
+ };
2585
+ }
2586
+ return { success: false, error: `${errMsg} (macOS ${this.macOSVersion ?? "unknown"})` };
2587
+ }
2588
+ }
2589
+ /**
2590
+ * Look up the plain text and chat_identifier for a message by GUID from chat.db.
2591
+ * Returns null if the message is not found or the query fails.
2592
+ */
2593
+ async getMessageInfo(guid) {
2594
+ if (!/^[A-Za-z0-9\-:/]+$/.test(guid)) {
2595
+ return null;
2596
+ }
2597
+ const safeGuid = guid.replace(/'/g, "''");
2598
+ const sql = `
2599
+ SELECT m.text, c.chat_identifier
2600
+ FROM message m
2601
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
2602
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
2603
+ WHERE m.guid = '${safeGuid}'
2604
+ LIMIT 1
2605
+ `;
2606
+ try {
2607
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, sql]);
2608
+ const rows = JSON.parse(stdout || "[]");
2609
+ const row = rows[0];
2610
+ if (!row) return null;
2611
+ return {
2612
+ text: row.text ?? "",
2613
+ chatIdentifier: row.chat_identifier ?? ""
2614
+ };
2615
+ } catch {
2616
+ return null;
2617
+ }
2618
+ }
2619
+ onMessage(handler) {
2620
+ this.messageHandler = handler;
2621
+ }
2622
+ onPresence(_handler) {
2623
+ }
2624
+ async isHealthy() {
2625
+ if (!this.running) return false;
2626
+ try {
2627
+ await execFile("sqlite3", [this.dbPath, "SELECT 1;"]);
2628
+ return true;
2629
+ } catch {
2630
+ return false;
2631
+ }
2632
+ }
2633
+ // -----------------------------------------------------------------------
2634
+ // Polling
2635
+ // -----------------------------------------------------------------------
2636
+ startPolling() {
2637
+ const poll = async () => {
2638
+ if (!this.running) return;
2639
+ try {
2640
+ await this.pollMessages();
2641
+ } catch {
2642
+ }
2643
+ if (this.running) {
2644
+ this.pollTimer = setTimeout(poll, this.pollInterval);
2645
+ }
2646
+ };
2647
+ this.pollTimer = setTimeout(poll, 0);
2648
+ }
2649
+ async pollMessages() {
2650
+ if (!this.messageHandler) return;
2651
+ const query = [
2652
+ "SELECT m.ROWID, m.text, m.date, h.id as handle, m.is_from_me,",
2653
+ " m.cache_has_attachments,",
2654
+ " m.associated_message_type, m.associated_message_guid,",
2655
+ " m.thread_originator_guid, m.destination_caller_id,",
2656
+ " c.chat_identifier, c.display_name",
2657
+ "FROM message m",
2658
+ "JOIN handle h ON m.handle_id = h.ROWID",
2659
+ "LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id",
2660
+ "LEFT JOIN chat c ON cmj.chat_id = c.ROWID",
2661
+ `WHERE m.ROWID > ${this.lastRowId} AND m.is_from_me = 0`,
2662
+ "ORDER BY m.ROWID ASC;"
2663
+ ].join(" ");
2664
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, query]);
2665
+ if (!stdout || stdout.trim() === "" || stdout.trim() === "[]") return;
2666
+ let rows;
2667
+ try {
2668
+ rows = JSON.parse(stdout);
2669
+ } catch {
2670
+ return;
2671
+ }
2672
+ for (const row of rows) {
2673
+ if (row.ROWID > this.lastRowId) {
2674
+ this.lastRowId = row.ROWID;
2675
+ }
2676
+ if (this.allowedHandles.size > 0 && !this.allowedHandles.has(row.handle)) {
2677
+ continue;
2678
+ }
2679
+ const inbound = await this.processRow(row);
2680
+ if (inbound) {
2681
+ this.messageHandler(inbound);
2682
+ }
2683
+ }
2684
+ }
2685
+ // -----------------------------------------------------------------------
2686
+ // Message processing
2687
+ // -----------------------------------------------------------------------
2688
+ async processRow(row) {
2689
+ if (isReaction(row.associated_message_type)) {
2690
+ const tapback = TAPBACK_TYPES[row.associated_message_type];
2691
+ if (!tapback) return null;
2692
+ let targetGuid = row.associated_message_guid ?? "";
2693
+ const guidMatch = targetGuid.match(/^(?:p:\d+\/|bp:)(.+)$/);
2694
+ if (guidMatch) {
2695
+ targetGuid = guidMatch[1];
2696
+ }
2697
+ return {
2698
+ id: String(row.ROWID),
2699
+ channelId: this.id,
2700
+ from: {
2701
+ channelId: this.id,
2702
+ userId: row.handle,
2703
+ groupId: row.chat_identifier ?? void 0
2704
+ },
2705
+ text: tapback.isRemove ? `[Removed ${tapback.name} reaction]` : `[${tapback.name} reaction]`,
2706
+ replyTo: targetGuid || void 0,
2707
+ timestamp: imessageDateToJS(row.date),
2708
+ raw: {
2709
+ ...row,
2710
+ reaction: true,
2711
+ reactionType: tapback.name,
2712
+ reactionRemoved: tapback.isRemove
2713
+ }
2714
+ };
2715
+ }
2716
+ const text = row.text ?? "";
2717
+ if (!text && !row.cache_has_attachments) return null;
2718
+ let attachments;
2719
+ if (row.cache_has_attachments) {
2720
+ attachments = await this.fetchAttachments(row.ROWID);
2721
+ if (attachments.length === 0) attachments = void 0;
2722
+ }
2723
+ if (!text && !attachments) return null;
2724
+ const isGroup = row.chat_identifier ? row.chat_identifier.startsWith("chat") : false;
2725
+ return {
2726
+ id: String(row.ROWID),
2727
+ channelId: this.id,
2728
+ from: {
2729
+ channelId: this.id,
2730
+ userId: row.handle,
2731
+ groupId: isGroup ? row.chat_identifier ?? void 0 : void 0
2732
+ },
2733
+ text,
2734
+ attachments,
2735
+ // Thread context: if this message is a reply in a thread.
2736
+ replyTo: row.thread_originator_guid ?? void 0,
2737
+ timestamp: imessageDateToJS(row.date),
2738
+ raw: {
2739
+ ...row,
2740
+ destination_caller_id: row.destination_caller_id,
2741
+ display_name: row.display_name
2742
+ }
2743
+ };
2744
+ }
2745
+ // -----------------------------------------------------------------------
2746
+ // Attachment handling
2747
+ // -----------------------------------------------------------------------
2748
+ async fetchAttachments(messageRowId) {
2749
+ const query = [
2750
+ "SELECT a.filename, a.mime_type, a.uti",
2751
+ "FROM attachment a",
2752
+ "JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id",
2753
+ `WHERE maj.message_id = ${messageRowId};`
2754
+ ].join(" ");
2755
+ try {
2756
+ const { stdout } = await execFile("sqlite3", ["-json", this.dbPath, query]);
2757
+ if (!stdout || stdout.trim() === "" || stdout.trim() === "[]") return [];
2758
+ const rows = JSON.parse(stdout);
2759
+ return rows.map((att) => {
2760
+ let filepath = att.filename ?? void 0;
2761
+ if (filepath?.startsWith("~/")) {
2762
+ filepath = `${process.env.HOME}${filepath.slice(1)}`;
2763
+ }
2764
+ return {
2765
+ type: classifyMimeType(att.mime_type),
2766
+ url: filepath ? `file://${filepath}` : void 0,
2767
+ filename: filepath?.split("/").pop(),
2768
+ mimeType: att.mime_type ?? void 0
2769
+ };
2770
+ });
2771
+ } catch {
2772
+ return [];
2773
+ }
2774
+ }
2775
+ };
2776
+ function buildTapbackScript(chatIdentifier, messageText, reactionIndex) {
2777
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\x00/g, "");
2778
+ return `(function() {
2779
+ const app = Application("Messages");
2780
+ app.activate();
2781
+ delay(0.5);
2782
+ const se = Application("System Events");
2783
+ const proc = se.processes.byName("Messages");
2784
+
2785
+ // Select the conversation in the sidebar.
2786
+ try {
2787
+ const sidebar = ${JXA_SIDEBAR_PATH};
2788
+ const rows = sidebar.tables[0].rows();
2789
+ for (const row of rows) {
2790
+ try {
2791
+ const label = row.staticTexts[0].value();
2792
+ if (label && label.includes("${esc(chatIdentifier)}")) {
2793
+ row.select();
2794
+ break;
2795
+ }
2796
+ } catch (_) {}
2797
+ }
2798
+ } catch (_) {}
2799
+ delay(0.3);
2800
+
2801
+ // Find the message bubble \u2014 primary path (macOS 13\u201314).
2802
+ let msgEl = null;
2803
+ const targetText = "${esc(messageText.slice(0, 40))}";
2804
+ try {
2805
+ const msgArea = ${JXA_MSG_AREA_PATH};
2806
+ const groups = msgArea.groups();
2807
+ for (const g of groups) {
2808
+ try {
2809
+ const txt = g.staticTexts[0].value();
2810
+ if (txt && txt.includes(targetText)) {
2811
+ msgEl = g;
2812
+ }
2813
+ } catch (_) {}
2814
+ }
2815
+ } catch (_) {}
2816
+
2817
+ // Fallback path: try alternate scroll area if primary search found nothing.
2818
+ if (!msgEl) {
2819
+ try {
2820
+ const msgAreaAlt = ${JXA_MSG_AREA_ALT};
2821
+ const altGroups = msgAreaAlt.groups();
2822
+ for (const g of altGroups) {
2823
+ try {
2824
+ const txt = g.staticTexts[0].value();
2825
+ if (txt && txt.includes(targetText)) {
2826
+ msgEl = g;
2827
+ }
2828
+ } catch (_) {}
2829
+ }
2830
+ } catch (_) {}
2831
+ }
2832
+
2833
+ if (!msgEl) return "message_not_found";
2834
+
2835
+ // Right-click the bubble to open the context menu.
2836
+ const pos = msgEl.position();
2837
+ const size = msgEl.size();
2838
+ se.rightClick({ at: [pos[0] + size[0] / 2, pos[1] + size[1] / 2] });
2839
+ delay(0.3);
2840
+
2841
+ // Click "React\u2026" item.
2842
+ try {
2843
+ const menu = proc.windows[0].menus[0];
2844
+ const reactItem = menu.menuItems.byName("React\u2026");
2845
+ reactItem.actions.byName("AXPress").perform();
2846
+ delay(0.2);
2847
+ reactItem.menus[0].menuItems[${reactionIndex}].actions.byName("AXPress").perform();
2848
+ } catch (err) {
2849
+ return "react_menu_error: " + err.toString();
2850
+ }
2851
+
2852
+ return "ok";
2853
+ })()`;
2854
+ }
2855
+ var TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token";
2856
+ var BOT_FRAMEWORK_SCOPE = "https://api.botframework.com/.default";
2857
+ var TeamsChannel = class _TeamsChannel {
2858
+ id = "teams";
2859
+ name = "Microsoft Teams";
2860
+ config = null;
2861
+ messageHandler = null;
2862
+ // OAuth token cache.
2863
+ accessToken = null;
2864
+ tokenExpiry = 0;
2865
+ // Service URL → conversation mappings for outbound messages.
2866
+ // Capped to prevent unbounded growth in high-traffic bots.
2867
+ static MAX_SERVICE_URLS = 1e4;
2868
+ serviceUrls = /* @__PURE__ */ new Map();
2869
+ // ---------------------------------------------------------------------------
2870
+ // IChannel lifecycle
2871
+ // ---------------------------------------------------------------------------
2872
+ async start(config) {
2873
+ const cfg = config;
2874
+ if (!cfg.appId || typeof cfg.appId !== "string") {
2875
+ throw new Error("TeamsChannel requires appId in config.");
2876
+ }
2877
+ if (!cfg.appPassword || typeof cfg.appPassword !== "string") {
2878
+ throw new Error("TeamsChannel requires appPassword in config.");
2879
+ }
2880
+ this.config = cfg;
2881
+ await this.getAccessToken();
2882
+ }
2883
+ async stop() {
2884
+ this.messageHandler = null;
2885
+ this.accessToken = null;
2886
+ this.tokenExpiry = 0;
2887
+ this.serviceUrls.clear();
2888
+ this.config = null;
2889
+ }
2890
+ onMessage(handler) {
2891
+ this.messageHandler = handler;
2892
+ }
2893
+ async send(to, message) {
2894
+ if (!this.config) {
2895
+ return { success: false, error: "TeamsChannel not started." };
2896
+ }
2897
+ const serviceUrl = this.serviceUrls.get(to.channelId);
2898
+ if (!serviceUrl) {
2899
+ return { success: false, error: `No service URL known for conversation ${to.channelId}` };
2900
+ }
2901
+ try {
2902
+ const token = await this.getAccessToken();
2903
+ const conversationId = to.channelId;
2904
+ const activity = {
2905
+ type: "message",
2906
+ text: message.text,
2907
+ textFormat: message.format === "markdown" ? "markdown" : "plain"
2908
+ };
2909
+ if (message.replyTo) {
2910
+ activity.replyToId = message.replyTo;
2911
+ }
2912
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(conversationId)}/activities`;
2913
+ const response = await fetch(url, {
2914
+ method: "POST",
2915
+ headers: {
2916
+ "Content-Type": "application/json",
2917
+ "Authorization": `Bearer ${token}`
2918
+ },
2919
+ body: JSON.stringify(activity)
2920
+ });
2921
+ if (!response.ok) {
2922
+ const errText = await response.text().catch(() => "");
2923
+ return { success: false, error: `Teams API ${response.status}: ${errText}` };
2924
+ }
2925
+ const result = await response.json();
2926
+ return { success: true, messageId: result.id };
2927
+ } catch (err) {
2928
+ return { success: false, error: `Teams send failed: ${err.message}` };
2929
+ }
2930
+ }
2931
+ async editMessage(to, messageId, message) {
2932
+ if (!this.config) {
2933
+ return { success: false, error: "TeamsChannel not started." };
2934
+ }
2935
+ const serviceUrl = this.serviceUrls.get(to.channelId);
2936
+ if (!serviceUrl) {
2937
+ return { success: false, error: `No service URL known for conversation ${to.channelId}` };
2938
+ }
2939
+ try {
2940
+ const token = await this.getAccessToken();
2941
+ const conversationId = to.channelId;
2942
+ const activity = {
2943
+ type: "message",
2944
+ id: messageId,
2945
+ text: message.text,
2946
+ textFormat: message.format === "markdown" ? "markdown" : "plain"
2947
+ };
2948
+ const url = `${serviceUrl}v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(messageId)}`;
2949
+ const response = await fetch(url, {
2950
+ method: "PUT",
2951
+ headers: {
2952
+ "Content-Type": "application/json",
2953
+ "Authorization": `Bearer ${token}`
2954
+ },
2955
+ body: JSON.stringify(activity)
2956
+ });
2957
+ if (!response.ok) {
2958
+ const errText = await response.text().catch(() => "");
2959
+ return { success: false, error: `Teams edit API ${response.status}: ${errText}` };
2960
+ }
2961
+ return { success: true, messageId };
2962
+ } catch (err) {
2963
+ return { success: false, error: `Teams edit failed: ${err.message}` };
2964
+ }
2965
+ }
2966
+ async isHealthy() {
2967
+ if (!this.config) return false;
2968
+ try {
2969
+ await this.getAccessToken();
2970
+ return true;
2971
+ } catch {
2972
+ return false;
2973
+ }
2974
+ }
2975
+ // ---------------------------------------------------------------------------
2976
+ // Inbound activity handling
2977
+ // ---------------------------------------------------------------------------
2978
+ /**
2979
+ * Process an inbound activity from the Bot Framework webhook.
2980
+ * Called by the gateway's channel webhook route.
2981
+ */
2982
+ handleIncomingActivity(activity) {
2983
+ if (!this.config || !this.messageHandler) return;
2984
+ if (activity.serviceUrl && activity.conversation?.id) {
2985
+ this.serviceUrls.set(activity.conversation.id, activity.serviceUrl);
2986
+ if (this.serviceUrls.size > _TeamsChannel.MAX_SERVICE_URLS) {
2987
+ const oldest = this.serviceUrls.keys().next().value;
2988
+ if (oldest !== void 0) this.serviceUrls.delete(oldest);
2989
+ }
2990
+ }
2991
+ if (activity.type !== "message") return;
2992
+ if (!activity.text) return;
2993
+ if (activity.from?.id === this.config.appId) return;
2994
+ if (this.config.allowedUsers?.length) {
2995
+ const aadId = activity.from?.aadObjectId;
2996
+ if (aadId && !this.config.allowedUsers.includes(aadId)) return;
2997
+ }
2998
+ const conversationId = activity.conversation?.id ?? "";
2999
+ const userId = activity.from?.id ?? "";
3000
+ const inbound = {
3001
+ id: activity.id ?? `teams-${Date.now()}`,
3002
+ channelId: conversationId,
3003
+ from: {
3004
+ channelId: conversationId,
3005
+ userId,
3006
+ groupId: activity.conversation?.isGroup ? conversationId : void 0
3007
+ },
3008
+ text: activity.text,
3009
+ timestamp: activity.timestamp ? new Date(activity.timestamp) : /* @__PURE__ */ new Date(),
3010
+ raw: activity
3011
+ };
3012
+ this.messageHandler(inbound);
3013
+ }
3014
+ // ---------------------------------------------------------------------------
3015
+ // OAuth2 token management
3016
+ // ---------------------------------------------------------------------------
3017
+ async getAccessToken() {
3018
+ if (this.accessToken && Date.now() < this.tokenExpiry - 6e4) {
3019
+ return this.accessToken;
3020
+ }
3021
+ if (!this.config) {
3022
+ throw new Error("TeamsChannel not configured.");
3023
+ }
3024
+ const body = new URLSearchParams({
3025
+ grant_type: "client_credentials",
3026
+ client_id: this.config.appId,
3027
+ client_secret: this.config.appPassword,
3028
+ scope: BOT_FRAMEWORK_SCOPE
3029
+ });
3030
+ const response = await fetch(TOKEN_URL, {
3031
+ method: "POST",
3032
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3033
+ body: body.toString()
3034
+ });
3035
+ if (!response.ok) {
3036
+ const errText = await response.text().catch(() => "");
3037
+ throw new Error(`Token request failed (${response.status}): ${errText}`);
3038
+ }
3039
+ const data = await response.json();
3040
+ this.accessToken = data.access_token;
3041
+ this.tokenExpiry = Date.now() + data.expires_in * 1e3;
3042
+ return this.accessToken;
3043
+ }
3044
+ };
3045
+ var ZALO_MAX_MESSAGE_LEN = 2e3;
3046
+ var OA_API_BASE = "https://openapi.zalo.me/v3.0/oa";
3047
+ var GRAPH_API_BASE = "https://graph.zalo.me/v2.0";
3048
+ var ZaloChannel = class {
3049
+ id = "zalo";
3050
+ name = "Zalo";
3051
+ config = null;
3052
+ messageHandler = null;
3053
+ // Access token cache (tokens expire after ~1 hour).
3054
+ currentAccessToken = null;
3055
+ tokenExpiry = 0;
3056
+ // ---------------------------------------------------------------------------
3057
+ // IChannel lifecycle
3058
+ // ---------------------------------------------------------------------------
3059
+ async start(config) {
3060
+ const cfg = config;
3061
+ if (!cfg.oaId || typeof cfg.oaId !== "string") {
3062
+ throw new Error("ZaloChannel requires oaId in config.");
3063
+ }
3064
+ if (!cfg.oaSecretKey || typeof cfg.oaSecretKey !== "string") {
3065
+ throw new Error("ZaloChannel requires oaSecretKey in config.");
3066
+ }
3067
+ if (!cfg.accessToken || typeof cfg.accessToken !== "string") {
3068
+ throw new Error("ZaloChannel requires accessToken in config.");
3069
+ }
3070
+ if (!cfg.appId || typeof cfg.appId !== "string") {
3071
+ throw new Error("ZaloChannel requires appId in config.");
3072
+ }
3073
+ this.config = cfg;
3074
+ this.currentAccessToken = cfg.accessToken;
3075
+ this.tokenExpiry = Date.now() + 3600 * 1e3;
3076
+ }
3077
+ async stop() {
3078
+ this.messageHandler = null;
3079
+ this.currentAccessToken = null;
3080
+ this.tokenExpiry = 0;
3081
+ this.config = null;
3082
+ }
3083
+ onMessage(handler) {
3084
+ this.messageHandler = handler;
3085
+ }
3086
+ async send(to, message) {
3087
+ if (!this.config) {
3088
+ return { success: false, error: "ZaloChannel not started." };
3089
+ }
3090
+ const userId = to.userId;
3091
+ if (!userId) {
3092
+ return { success: false, error: "Recipient must have userId for Zalo." };
3093
+ }
3094
+ try {
3095
+ const token = await this.getAccessToken();
3096
+ const chunks = splitMessage(message.text ?? "", ZALO_MAX_MESSAGE_LEN);
3097
+ let lastId;
3098
+ for (const chunk of chunks) {
3099
+ const body = {
3100
+ recipient: { user_id: userId },
3101
+ message: { text: chunk }
3102
+ };
3103
+ const response = await fetch(`${OA_API_BASE}/message/cs`, {
3104
+ method: "POST",
3105
+ headers: {
3106
+ "Content-Type": "application/json",
3107
+ "access_token": token
3108
+ },
3109
+ body: JSON.stringify(body)
3110
+ });
3111
+ if (!response.ok) {
3112
+ const errText = await response.text().catch(() => "");
3113
+ return { success: false, error: `Zalo API ${response.status}: ${errText}` };
3114
+ }
3115
+ const result = await response.json();
3116
+ if (result.error !== 0) {
3117
+ return { success: false, error: `Zalo API error ${result.error}: ${result.message}` };
3118
+ }
3119
+ lastId = result.data?.message_id ?? lastId;
3120
+ }
3121
+ return { success: true, messageId: lastId };
3122
+ } catch (err) {
3123
+ return { success: false, error: `Zalo send failed: ${err.message}` };
3124
+ }
3125
+ }
3126
+ async isHealthy() {
3127
+ if (!this.config) return false;
3128
+ try {
3129
+ const token = await this.getAccessToken();
3130
+ const response = await fetch(`${OA_API_BASE}/getoa`, {
3131
+ method: "GET",
3132
+ headers: { "access_token": token }
3133
+ });
3134
+ if (!response.ok) return false;
3135
+ const result = await response.json();
3136
+ return result.error === 0;
3137
+ } catch {
3138
+ return false;
3139
+ }
3140
+ }
3141
+ // ---------------------------------------------------------------------------
3142
+ // Inbound webhook handling
3143
+ // ---------------------------------------------------------------------------
3144
+ /**
3145
+ * Verify a Zalo webhook MAC signature.
3146
+ *
3147
+ * Zalo signs webhooks using SHA-256:
3148
+ * mac = SHA256(appId + rawBody + timestamp + oaSecretKey)
3149
+ *
3150
+ * The signature is sent in the `mac` field of the event payload.
3151
+ */
3152
+ async verifyWebhookMac(rawBody, mac) {
3153
+ if (!this.config) return false;
3154
+ try {
3155
+ const { createHash } = await import("crypto");
3156
+ const parsed = JSON.parse(rawBody);
3157
+ const appId = parsed.app_id ?? this.config.appId;
3158
+ const timestamp = parsed.timestamp ?? "";
3159
+ const baseString = `${appId}${rawBody}${timestamp}${this.config.oaSecretKey}`;
3160
+ const expected = createHash("sha256").update(baseString).digest("hex");
3161
+ return expected === mac;
3162
+ } catch {
3163
+ return false;
3164
+ }
3165
+ }
3166
+ /**
3167
+ * Process an inbound webhook event from Zalo.
3168
+ * Called by the gateway's channel webhook route.
3169
+ */
3170
+ handleIncomingEvent(event) {
3171
+ if (!this.config || !this.messageHandler) return;
3172
+ if (event.event_name !== "user_send_text") return;
3173
+ const text = event.message?.text;
3174
+ if (!text) return;
3175
+ const senderId = event.sender?.id ?? "";
3176
+ if (!senderId) return;
3177
+ if (this.config.allowedUsers?.length) {
3178
+ if (!this.config.allowedUsers.includes(senderId)) return;
3179
+ }
3180
+ const inbound = {
3181
+ id: event.message?.msg_id ?? `zalo-${Date.now()}`,
3182
+ channelId: this.id,
3183
+ from: {
3184
+ channelId: this.id,
3185
+ userId: senderId
3186
+ },
3187
+ text,
3188
+ timestamp: event.timestamp ? new Date(parseInt(event.timestamp, 10)) : /* @__PURE__ */ new Date(),
3189
+ raw: event
3190
+ };
3191
+ this.messageHandler(inbound);
3192
+ }
3193
+ // ---------------------------------------------------------------------------
3194
+ // Access token management
3195
+ // ---------------------------------------------------------------------------
3196
+ async getAccessToken() {
3197
+ if (this.currentAccessToken && Date.now() < this.tokenExpiry - 6e4) {
3198
+ return this.currentAccessToken;
3199
+ }
3200
+ if (this.config?.refreshToken && this.config.appSecret) {
3201
+ try {
3202
+ return await this.refreshAccessToken();
3203
+ } catch {
3204
+ }
3205
+ }
3206
+ if (this.currentAccessToken) {
3207
+ return this.currentAccessToken;
3208
+ }
3209
+ throw new Error("ZaloChannel: no valid access token available.");
3210
+ }
3211
+ async refreshAccessToken() {
3212
+ if (!this.config?.refreshToken || !this.config.appSecret) {
3213
+ throw new Error("Cannot refresh: missing refreshToken or appSecret.");
3214
+ }
3215
+ const response = await fetch(`${GRAPH_API_BASE}/me/token`, {
3216
+ method: "POST",
3217
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3218
+ body: new URLSearchParams({
3219
+ app_id: this.config.appId,
3220
+ grant_type: "refresh_token",
3221
+ refresh_token: this.config.refreshToken,
3222
+ app_secret: this.config.appSecret
3223
+ }).toString()
3224
+ });
3225
+ if (!response.ok) {
3226
+ const errText = await response.text().catch(() => "");
3227
+ throw new Error(`Token refresh failed (${response.status}): ${errText}`);
3228
+ }
3229
+ const data = await response.json();
3230
+ if (data.error && data.error !== 0) {
3231
+ throw new Error(`Token refresh error ${data.error}: ${data.message ?? "unknown"}`);
3232
+ }
3233
+ if (!data.access_token) {
3234
+ throw new Error("Token refresh returned no access_token.");
3235
+ }
3236
+ this.currentAccessToken = data.access_token;
3237
+ this.tokenExpiry = Date.now() + (data.expires_in ?? 3600) * 1e3;
3238
+ if (data.refresh_token) {
3239
+ this.config.refreshToken = data.refresh_token;
3240
+ }
3241
+ return this.currentAccessToken;
3242
+ }
3243
+ };
3244
+ var SEND_TIMEOUT_MS = 15e3;
3245
+ var HEALTH_TIMEOUT_MS = 5e3;
3246
+ var ZaloPersonalChannel = class {
3247
+ id = "zalo-personal";
3248
+ name = "Zalo Personal";
3249
+ config = null;
3250
+ messageHandler = null;
3251
+ // ---------------------------------------------------------------------------
3252
+ // IChannel lifecycle
3253
+ // ---------------------------------------------------------------------------
3254
+ async start(config) {
3255
+ const cfg = config;
3256
+ if (!cfg.bridgeUrl || typeof cfg.bridgeUrl !== "string") {
3257
+ throw new Error("ZaloPersonalChannel requires bridgeUrl in config.");
3258
+ }
3259
+ cfg.bridgeUrl = cfg.bridgeUrl.replace(/\/+$/, "");
3260
+ this.config = cfg;
3261
+ console.warn(
3262
+ "\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"
3263
+ );
3264
+ try {
3265
+ const healthy = await this.isHealthy();
3266
+ if (!healthy) {
3267
+ console.warn("\u26A0\uFE0F Zalo Personal bridge is not responding at: " + cfg.bridgeUrl);
3268
+ }
3269
+ } catch {
3270
+ console.warn("\u26A0\uFE0F Could not reach Zalo Personal bridge at: " + cfg.bridgeUrl);
3271
+ }
3272
+ }
3273
+ async stop() {
3274
+ this.messageHandler = null;
3275
+ this.config = null;
3276
+ }
3277
+ onMessage(handler) {
3278
+ this.messageHandler = handler;
3279
+ }
3280
+ async send(to, message) {
3281
+ if (!this.config) {
3282
+ return { success: false, error: "ZaloPersonalChannel not started." };
3283
+ }
3284
+ const target = to.userId ?? to.channelId;
3285
+ if (!target) {
3286
+ return { success: false, error: "No target specified." };
3287
+ }
3288
+ try {
3289
+ const url = `${this.config.bridgeUrl}/send`;
3290
+ const headers = {
3291
+ "Content-Type": "application/json"
3292
+ };
3293
+ if (this.config.bridgeToken) {
3294
+ headers["Authorization"] = `Bearer ${this.config.bridgeToken}`;
3295
+ }
3296
+ const controller = new AbortController();
3297
+ const timeoutId = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
3298
+ try {
3299
+ const response = await fetch(url, {
3300
+ method: "POST",
3301
+ headers,
3302
+ body: JSON.stringify({ to: target, text: message.text }),
3303
+ signal: controller.signal
3304
+ });
3305
+ if (!response.ok) {
3306
+ const errText = await response.text().catch(() => "");
3307
+ return { success: false, error: `Bridge send failed (${response.status}): ${errText}` };
3308
+ }
3309
+ const result = await response.json().catch(() => ({}));
3310
+ return { success: true, messageId: result.messageId ?? `zp-${Date.now()}` };
3311
+ } finally {
3312
+ clearTimeout(timeoutId);
3313
+ }
3314
+ } catch (err) {
3315
+ if (err.name === "AbortError") {
3316
+ return { success: false, error: `Bridge send timed out after ${SEND_TIMEOUT_MS}ms.` };
3317
+ }
3318
+ return { success: false, error: `Bridge send failed: ${err.message}` };
3319
+ }
3320
+ }
3321
+ async isHealthy() {
3322
+ if (!this.config) return false;
3323
+ try {
3324
+ const url = `${this.config.bridgeUrl}/health`;
3325
+ const headers = {};
3326
+ if (this.config.bridgeToken) {
3327
+ headers["Authorization"] = `Bearer ${this.config.bridgeToken}`;
3328
+ }
3329
+ const controller = new AbortController();
3330
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
3331
+ try {
3332
+ const response = await fetch(url, { headers, signal: controller.signal });
3333
+ return response.ok;
3334
+ } finally {
3335
+ clearTimeout(timeoutId);
3336
+ }
3337
+ } catch {
3338
+ return false;
3339
+ }
3340
+ }
3341
+ // ---------------------------------------------------------------------------
3342
+ // Inbound event handling
3343
+ // ---------------------------------------------------------------------------
3344
+ /**
3345
+ * Process an inbound event from the user's Zalo bridge.
3346
+ * Called by the gateway's channel webhook route.
3347
+ */
3348
+ handleIncomingEvent(event) {
3349
+ if (!this.config || !this.messageHandler) return;
3350
+ if (!event.sender || !event.text) return;
3351
+ if (this.config.allowedUsers?.length) {
3352
+ if (!this.config.allowedUsers.includes(event.sender)) return;
3353
+ }
3354
+ const inbound = {
3355
+ id: event.messageId ?? `zp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3356
+ channelId: event.threadId ?? event.sender,
3357
+ from: {
3358
+ channelId: event.threadId ?? event.sender,
3359
+ userId: event.sender,
3360
+ groupId: event.threadId
3361
+ },
3362
+ text: event.text,
3363
+ timestamp: event.timestamp ? new Date(event.timestamp) : /* @__PURE__ */ new Date(),
3364
+ raw: event
3365
+ };
3366
+ this.messageHandler(inbound);
3367
+ }
3368
+ };
3369
+ var API_TIMEOUT_MS = 15e3;
3370
+ var HEALTH_TIMEOUT_MS2 = 5e3;
3371
+ var BlueBubblesChannel = class {
3372
+ id = "bluebubbles";
3373
+ name = "BlueBubbles";
3374
+ config = null;
3375
+ messageHandler = null;
3376
+ // ---------------------------------------------------------------------------
3377
+ // IChannel lifecycle
3378
+ // ---------------------------------------------------------------------------
3379
+ async start(config) {
3380
+ const cfg = config;
3381
+ if (!cfg.host || typeof cfg.host !== "string") {
3382
+ throw new Error("BlueBubblesChannel requires host in config.");
3383
+ }
3384
+ if (!cfg.password || typeof cfg.password !== "string") {
3385
+ throw new Error("BlueBubblesChannel requires password in config.");
3386
+ }
3387
+ cfg.host = cfg.host.replace(/\/+$/, "");
3388
+ this.config = cfg;
3389
+ await this.verifyConnection();
3390
+ }
3391
+ async stop() {
3392
+ this.messageHandler = null;
3393
+ this.config = null;
3394
+ }
3395
+ onMessage(handler) {
3396
+ this.messageHandler = handler;
3397
+ }
3398
+ async send(to, message) {
3399
+ if (!this.config) {
3400
+ return { success: false, error: "BlueBubblesChannel not started." };
3401
+ }
3402
+ const chatGuid = to.groupId ?? to.channelId;
3403
+ if (!chatGuid) {
3404
+ return { success: false, error: "No chat GUID specified." };
3405
+ }
3406
+ try {
3407
+ const url = this.apiUrl(`/api/v1/message/text`);
3408
+ const controller = new AbortController();
3409
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
3410
+ try {
3411
+ const response = await fetch(url, {
3412
+ method: "POST",
3413
+ headers: { "Content-Type": "application/json" },
3414
+ body: JSON.stringify({
3415
+ chatGuid,
3416
+ message: message.text,
3417
+ tempGuid: `temp-${Date.now()}`
3418
+ }),
3419
+ signal: controller.signal
3420
+ });
3421
+ if (!response.ok) {
3422
+ const errText = await response.text().catch(() => "");
3423
+ return { success: false, error: `BlueBubbles API ${response.status}: ${errText}` };
3424
+ }
3425
+ const result = await response.json();
3426
+ if (result.status !== 200) {
3427
+ return { success: false, error: result.error?.message ?? result.message };
3428
+ }
3429
+ return { success: true, messageId: result.data?.guid };
3430
+ } finally {
3431
+ clearTimeout(timeoutId);
3432
+ }
3433
+ } catch (err) {
3434
+ if (err.name === "AbortError") {
3435
+ return { success: false, error: `BlueBubbles send timed out after ${API_TIMEOUT_MS}ms.` };
3436
+ }
3437
+ return { success: false, error: `BlueBubbles send failed: ${err.message}` };
3438
+ }
3439
+ }
3440
+ async isHealthy() {
3441
+ if (!this.config) return false;
3442
+ try {
3443
+ const url = this.apiUrl("/api/v1/server/info");
3444
+ const controller = new AbortController();
3445
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS2);
3446
+ try {
3447
+ const response = await fetch(url, { signal: controller.signal });
3448
+ if (!response.ok) return false;
3449
+ const data = await response.json();
3450
+ return data.status === 200;
3451
+ } finally {
3452
+ clearTimeout(timeoutId);
3453
+ }
3454
+ } catch {
3455
+ return false;
3456
+ }
3457
+ }
3458
+ // ---------------------------------------------------------------------------
3459
+ // Inbound event handling
3460
+ // ---------------------------------------------------------------------------
3461
+ /**
3462
+ * Process an inbound webhook event from BlueBubbles server.
3463
+ * Called by the gateway's channel webhook route.
3464
+ */
3465
+ handleIncomingEvent(event) {
3466
+ if (!this.config || !this.messageHandler) return;
3467
+ if (event.type !== "new-message") return;
3468
+ const data = event.data;
3469
+ if (!data || !data.text) return;
3470
+ if (data.isFromMe) return;
3471
+ const senderAddress = data.handle?.address ?? data.handle?.id ?? "";
3472
+ if (!senderAddress) return;
3473
+ if (this.config.allowedAddresses?.length) {
3474
+ if (!this.config.allowedAddresses.includes(senderAddress)) return;
3475
+ }
3476
+ const chat = data.chats?.[0];
3477
+ const chatGuid = chat?.guid ?? senderAddress;
3478
+ const inbound = {
3479
+ id: data.guid ?? `bb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3480
+ channelId: chatGuid,
3481
+ from: {
3482
+ channelId: chatGuid,
3483
+ userId: senderAddress,
3484
+ groupId: chat?.chatIdentifier?.startsWith("chat") ? chatGuid : void 0
3485
+ },
3486
+ text: data.text,
3487
+ timestamp: data.dateCreated ? new Date(data.dateCreated) : /* @__PURE__ */ new Date(),
3488
+ raw: event
3489
+ };
3490
+ this.messageHandler(inbound);
3491
+ }
3492
+ // ---------------------------------------------------------------------------
3493
+ // Internal helpers
3494
+ // ---------------------------------------------------------------------------
3495
+ /**
3496
+ * Build a full API URL with the password query parameter.
3497
+ */
3498
+ apiUrl(path) {
3499
+ const base = `${this.config.host}${path}`;
3500
+ const separator = base.includes("?") ? "&" : "?";
3501
+ return `${base}${separator}password=${encodeURIComponent(this.config.password)}`;
3502
+ }
3503
+ /**
3504
+ * Verify the BlueBubbles server is reachable on startup.
3505
+ */
3506
+ async verifyConnection() {
3507
+ const healthy = await this.isHealthy();
3508
+ if (!healthy) {
3509
+ throw new Error(
3510
+ `Cannot connect to BlueBubbles server at ${this.config.host}. Ensure the server is running and the password is correct.`
3511
+ );
3512
+ }
3513
+ }
3514
+ };
3515
+ var GOOGLE_CHAT_MAX_MESSAGE_LEN = 4e3;
3516
+ var CHAT_API_BASE = "https://chat.googleapis.com/v1";
3517
+ var TOKEN_URL2 = "https://oauth2.googleapis.com/token";
3518
+ var CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
3519
+ var API_TIMEOUT_MS2 = 15e3;
3520
+ var JWT_LIFETIME_S = 3600;
3521
+ var GoogleChatChannel = class {
3522
+ id = "googlechat";
3523
+ name = "Google Chat";
3524
+ config = null;
3525
+ messageHandler = null;
3526
+ // Service account credentials (parsed once on start).
3527
+ serviceAccount = null;
3528
+ // OAuth token cache.
3529
+ accessToken = null;
3530
+ tokenExpiry = 0;
3531
+ // ---------------------------------------------------------------------------
3532
+ // IChannel lifecycle
3533
+ // ---------------------------------------------------------------------------
3534
+ async start(config) {
3535
+ const cfg = config;
3536
+ if (!cfg.serviceAccountKey || typeof cfg.serviceAccountKey !== "string") {
3537
+ throw new Error("GoogleChatChannel requires serviceAccountKey in config.");
3538
+ }
3539
+ try {
3540
+ this.serviceAccount = JSON.parse(cfg.serviceAccountKey);
3541
+ } catch {
3542
+ throw new Error("GoogleChatChannel: serviceAccountKey must be valid JSON.");
3543
+ }
3544
+ if (!this.serviceAccount.client_email || !this.serviceAccount.private_key) {
3545
+ throw new Error("GoogleChatChannel: serviceAccountKey must contain client_email and private_key.");
3546
+ }
3547
+ this.config = cfg;
3548
+ await this.getAccessToken();
3549
+ }
3550
+ async stop() {
3551
+ this.messageHandler = null;
3552
+ this.accessToken = null;
3553
+ this.tokenExpiry = 0;
3554
+ this.serviceAccount = null;
3555
+ this.config = null;
3556
+ }
3557
+ onMessage(handler) {
3558
+ this.messageHandler = handler;
3559
+ }
3560
+ async send(to, message) {
3561
+ if (!this.config || !this.serviceAccount) {
3562
+ return { success: false, error: "GoogleChatChannel not started." };
3563
+ }
3564
+ const spaceName = to.groupId ?? to.channelId;
3565
+ if (!spaceName) {
3566
+ return { success: false, error: "No space specified." };
3567
+ }
3568
+ try {
3569
+ const token = await this.getAccessToken();
3570
+ const url = `${CHAT_API_BASE}/${spaceName}/messages`;
3571
+ const chunks = splitMessage(message.text ?? "", GOOGLE_CHAT_MAX_MESSAGE_LEN);
3572
+ let lastId;
3573
+ for (const chunk of chunks) {
3574
+ const body = { text: chunk };
3575
+ if (to.threadId) {
3576
+ body.thread = { name: to.threadId };
3577
+ }
3578
+ const controller = new AbortController();
3579
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS2);
3580
+ try {
3581
+ const response = await fetch(url, {
3582
+ method: "POST",
3583
+ headers: {
3584
+ "Content-Type": "application/json",
3585
+ "Authorization": `Bearer ${token}`
3586
+ },
3587
+ body: JSON.stringify(body),
3588
+ signal: controller.signal
3589
+ });
3590
+ if (!response.ok) {
3591
+ const errText = await response.text().catch(() => "");
3592
+ return { success: false, error: `Google Chat API ${response.status}: ${errText}` };
3593
+ }
3594
+ const result = await response.json();
3595
+ if (result.error) {
3596
+ return { success: false, error: `Google Chat API error: ${result.error.message}` };
3597
+ }
3598
+ lastId = result.name;
3599
+ } finally {
3600
+ clearTimeout(timeoutId);
3601
+ }
3602
+ }
3603
+ return { success: true, messageId: lastId };
3604
+ } catch (err) {
3605
+ if (err.name === "AbortError") {
3606
+ return { success: false, error: `Google Chat send timed out after ${API_TIMEOUT_MS2}ms.` };
3607
+ }
3608
+ return { success: false, error: `Google Chat send failed: ${err.message}` };
3609
+ }
3610
+ }
3611
+ async editMessage(_to, messageId, message) {
3612
+ if (!this.config || !this.serviceAccount) {
3613
+ return { success: false, error: "GoogleChatChannel not started." };
3614
+ }
3615
+ try {
3616
+ const token = await this.getAccessToken();
3617
+ const url = `${CHAT_API_BASE}/${messageId}?updateMask=text`;
3618
+ const controller = new AbortController();
3619
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS2);
3620
+ try {
3621
+ const response = await fetch(url, {
3622
+ method: "PUT",
3623
+ headers: {
3624
+ "Content-Type": "application/json",
3625
+ "Authorization": `Bearer ${token}`
3626
+ },
3627
+ body: JSON.stringify({ text: truncateMessage(message.text ?? "", GOOGLE_CHAT_MAX_MESSAGE_LEN) }),
3628
+ signal: controller.signal
3629
+ });
3630
+ if (!response.ok) {
3631
+ const errText = await response.text().catch(() => "");
3632
+ return { success: false, error: `Google Chat edit API ${response.status}: ${errText}` };
3633
+ }
3634
+ return { success: true, messageId };
3635
+ } finally {
3636
+ clearTimeout(timeoutId);
3637
+ }
3638
+ } catch (err) {
3639
+ if (err.name === "AbortError") {
3640
+ return { success: false, error: `Google Chat edit timed out after ${API_TIMEOUT_MS2}ms.` };
3641
+ }
3642
+ return { success: false, error: `Google Chat edit failed: ${err.message}` };
3643
+ }
3644
+ }
3645
+ async isHealthy() {
3646
+ if (!this.config || !this.serviceAccount) return false;
3647
+ try {
3648
+ await this.getAccessToken();
3649
+ return true;
3650
+ } catch {
3651
+ return false;
3652
+ }
3653
+ }
3654
+ // ---------------------------------------------------------------------------
3655
+ // Inbound event handling
3656
+ // ---------------------------------------------------------------------------
3657
+ /**
3658
+ * Process an inbound event from Google Chat webhook.
3659
+ * Called by the gateway's channel webhook route.
3660
+ */
3661
+ handleIncomingEvent(event) {
3662
+ if (!this.config || !this.messageHandler) return;
3663
+ if (this.config.verificationToken && event.token) {
3664
+ if (event.token !== this.config.verificationToken) return;
3665
+ }
3666
+ if (event.type !== "MESSAGE") return;
3667
+ const msg = event.message;
3668
+ if (!msg?.text && !msg?.argumentText) return;
3669
+ if (msg.sender?.type === "BOT") return;
3670
+ const senderEmail = msg.sender?.email ?? "";
3671
+ if (this.config.allowedUsers?.length) {
3672
+ if (senderEmail && !this.config.allowedUsers.includes(senderEmail)) return;
3673
+ }
3674
+ const spaceName = msg.space?.name ?? event.space?.name ?? "";
3675
+ if (this.config.allowedSpaces?.length) {
3676
+ if (spaceName && !this.config.allowedSpaces.includes(spaceName)) return;
3677
+ }
3678
+ const text = msg.argumentText ?? msg.text ?? "";
3679
+ const senderId = msg.sender?.name ?? senderEmail;
3680
+ const inbound = {
3681
+ id: msg.name ?? `gc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3682
+ channelId: spaceName,
3683
+ from: {
3684
+ channelId: spaceName,
3685
+ userId: senderId,
3686
+ groupId: msg.space?.type === "ROOM" ? spaceName : void 0,
3687
+ threadId: msg.thread?.name
3688
+ },
3689
+ text,
3690
+ timestamp: msg.createTime ? new Date(msg.createTime) : /* @__PURE__ */ new Date(),
3691
+ raw: event
3692
+ };
3693
+ this.messageHandler(inbound);
3694
+ }
3695
+ // ---------------------------------------------------------------------------
3696
+ // JWT token management
3697
+ // ---------------------------------------------------------------------------
3698
+ /** Get a valid access token, refreshing if needed. */
3699
+ async getAccessToken() {
3700
+ if (this.accessToken && Date.now() < this.tokenExpiry - 6e4) {
3701
+ return this.accessToken;
3702
+ }
3703
+ if (!this.serviceAccount) {
3704
+ throw new Error("GoogleChatChannel not configured.");
3705
+ }
3706
+ const jwt = this.createJwt();
3707
+ const body = new URLSearchParams({
3708
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
3709
+ assertion: jwt
3710
+ });
3711
+ const tokenUrl = this.serviceAccount.token_uri ?? TOKEN_URL2;
3712
+ const response = await fetch(tokenUrl, {
3713
+ method: "POST",
3714
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3715
+ body: body.toString()
3716
+ });
3717
+ if (!response.ok) {
3718
+ const errText = await response.text().catch(() => "");
3719
+ throw new Error(`Google token request failed (${response.status}): ${errText}`);
3720
+ }
3721
+ const data = await response.json();
3722
+ this.accessToken = data.access_token;
3723
+ this.tokenExpiry = Date.now() + data.expires_in * 1e3;
3724
+ return this.accessToken;
3725
+ }
3726
+ /** Create a self-signed JWT for the service account. */
3727
+ createJwt() {
3728
+ const now = Math.floor(Date.now() / 1e3);
3729
+ const header = {
3730
+ alg: "RS256",
3731
+ typ: "JWT"
3732
+ };
3733
+ const payload = {
3734
+ iss: this.serviceAccount.client_email,
3735
+ scope: CHAT_SCOPE,
3736
+ aud: this.serviceAccount.token_uri ?? TOKEN_URL2,
3737
+ iat: now,
3738
+ exp: now + JWT_LIFETIME_S
3739
+ };
3740
+ const headerB64 = base64urlEncode(JSON.stringify(header));
3741
+ const payloadB64 = base64urlEncode(JSON.stringify(payload));
3742
+ const signingInput = `${headerB64}.${payloadB64}`;
3743
+ const signer = createSign("RSA-SHA256");
3744
+ signer.update(signingInput);
3745
+ const signature = signer.sign(this.serviceAccount.private_key, "base64");
3746
+ const signatureB64 = signature.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3747
+ return `${signingInput}.${signatureB64}`;
3748
+ }
3749
+ };
3750
+ function base64urlEncode(str) {
3751
+ return Buffer.from(str, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3752
+ }
3753
+ var WebChatChannel = class {
3754
+ id = "webchat";
3755
+ name = "WebChat";
3756
+ config = null;
3757
+ messageHandler = null;
3758
+ // Map of userId → Set<WebSocket> (supports multiple browser tabs).
3759
+ clients = /* @__PURE__ */ new Map();
3760
+ // Counter for generating anonymous user IDs.
3761
+ anonCounter = 0;
3762
+ // ---------------------------------------------------------------------------
3763
+ // IChannel lifecycle
3764
+ // ---------------------------------------------------------------------------
3765
+ async start(config) {
3766
+ this.config = config;
3767
+ }
3768
+ async stop() {
3769
+ for (const [, sockets] of this.clients) {
3770
+ for (const ws of sockets) {
3771
+ try {
3772
+ ws.close(1001, "Channel stopping");
3773
+ } catch {
3774
+ }
3775
+ }
3776
+ }
3777
+ this.clients.clear();
3778
+ this.messageHandler = null;
3779
+ this.config = null;
3780
+ }
3781
+ onMessage(handler) {
3782
+ this.messageHandler = handler;
3783
+ }
3784
+ async send(to, message) {
3785
+ if (!this.config) {
3786
+ return { success: false, error: "WebChatChannel not started." };
3787
+ }
3788
+ const userId = to.userId ?? to.channelId;
3789
+ const sockets = this.clients.get(userId);
3790
+ if (!sockets || sockets.size === 0) {
3791
+ return { success: false, error: `No WebSocket connection for user ${userId}` };
3792
+ }
3793
+ const messageId = `wc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3794
+ const payload = {
3795
+ type: "message",
3796
+ id: messageId,
3797
+ text: message.text
3798
+ };
3799
+ const json = JSON.stringify(payload);
3800
+ let sent = false;
3801
+ for (const ws of sockets) {
3802
+ try {
3803
+ if (ws.readyState === 1) {
3804
+ ws.send(json);
3805
+ sent = true;
3806
+ }
3807
+ } catch {
3808
+ sockets.delete(ws);
3809
+ }
3810
+ }
3811
+ return sent ? { success: true, messageId } : { success: false, error: "All WebSocket connections are closed." };
3812
+ }
3813
+ async editMessage(to, messageId, message) {
3814
+ if (!this.config) {
3815
+ return { success: false, error: "WebChatChannel not started." };
3816
+ }
3817
+ const userId = to.userId ?? to.channelId;
3818
+ const sockets = this.clients.get(userId);
3819
+ if (!sockets || sockets.size === 0) {
3820
+ return { success: false, error: `No WebSocket connection for user ${userId}` };
3821
+ }
3822
+ const payload = {
3823
+ type: "edit",
3824
+ messageId,
3825
+ text: message.text
3826
+ };
3827
+ const json = JSON.stringify(payload);
3828
+ let sent = false;
3829
+ for (const ws of sockets) {
3830
+ try {
3831
+ if (ws.readyState === 1) {
3832
+ ws.send(json);
3833
+ sent = true;
3834
+ }
3835
+ } catch {
3836
+ sockets.delete(ws);
3837
+ }
3838
+ }
3839
+ return sent ? { success: true, messageId } : { success: false, error: "All WebSocket connections are closed." };
3840
+ }
3841
+ async isHealthy() {
3842
+ return this.config !== null;
3843
+ }
3844
+ // ---------------------------------------------------------------------------
3845
+ // WebSocket connection management
3846
+ // ---------------------------------------------------------------------------
3847
+ /**
3848
+ * Register a new WebSocket connection.
3849
+ * Called by the gateway's handleUpgrade() when a client connects to /webchat.
3850
+ */
3851
+ handleConnection(ws) {
3852
+ if (!this.config) {
3853
+ ws.close(1013, "Channel not started");
3854
+ return;
3855
+ }
3856
+ let userId = null;
3857
+ const requireAuth = this.config.requireAuth ?? false;
3858
+ ws.on("message", (data) => {
3859
+ try {
3860
+ const msg = JSON.parse(typeof data === "string" ? data : data.toString());
3861
+ if (msg.type === "auth") {
3862
+ return;
3863
+ }
3864
+ if (msg.type === "message") {
3865
+ if (!userId) {
3866
+ if (requireAuth && !msg.userId) {
3867
+ this.sendError(ws, "Authentication required.");
3868
+ return;
3869
+ }
3870
+ userId = msg.userId ?? `anon-${++this.anonCounter}`;
3871
+ this.addClient(userId, ws);
3872
+ }
3873
+ if (!msg.text) return;
3874
+ if (!this.messageHandler) return;
3875
+ const inbound = {
3876
+ id: `wc-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3877
+ channelId: this.id,
3878
+ from: {
3879
+ channelId: this.id,
3880
+ userId
3881
+ },
3882
+ text: msg.text,
3883
+ timestamp: /* @__PURE__ */ new Date()
3884
+ };
3885
+ this.messageHandler(inbound);
3886
+ }
3887
+ } catch {
3888
+ this.sendError(ws, "Invalid message format.");
3889
+ }
3890
+ });
3891
+ ws.on("close", () => {
3892
+ if (userId) {
3893
+ this.removeClient(userId, ws);
3894
+ }
3895
+ });
3896
+ ws.on("error", () => {
3897
+ if (userId) {
3898
+ this.removeClient(userId, ws);
3899
+ }
3900
+ });
3901
+ }
3902
+ /**
3903
+ * Register a WebSocket for a specific userId.
3904
+ * Called when the user is already authenticated via the gateway.
3905
+ */
3906
+ handleAuthenticatedConnection(ws, authenticatedUserId) {
3907
+ if (!this.config) {
3908
+ ws.close(1013, "Channel not started");
3909
+ return;
3910
+ }
3911
+ this.addClient(authenticatedUserId, ws);
3912
+ ws.on("message", (data) => {
3913
+ try {
3914
+ const msg = JSON.parse(typeof data === "string" ? data : data.toString());
3915
+ if (msg.type === "message" && msg.text) {
3916
+ if (!this.messageHandler) return;
3917
+ const inbound = {
3918
+ id: `wc-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3919
+ channelId: this.id,
3920
+ from: {
3921
+ channelId: this.id,
3922
+ userId: authenticatedUserId
3923
+ },
3924
+ text: msg.text,
3925
+ timestamp: /* @__PURE__ */ new Date()
3926
+ };
3927
+ this.messageHandler(inbound);
3928
+ }
3929
+ } catch {
3930
+ this.sendError(ws, "Invalid message format.");
3931
+ }
3932
+ });
3933
+ ws.on("close", () => {
3934
+ this.removeClient(authenticatedUserId, ws);
3935
+ });
3936
+ ws.on("error", () => {
3937
+ this.removeClient(authenticatedUserId, ws);
3938
+ });
3939
+ }
3940
+ // ---------------------------------------------------------------------------
3941
+ // Private helpers
3942
+ // ---------------------------------------------------------------------------
3943
+ addClient(userId, ws) {
3944
+ let sockets = this.clients.get(userId);
3945
+ if (!sockets) {
3946
+ sockets = /* @__PURE__ */ new Set();
3947
+ this.clients.set(userId, sockets);
3948
+ }
3949
+ sockets.add(ws);
3950
+ }
3951
+ removeClient(userId, ws) {
3952
+ const sockets = this.clients.get(userId);
3953
+ if (sockets) {
3954
+ sockets.delete(ws);
3955
+ if (sockets.size === 0) {
3956
+ this.clients.delete(userId);
3957
+ }
3958
+ }
3959
+ }
3960
+ sendError(ws, error) {
3961
+ try {
3962
+ const payload = { type: "error", error };
3963
+ ws.send(JSON.stringify(payload));
3964
+ } catch {
3965
+ }
3966
+ }
3967
+ };
3968
+ var DEFAULT_PORT = 6697;
3969
+ var DEFAULT_NICK = "ch4p";
3970
+ var DEFAULT_RECONNECT_DELAY = 5e3;
3971
+ var MAX_IRC_LINE = 512;
3972
+ var MAX_PRIVMSG_TEXT = 400;
3973
+ var IrcChannel = class {
3974
+ id = "irc";
3975
+ name = "IRC";
3976
+ config = null;
3977
+ messageHandler = null;
3978
+ socket = null;
3979
+ buffer = "";
3980
+ registered = false;
3981
+ stopping = false;
3982
+ reconnectTimer = null;
3983
+ // ---------------------------------------------------------------------------
3984
+ // IChannel lifecycle
3985
+ // ---------------------------------------------------------------------------
3986
+ async start(config) {
3987
+ const cfg = config;
3988
+ if (!cfg.server || typeof cfg.server !== "string") {
3989
+ throw new Error("IrcChannel requires server in config.");
3990
+ }
3991
+ this.config = cfg;
3992
+ this.stopping = false;
3993
+ await this.connect();
3994
+ }
3995
+ async stop() {
3996
+ this.stopping = true;
3997
+ if (this.reconnectTimer) {
3998
+ clearTimeout(this.reconnectTimer);
3999
+ this.reconnectTimer = null;
4000
+ }
4001
+ if (this.socket) {
4002
+ try {
4003
+ this.rawSend("QUIT :Goodbye");
4004
+ } catch {
4005
+ }
4006
+ this.socket.destroy();
4007
+ this.socket = null;
4008
+ }
4009
+ this.registered = false;
4010
+ this.buffer = "";
4011
+ this.messageHandler = null;
4012
+ this.config = null;
4013
+ }
4014
+ onMessage(handler) {
4015
+ this.messageHandler = handler;
4016
+ }
4017
+ async send(to, message) {
4018
+ if (!this.config || !this.socket || !this.registered) {
4019
+ return { success: false, error: "IrcChannel not connected." };
4020
+ }
4021
+ const target = to.groupId ?? to.userId ?? to.channelId;
4022
+ if (!target) {
4023
+ return { success: false, error: "No target specified." };
4024
+ }
4025
+ try {
4026
+ const lines = splitMessage2(message.text, MAX_PRIVMSG_TEXT);
4027
+ for (const line of lines) {
4028
+ this.rawSend(`PRIVMSG ${target} :${line}`);
4029
+ }
4030
+ return { success: true, messageId: `irc-${Date.now()}` };
4031
+ } catch (err) {
4032
+ return { success: false, error: `IRC send failed: ${err.message}` };
4033
+ }
4034
+ }
4035
+ async isHealthy() {
4036
+ return this.registered && this.socket !== null && !this.socket.destroyed;
4037
+ }
4038
+ // ---------------------------------------------------------------------------
4039
+ // Connection management
4040
+ // ---------------------------------------------------------------------------
4041
+ async connect() {
4042
+ if (!this.config) throw new Error("Not configured.");
4043
+ const cfg = this.config;
4044
+ const port = cfg.port ?? DEFAULT_PORT;
4045
+ const useSsl = cfg.ssl !== false;
4046
+ return new Promise((resolve, reject) => {
4047
+ let settled = false;
4048
+ const registrationTimeout = setTimeout(() => {
4049
+ if (!settled) {
4050
+ settled = true;
4051
+ this.socket?.destroy();
4052
+ this.socket = null;
4053
+ reject(new Error("IRC registration timeout \u2014 no WELCOME received within 30s"));
4054
+ }
4055
+ }, 3e4);
4056
+ const onConnect = () => {
4057
+ if (cfg.password) {
4058
+ this.rawSend(`PASS ${cfg.password}`);
4059
+ }
4060
+ const nick = cfg.nick ?? DEFAULT_NICK;
4061
+ this.rawSend(`NICK ${nick}`);
4062
+ this.rawSend(`USER ${nick} 0 * :ch4p bot`);
4063
+ clearTimeout(registrationTimeout);
4064
+ settled = true;
4065
+ resolve();
4066
+ };
4067
+ const onError = (err) => {
4068
+ if (!settled) {
4069
+ settled = true;
4070
+ clearTimeout(registrationTimeout);
4071
+ reject(new Error(`IRC connection failed: ${err.message}`));
4072
+ }
4073
+ };
4074
+ if (useSsl) {
4075
+ this.socket = tlsConnect(
4076
+ { host: cfg.server, port, rejectUnauthorized: true },
4077
+ onConnect
4078
+ );
4079
+ } else {
4080
+ this.socket = netConnect({ host: cfg.server, port }, onConnect);
4081
+ }
4082
+ this.socket.setEncoding("utf8");
4083
+ this.socket.on("data", (data) => this.handleData(data));
4084
+ this.socket.on("error", onError);
4085
+ this.socket.on("close", () => this.handleDisconnect());
4086
+ });
4087
+ }
4088
+ scheduleReconnect() {
4089
+ if (this.stopping || !this.config) return;
4090
+ const delay = this.config.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
4091
+ this.reconnectTimer = setTimeout(() => {
4092
+ this.reconnectTimer = null;
4093
+ if (this.stopping) return;
4094
+ this.registered = false;
4095
+ this.buffer = "";
4096
+ this.connect().catch(() => {
4097
+ this.scheduleReconnect();
4098
+ });
4099
+ }, delay);
4100
+ this.reconnectTimer.unref();
4101
+ }
4102
+ handleDisconnect() {
4103
+ this.registered = false;
4104
+ this.socket = null;
4105
+ if (!this.stopping) {
4106
+ this.scheduleReconnect();
4107
+ }
4108
+ }
4109
+ // ---------------------------------------------------------------------------
4110
+ // IRC protocol parsing
4111
+ // ---------------------------------------------------------------------------
4112
+ handleData(data) {
4113
+ this.buffer += data;
4114
+ let newlineIdx;
4115
+ while ((newlineIdx = this.buffer.indexOf("\r\n")) !== -1) {
4116
+ const line = this.buffer.slice(0, newlineIdx);
4117
+ this.buffer = this.buffer.slice(newlineIdx + 2);
4118
+ if (line.length > 0) {
4119
+ this.parseLine(line);
4120
+ }
4121
+ }
4122
+ }
4123
+ parseLine(line) {
4124
+ let prefix = "";
4125
+ let rest = line;
4126
+ if (rest.startsWith(":")) {
4127
+ const spaceIdx = rest.indexOf(" ");
4128
+ if (spaceIdx === -1) return;
4129
+ prefix = rest.slice(1, spaceIdx);
4130
+ rest = rest.slice(spaceIdx + 1);
4131
+ }
4132
+ const parts = rest.split(" ");
4133
+ const command = parts[0];
4134
+ if (command === "PING") {
4135
+ const server = parts.slice(1).join(" ");
4136
+ this.rawSend(`PONG ${server}`);
4137
+ return;
4138
+ }
4139
+ if (command === "001") {
4140
+ this.registered = true;
4141
+ const channels = this.config?.channels ?? [];
4142
+ for (const ch of channels) {
4143
+ this.rawSend(`JOIN ${ch}`);
4144
+ }
4145
+ return;
4146
+ }
4147
+ if (command === "PRIVMSG" && parts.length >= 2) {
4148
+ const target = parts[1];
4149
+ const msgStart = rest.indexOf(" :");
4150
+ if (msgStart === -1) return;
4151
+ const text = rest.slice(msgStart + 2);
4152
+ const nick = prefix.split("!")[0] ?? "";
4153
+ if (this.config?.allowedUsers?.length) {
4154
+ if (!this.config.allowedUsers.includes(nick)) return;
4155
+ }
4156
+ if (!this.messageHandler) return;
4157
+ const isChannel = target.startsWith("#") || target.startsWith("&");
4158
+ const inbound = {
4159
+ id: `irc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4160
+ channelId: isChannel ? target : nick,
4161
+ from: {
4162
+ channelId: isChannel ? target : nick,
4163
+ userId: nick,
4164
+ groupId: isChannel ? target : void 0
4165
+ },
4166
+ text,
4167
+ timestamp: /* @__PURE__ */ new Date(),
4168
+ raw: { prefix, command, target, text }
4169
+ };
4170
+ this.messageHandler(inbound);
4171
+ }
4172
+ }
4173
+ // ---------------------------------------------------------------------------
4174
+ // Raw socket I/O
4175
+ // ---------------------------------------------------------------------------
4176
+ rawSend(line) {
4177
+ if (!this.socket || this.socket.destroyed) return;
4178
+ const truncated = line.length > MAX_IRC_LINE - 2 ? line.slice(0, MAX_IRC_LINE - 2) : line;
4179
+ this.socket.write(`${truncated}\r
4180
+ `);
4181
+ }
4182
+ };
4183
+ function splitMessage2(text, maxLen) {
4184
+ if (text.length <= maxLen) return [text];
4185
+ const chunks = [];
4186
+ let remaining = text;
4187
+ while (remaining.length > 0) {
4188
+ chunks.push(remaining.slice(0, maxLen));
4189
+ remaining = remaining.slice(maxLen);
4190
+ }
4191
+ return chunks;
4192
+ }
4193
+ var execFile2 = promisify2(execFileCb2);
4194
+ var MacOSChannel = class {
4195
+ id = "macos";
4196
+ name = "macOS Native";
4197
+ messageHandler = null;
4198
+ running = false;
4199
+ mode = "dialog";
4200
+ dialogDelay = 500;
4201
+ title = "ch4p";
4202
+ sound = "Submarine";
4203
+ pendingDialog = null;
4204
+ dialogTimer = null;
4205
+ waitingForResponse = false;
4206
+ // -----------------------------------------------------------------------
4207
+ // IChannel implementation
4208
+ // -----------------------------------------------------------------------
4209
+ async start(config) {
4210
+ if (this.running) return;
4211
+ if (process.platform !== "darwin") {
4212
+ throw new Error(
4213
+ `MacOSChannel is macOS-only. Current platform "${process.platform}" is not supported.`
4214
+ );
4215
+ }
4216
+ try {
4217
+ await execFile2("which", ["osascript"]);
4218
+ } catch {
4219
+ throw new Error(
4220
+ "osascript not found on PATH. This should always be available on macOS."
4221
+ );
4222
+ }
4223
+ const cfg = config;
4224
+ this.mode = cfg.mode ?? "dialog";
4225
+ this.dialogDelay = cfg.dialogDelay ?? 500;
4226
+ this.title = cfg.title ?? "ch4p";
4227
+ this.sound = cfg.sound ?? "Submarine";
4228
+ this.running = true;
4229
+ this.scheduleInputDialog();
4230
+ }
4231
+ async stop() {
4232
+ this.running = false;
4233
+ if (this.dialogTimer) {
4234
+ clearTimeout(this.dialogTimer);
4235
+ this.dialogTimer = null;
4236
+ }
4237
+ if (this.pendingDialog) {
4238
+ try {
4239
+ this.pendingDialog.kill("SIGTERM");
4240
+ } catch {
4241
+ }
4242
+ this.pendingDialog = null;
4243
+ }
4244
+ }
4245
+ async send(_to, message) {
4246
+ if (!this.running) {
4247
+ return { success: false, error: "Channel is not running." };
4248
+ }
4249
+ try {
4250
+ const text = message.text || "(no text)";
4251
+ if (this.mode === "notification") {
4252
+ await this.showNotification(text);
4253
+ } else {
4254
+ await this.showNotification(text);
4255
+ }
4256
+ this.waitingForResponse = false;
4257
+ this.scheduleInputDialog();
4258
+ return {
4259
+ success: true,
4260
+ messageId: generateId()
4261
+ };
4262
+ } catch (err) {
4263
+ return {
4264
+ success: false,
4265
+ error: err instanceof Error ? err.message : String(err)
4266
+ };
4267
+ }
4268
+ }
4269
+ onMessage(handler) {
4270
+ this.messageHandler = handler;
4271
+ }
4272
+ onPresence(_handler) {
4273
+ }
4274
+ async isHealthy() {
4275
+ if (!this.running) return false;
4276
+ if (process.platform !== "darwin") return false;
4277
+ try {
4278
+ await execFile2("osascript", ["-e", "1"]);
4279
+ return true;
4280
+ } catch {
4281
+ return false;
4282
+ }
4283
+ }
4284
+ // -----------------------------------------------------------------------
4285
+ // Input dialog
4286
+ // -----------------------------------------------------------------------
4287
+ scheduleInputDialog() {
4288
+ if (!this.running || !this.messageHandler || this.waitingForResponse) return;
4289
+ if (this.dialogTimer) {
4290
+ clearTimeout(this.dialogTimer);
4291
+ }
4292
+ this.dialogTimer = setTimeout(() => {
4293
+ this.dialogTimer = null;
4294
+ void this.showInputDialog();
4295
+ }, this.dialogDelay);
4296
+ }
4297
+ async showInputDialog() {
4298
+ if (!this.running || !this.messageHandler) return;
4299
+ try {
4300
+ const script = [
4301
+ `set dialogResult to display dialog "Message for ch4p:" default answer "" with title "${this.escapeAppleScript(this.title)}" buttons {"Cancel", "Send"} default button "Send"`,
4302
+ "return text returned of dialogResult"
4303
+ ].join("\n");
4304
+ const { stdout } = await execFile2("osascript", ["-e", script]);
4305
+ const text = stdout.trim();
4306
+ if (text && this.messageHandler && this.running) {
4307
+ this.waitingForResponse = true;
4308
+ const inbound = {
4309
+ id: generateId(),
4310
+ channelId: this.id,
4311
+ from: {
4312
+ channelId: this.id,
4313
+ userId: "local-user"
4314
+ },
4315
+ text,
4316
+ timestamp: /* @__PURE__ */ new Date()
4317
+ };
4318
+ this.messageHandler(inbound);
4319
+ } else {
4320
+ this.scheduleInputDialog();
4321
+ }
4322
+ } catch (err) {
4323
+ const errMsg = err instanceof Error ? err.message : String(err);
4324
+ if (errMsg.includes("-128") || errMsg.includes("User canceled")) {
4325
+ if (this.running) {
4326
+ this.dialogTimer = setTimeout(() => {
4327
+ this.dialogTimer = null;
4328
+ void this.showInputDialog();
4329
+ }, 5e3);
4330
+ }
4331
+ return;
4332
+ }
4333
+ if (this.running) {
4334
+ this.scheduleInputDialog();
4335
+ }
4336
+ }
4337
+ }
4338
+ // -----------------------------------------------------------------------
4339
+ // Notification
4340
+ // -----------------------------------------------------------------------
4341
+ async showNotification(text) {
4342
+ const displayText = text.length > 500 ? text.slice(0, 497) + "..." : text;
4343
+ const escaped = this.escapeAppleScript(displayText);
4344
+ const escapedTitle = this.escapeAppleScript(this.title);
4345
+ const script = `display notification "${escaped}" with title "${escapedTitle}" sound name "${this.sound}"`;
4346
+ await execFile2("osascript", ["-e", script]);
4347
+ }
4348
+ // -----------------------------------------------------------------------
4349
+ // Helpers
4350
+ // -----------------------------------------------------------------------
4351
+ /** Escape a string for embedding in an AppleScript string literal. */
4352
+ escapeAppleScript(text) {
4353
+ return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
4354
+ }
4355
+ };
4356
+ var ChannelRegistry = class {
4357
+ channels = /* @__PURE__ */ new Map();
4358
+ /** Register a channel instance. Overwrites if id already exists. */
4359
+ register(channel) {
4360
+ this.channels.set(channel.id, channel);
4361
+ }
4362
+ /** Retrieve a channel by id. */
4363
+ get(id) {
4364
+ return this.channels.get(id);
4365
+ }
4366
+ /** Check whether a channel with the given id is registered. */
4367
+ has(id) {
4368
+ return this.channels.has(id);
4369
+ }
4370
+ /** Return all registered channels. */
4371
+ list() {
4372
+ return [...this.channels.values()];
4373
+ }
4374
+ /** Remove all registered channels. */
4375
+ clear() {
4376
+ this.channels.clear();
4377
+ }
4378
+ /**
4379
+ * Look up a channel by id, call `start()` with the supplied config,
4380
+ * and return the started channel.
4381
+ *
4382
+ * Throws if the channel id is not registered.
4383
+ */
4384
+ async createFromConfig(id, config) {
4385
+ const channel = this.channels.get(id);
4386
+ if (!channel) {
4387
+ throw new Error(`Channel "${id}" is not registered`);
4388
+ }
4389
+ await channel.start(config);
4390
+ return channel;
4391
+ }
4392
+ };
4393
+
4394
+ export {
4395
+ CliChannel,
4396
+ TelegramChannel,
4397
+ DiscordChannel,
4398
+ SlackChannel,
4399
+ MatrixChannel,
4400
+ WhatsAppChannel,
4401
+ SignalChannel,
4402
+ IMessageChannel,
4403
+ TeamsChannel,
4404
+ ZaloChannel,
4405
+ ZaloPersonalChannel,
4406
+ BlueBubblesChannel,
4407
+ GoogleChatChannel,
4408
+ WebChatChannel,
4409
+ IrcChannel,
4410
+ MacOSChannel,
4411
+ ChannelRegistry
4412
+ };