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