@qwen-code/channel-wecom 0.21.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1653 @@
1
+ import { constants, lstatSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
2
+ import { open, writeFile } from 'node:fs/promises';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { basename, join, resolve, win32, posix } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { Buffer } from 'node:buffer';
8
+ import { isIP } from 'node:net';
9
+ import { lookup } from 'node:dns/promises';
10
+ import { WSClient, decryptFile } from '@wecom/aibot-node-sdk';
11
+ import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base';
12
+ const ClientCtor = WSClient;
13
+ const MESSAGE_EVENTS = [
14
+ 'message.text',
15
+ 'message.image',
16
+ 'message.mixed',
17
+ 'message.voice',
18
+ 'message.file',
19
+ 'message.video',
20
+ ];
21
+ const SENSITIVE_ERROR_FIELDS = new Set([
22
+ 'secret',
23
+ 'aeskey',
24
+ 'token',
25
+ 'password',
26
+ 'authorization',
27
+ ]);
28
+ const DEDUP_TTL_MS = 5 * 60 * 1000;
29
+ const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
30
+ const MARKDOWN_CHUNK_BYTES = 3800;
31
+ const AUTHENTICATION_TIMEOUT_MS = 30_000;
32
+ const KICK_RECONNECT_MAX_ATTEMPTS = 3;
33
+ const KICK_RECONNECT_MAX_RETRY_CYCLES = 3;
34
+ const KICK_RECONNECT_BASE_DELAY_MS = 1_000;
35
+ const KICK_RECONNECT_RESET_MS = 60_000;
36
+ const KICK_RECONNECT_RETRY_MS = 5 * 60 * 1000;
37
+ const KICK_RECONNECT_LONG_RETRY_MS = 15 * 60 * 1000;
38
+ const DISCONNECT_RECONNECT_FALLBACK_MS = 30_000;
39
+ const ACTIVITY_WATCHDOG_INTERVAL_MS = 60_000;
40
+ const ACTIVITY_STALE_MS = 5 * 60_000;
41
+ export class WeComChannel extends ChannelBase {
42
+ wecom;
43
+ client;
44
+ seenMessages = new Map();
45
+ inFlightMessages = new Set();
46
+ attachmentDirsByMessage = new Map();
47
+ attachmentMessageByDir = new Map();
48
+ attachmentDirsBySession = new Map();
49
+ attachmentDirsWithoutMessageByRoute = new Map();
50
+ bufferedAttachmentMessages = new Set();
51
+ coalescedAttachmentMessages = new Map();
52
+ dedupTimer;
53
+ kickReconnectReset;
54
+ kickReconnectRetry;
55
+ disconnectReconnectFallback;
56
+ activityWatchdog;
57
+ lastActivityAt = 0;
58
+ connecting;
59
+ connectingClient;
60
+ authentication;
61
+ disconnectGeneration = 0;
62
+ clientHandlers;
63
+ reconnectingAfterKick = false;
64
+ pendingKickReconnect = false;
65
+ kickReconnectAttempts = 0;
66
+ kickReconnectRetryCycles = 0;
67
+ constructor(name, config, bridge, options) {
68
+ super(name, config, bridge, options);
69
+ this.wecom = parseWeComConfig(name, config);
70
+ }
71
+ async connect() {
72
+ if (this.client)
73
+ return;
74
+ if (this.connecting)
75
+ return this.connecting;
76
+ const connecting = this.openClient();
77
+ this.connecting = connecting;
78
+ try {
79
+ await connecting;
80
+ }
81
+ finally {
82
+ if (this.connecting === connecting)
83
+ this.connecting = undefined;
84
+ }
85
+ }
86
+ async openClient() {
87
+ const options = {
88
+ botId: this.wecom.botId,
89
+ secret: this.wecom.secret,
90
+ logger: createWeComLogger(this.name),
91
+ };
92
+ if (this.wecom.wsUrl) {
93
+ options.wsUrl = this.wecom.wsUrl;
94
+ }
95
+ const client = new ClientCtor(options);
96
+ let authenticated = false;
97
+ const connectionGeneration = this.disconnectGeneration;
98
+ const messageHandler = (payload) => {
99
+ this.recordActivity();
100
+ if (!authenticated) {
101
+ process.stderr.write(`[WeCom:${this.name}] dropping message before authentication.\n`);
102
+ return;
103
+ }
104
+ this.clearDisconnectReconnectFallback();
105
+ this.onMessage(payload, connectionGeneration).catch((err) => {
106
+ const logMessageId = getLogMessageId(payload);
107
+ process.stderr.write(`[WeCom:${this.name}] message handling failed for ${logMessageId}: ${sanitizeLogText(formatSdkError(err), 200)}\n`);
108
+ });
109
+ };
110
+ const errorHandler = (err) => {
111
+ this.recordActivity();
112
+ process.stderr.write(`[WeCom:${this.name}] SDK error: ${sanitizeLogText(formatSdkError(err), 200)}\n`);
113
+ };
114
+ const disconnectedHandler = (reason) => {
115
+ this.recordActivity();
116
+ if (this.disconnectGeneration !== connectionGeneration)
117
+ return;
118
+ process.stderr.write(`[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`);
119
+ if (authenticated) {
120
+ this.scheduleDisconnectReconnectFallback(reason, client, this.disconnectGeneration);
121
+ }
122
+ };
123
+ const kickedHandler = (reason) => {
124
+ this.recordActivity();
125
+ if (this.disconnectGeneration !== connectionGeneration)
126
+ return;
127
+ this.clearDisconnectReconnectFallback();
128
+ this.startKickReconnect(reason);
129
+ };
130
+ const handlers = {
131
+ message: messageHandler,
132
+ error: errorHandler,
133
+ disconnected: disconnectedHandler,
134
+ kicked: kickedHandler,
135
+ };
136
+ for (const event of MESSAGE_EVENTS) {
137
+ client.on(event, messageHandler);
138
+ }
139
+ client.on('error', errorHandler);
140
+ client.on('disconnected', disconnectedHandler);
141
+ client.on('event.disconnected_event', kickedHandler);
142
+ this.clientHandlers = handlers;
143
+ this.connectingClient = client;
144
+ const authentication = waitForAuthentication(client);
145
+ this.authentication = authentication;
146
+ try {
147
+ authentication.promise.catch(() => { });
148
+ const connected = client.connect();
149
+ const connectedPromise = isPromiseLike(connected)
150
+ ? withTimeout(Promise.resolve(connected).then(() => { }), AUTHENTICATION_TIMEOUT_MS, 'WeCom SDK connect timed out.')
151
+ : Promise.resolve();
152
+ await Promise.all([connectedPromise, authentication.promise]);
153
+ authenticated = true;
154
+ if (this.connectingClient !== client) {
155
+ throw new Error('WeCom connection was replaced before authentication.');
156
+ }
157
+ this.client = client;
158
+ this.connectingClient = undefined;
159
+ this.authentication = undefined;
160
+ this.recordActivity();
161
+ this.startActivityWatchdog(connectionGeneration);
162
+ }
163
+ catch (err) {
164
+ authentication.cancel();
165
+ try {
166
+ this.detachClientHandlers(client, handlers);
167
+ }
168
+ catch {
169
+ // cleanup must not mask the original connection error
170
+ }
171
+ if (this.connectingClient === client)
172
+ this.connectingClient = undefined;
173
+ if (this.authentication === authentication)
174
+ this.authentication = undefined;
175
+ try {
176
+ client.disconnect();
177
+ }
178
+ catch {
179
+ // cleanup must not mask the original connection error
180
+ }
181
+ throw err;
182
+ }
183
+ if (!this.dedupTimer) {
184
+ this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000);
185
+ this.dedupTimer.unref?.();
186
+ }
187
+ process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`);
188
+ }
189
+ disconnect() {
190
+ this.disconnectGeneration += 1;
191
+ this.kickReconnectAttempts = 0;
192
+ this.kickReconnectRetryCycles = 0;
193
+ this.pendingKickReconnect = false;
194
+ if (this.kickReconnectReset) {
195
+ clearTimeout(this.kickReconnectReset);
196
+ this.kickReconnectReset = undefined;
197
+ }
198
+ if (this.kickReconnectRetry) {
199
+ clearTimeout(this.kickReconnectRetry);
200
+ this.kickReconnectRetry = undefined;
201
+ }
202
+ this.clearDisconnectReconnectFallback();
203
+ this.clearActivityWatchdog();
204
+ if (this.dedupTimer) {
205
+ clearInterval(this.dedupTimer);
206
+ this.dedupTimer = undefined;
207
+ }
208
+ this.seenMessages.clear();
209
+ this.inFlightMessages.clear();
210
+ this.cleanupAllAttachmentDirs();
211
+ this.disconnectClientOnly(new Error('WeCom channel disconnected.'));
212
+ process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`);
213
+ }
214
+ supportsProactiveSend() {
215
+ return true;
216
+ }
217
+ async sendMessage(chatId, text) {
218
+ const client = this.client;
219
+ if (!client) {
220
+ throw new Error(`[WeCom:${this.name}] No active SDK client, cannot send.`);
221
+ }
222
+ const { cleanedText, media } = parseOutboundMediaMarkers(text);
223
+ const chunks = splitMarkdownChunks(cleanedText);
224
+ if (chunks.length === 0 && media.length === 0) {
225
+ process.stderr.write(`[WeCom:${this.name}] sendMessage produced empty payload for chatId=${sanitizeLogText(chatId, 100)}.\n`);
226
+ return;
227
+ }
228
+ for (const chunk of chunks) {
229
+ await client.sendMessage(chatId, {
230
+ msgtype: 'markdown',
231
+ markdown: { content: chunk },
232
+ });
233
+ }
234
+ const mediaErrors = [];
235
+ for (const item of media) {
236
+ if (item.type !== 'image') {
237
+ process.stderr.write(`[WeCom:${this.name}] skipping unsupported outbound media marker: ${item.type}\n`);
238
+ continue;
239
+ }
240
+ try {
241
+ const file = await readOutboundMedia(item.path, this.config.cwd);
242
+ const upload = await client.uploadMedia(file.data, {
243
+ type: item.type,
244
+ filename: file.fileName,
245
+ });
246
+ const mediaId = extractMediaId(upload);
247
+ if (!mediaId) {
248
+ mediaErrors.push(`upload returned no media_id for ${item.type}`);
249
+ process.stderr.write(`[WeCom:${this.name}] upload returned no media_id, skipping.\n`);
250
+ continue;
251
+ }
252
+ await client.sendMediaMessage(chatId, item.type, mediaId);
253
+ }
254
+ catch (err) {
255
+ const message = sanitizeLogText(formatSdkError(err), 200);
256
+ mediaErrors.push(`${item.type}: ${message}`);
257
+ process.stderr.write(`[WeCom:${this.name}] media send failed for ${item.type}: ${message}\n`);
258
+ }
259
+ }
260
+ if (mediaErrors.length > 0) {
261
+ const message = `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed (markdown text may already be delivered): ${mediaErrors.join('; ')}`;
262
+ process.stderr.write(`${message}\n`);
263
+ }
264
+ }
265
+ async onMessage(payload, connectionGeneration) {
266
+ const body = extractBody(payload);
267
+ if (!body) {
268
+ process.stderr.write(`[WeCom:${this.name}] dropping message with unrecognized payload structure.\n`);
269
+ return;
270
+ }
271
+ this.logDebugPayload('WeCom', body);
272
+ const rawMessageId = getString(body, 'msgid') || undefined;
273
+ const messageId = rawMessageId ?? `synthetic-${randomUUID()}`;
274
+ const logMessageId = sanitizeLogText(rawMessageId || '(no id)', 100);
275
+ const from = getRecord(body, 'from');
276
+ const senderId = getString(from, 'userid') || '';
277
+ const senderName = getString(from, 'name') || senderId || 'Unknown';
278
+ const isGroup = getString(body, 'chattype') === 'group';
279
+ const rawChatId = getString(body, 'chatid');
280
+ const chatId = isGroup ? rawChatId : rawChatId || senderId;
281
+ if (!chatId || !senderId) {
282
+ process.stderr.write(`[WeCom:${this.name}] dropping message ${logMessageId}: missing ${!senderId ? 'senderId' : 'chatId'}.\n`);
283
+ return;
284
+ }
285
+ if (rawMessageId) {
286
+ if (this.inFlightMessages.has(rawMessageId)) {
287
+ process.stderr.write(`[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already in flight).\n`);
288
+ return;
289
+ }
290
+ if (this.seenMessages.has(rawMessageId)) {
291
+ process.stderr.write(`[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already seen).\n`);
292
+ return;
293
+ }
294
+ this.inFlightMessages.add(rawMessageId);
295
+ }
296
+ const text = extractText(body);
297
+ const quote = getRecord(body, 'quote');
298
+ const envelope = {
299
+ channelName: this.name,
300
+ senderId,
301
+ senderName,
302
+ chatId,
303
+ text,
304
+ messageId: rawMessageId ?? messageId,
305
+ isGroup,
306
+ // WeCom only delivers group callbacks when the intelligent robot is
307
+ // mentioned, so each delivered group message is already mention-scoped.
308
+ isMentioned: true,
309
+ isReplyToBot: getString(getRecord(quote, 'from'), 'userid') === this.wecom.botId,
310
+ referencedText: extractQuoteText(quote),
311
+ };
312
+ let attachments = [];
313
+ const attachmentRouteKey = this.attachmentRouteKey(senderId, chatId, envelope.threadId);
314
+ let processStarted = false;
315
+ try {
316
+ if (!(await this.preflightInbound(envelope))) {
317
+ process.stderr.write(`[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`);
318
+ return;
319
+ }
320
+ attachments = await this.downloadAttachments(body, attachments, messageId, attachmentRouteKey, connectionGeneration);
321
+ if (this.disconnectGeneration !== connectionGeneration) {
322
+ process.stderr.write(`[WeCom:${this.name}] dropping message ${logMessageId}: connection changed during attachment download.\n`);
323
+ return;
324
+ }
325
+ if (attachments.length) {
326
+ envelope.attachments = attachments;
327
+ }
328
+ if (!envelope.text && attachments.length) {
329
+ envelope.text = attachments.some((a) => a.type === 'image')
330
+ ? '(image)'
331
+ : `(file: ${attachments[0]?.fileName ?? 'file'})`;
332
+ }
333
+ if (rawMessageId)
334
+ this.seenMessages.set(rawMessageId, Date.now());
335
+ processStarted = true;
336
+ await this.processInbound(envelope);
337
+ }
338
+ catch (err) {
339
+ if (rawMessageId && !processStarted) {
340
+ this.seenMessages.delete(rawMessageId);
341
+ }
342
+ else if (rawMessageId) {
343
+ process.stderr.write(`[WeCom:${this.name}] message ${logMessageId} failed after processing started; dedup entry retained.\n`);
344
+ }
345
+ throw err;
346
+ }
347
+ finally {
348
+ if (rawMessageId)
349
+ this.inFlightMessages.delete(rawMessageId);
350
+ if (messageId &&
351
+ !this.bufferedAttachmentMessages.has(messageId) &&
352
+ this.attachmentDirsByMessage.has(messageId)) {
353
+ this.cleanupAttachmentDirsForMessage(messageId);
354
+ }
355
+ }
356
+ }
357
+ async downloadAttachments(body, attachments = [], messageId, routeKey, connectionGeneration = this.disconnectGeneration) {
358
+ const refs = collectInboundMediaRefs(body);
359
+ for (const ref of refs) {
360
+ if (this.disconnectGeneration !== connectionGeneration)
361
+ return attachments;
362
+ let downloaded;
363
+ try {
364
+ downloaded = await downloadInboundMedia(ref);
365
+ }
366
+ catch (err) {
367
+ process.stderr.write(`[WeCom:${this.name}] skipping ${ref.type} attachment: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 160)}.\n`);
368
+ continue;
369
+ }
370
+ if (this.disconnectGeneration !== connectionGeneration)
371
+ return attachments;
372
+ const data = downloaded.buffer;
373
+ const fileName = sanitizeFileName(ref.fileName || downloaded.filename);
374
+ if (ref.type === 'image') {
375
+ attachments.push({
376
+ type: 'image',
377
+ data: data.toString('base64'),
378
+ mimeType: detectImageMime(data),
379
+ fileName,
380
+ });
381
+ }
382
+ else {
383
+ const dir = join(tmpdir(), 'channel-files', randomUUID());
384
+ const safeName = fileName || `wecom_${ref.type}`;
385
+ const filePath = join(dir, safeName);
386
+ try {
387
+ if (this.disconnectGeneration !== connectionGeneration) {
388
+ return attachments;
389
+ }
390
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
391
+ await writeFile(filePath, data, { mode: 0o600 });
392
+ if (this.disconnectGeneration !== connectionGeneration) {
393
+ cleanupAttachmentDirs([dir]);
394
+ return attachments;
395
+ }
396
+ this.rememberAttachmentDir(dir, messageId, routeKey);
397
+ }
398
+ catch (err) {
399
+ cleanupAttachmentDirs([dir]);
400
+ process.stderr.write(`[WeCom:${this.name}] skipping ${ref.type} attachment: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 160)}.\n`);
401
+ continue;
402
+ }
403
+ attachments.push({
404
+ type: ref.type === 'voice' ? 'audio' : ref.type,
405
+ filePath,
406
+ mimeType: mediaTypeToMime(ref.type),
407
+ fileName: safeName,
408
+ });
409
+ }
410
+ }
411
+ return attachments;
412
+ }
413
+ onPromptBuffered(_chatId, sessionId, messageId) {
414
+ if (!messageId) {
415
+ this.rememberUntrackedDirsForSession(sessionId);
416
+ return;
417
+ }
418
+ this.bufferedAttachmentMessages.add(messageId);
419
+ this.rememberMessageDirsForSession(messageId, sessionId);
420
+ }
421
+ onPromptStart(_chatId, sessionId, messageId) {
422
+ if (messageId) {
423
+ this.rememberMessageDirsForSession(messageId, sessionId);
424
+ }
425
+ else {
426
+ this.rememberUntrackedDirsForSession(sessionId);
427
+ }
428
+ }
429
+ onPromptBufferDrained(_chatId, _sessionId, messageIds) {
430
+ const lastMessageId = messageIds.at(-1);
431
+ if (lastMessageId) {
432
+ this.coalescedAttachmentMessages.set(lastMessageId, messageIds);
433
+ }
434
+ }
435
+ onPromptBufferDropped(_chatId, sessionId, messageIds) {
436
+ for (const messageId of messageIds) {
437
+ this.cleanupAttachmentDirsForMessage(messageId);
438
+ }
439
+ this.cleanupUntrackedAttachmentDirsForSession(sessionId);
440
+ }
441
+ onPromptEnd(_chatId, sessionId, messageId) {
442
+ if (!messageId) {
443
+ this.cleanupAttachmentDirsForSession(sessionId);
444
+ return;
445
+ }
446
+ const coalescedMessageIds = this.coalescedAttachmentMessages.get(messageId);
447
+ if (coalescedMessageIds) {
448
+ this.coalescedAttachmentMessages.delete(messageId);
449
+ for (const coalescedMessageId of coalescedMessageIds) {
450
+ this.cleanupAttachmentDirsForMessage(coalescedMessageId);
451
+ }
452
+ this.cleanupUntrackedAttachmentDirsForSession(sessionId);
453
+ return;
454
+ }
455
+ this.cleanupAttachmentDirsForMessage(messageId);
456
+ }
457
+ rememberAttachmentDir(dir, messageId, routeKey) {
458
+ if (messageId) {
459
+ const messageDirs = this.attachmentDirsByMessage.get(messageId) ?? [];
460
+ messageDirs.push(dir);
461
+ this.attachmentDirsByMessage.set(messageId, messageDirs);
462
+ this.attachmentMessageByDir.set(dir, messageId);
463
+ }
464
+ else if (routeKey) {
465
+ const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey) ?? [];
466
+ dirs.push(dir);
467
+ this.attachmentDirsWithoutMessageByRoute.set(routeKey, dirs);
468
+ }
469
+ }
470
+ rememberMessageDirsForSession(messageId, sessionId) {
471
+ const dirs = this.attachmentDirsByMessage.get(messageId);
472
+ if (!dirs)
473
+ return;
474
+ const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? [];
475
+ for (const dir of dirs) {
476
+ if (!sessionDirs.includes(dir))
477
+ sessionDirs.push(dir);
478
+ }
479
+ this.attachmentDirsBySession.set(sessionId, sessionDirs);
480
+ }
481
+ cleanupAttachmentDirsForMessage(messageId) {
482
+ this.bufferedAttachmentMessages.delete(messageId);
483
+ const dirs = this.attachmentDirsByMessage.get(messageId);
484
+ if (!dirs)
485
+ return;
486
+ this.attachmentDirsByMessage.delete(messageId);
487
+ for (const dir of dirs) {
488
+ this.attachmentMessageByDir.delete(dir);
489
+ }
490
+ this.removeAttachmentDirsFromSessions(dirs);
491
+ cleanupAttachmentDirs(dirs);
492
+ }
493
+ cleanupAttachmentDirsForSession(sessionId) {
494
+ const dirs = this.attachmentDirsBySession.get(sessionId);
495
+ if (!dirs)
496
+ return;
497
+ this.attachmentDirsBySession.delete(sessionId);
498
+ this.removeAttachmentDirsFromMessages(dirs);
499
+ for (const dir of dirs) {
500
+ this.attachmentMessageByDir.delete(dir);
501
+ }
502
+ cleanupAttachmentDirs(dirs);
503
+ }
504
+ cleanupUntrackedAttachmentDirsForSession(sessionId) {
505
+ const dirs = this.attachmentDirsBySession.get(sessionId);
506
+ if (!dirs)
507
+ return;
508
+ const untrackedDirs = dirs.filter((dir) => !this.attachmentMessageByDir.has(dir));
509
+ if (untrackedDirs.length === 0)
510
+ return;
511
+ const remainingDirs = dirs.filter((dir) => this.attachmentMessageByDir.has(dir));
512
+ if (remainingDirs.length > 0) {
513
+ this.attachmentDirsBySession.set(sessionId, remainingDirs);
514
+ }
515
+ else {
516
+ this.attachmentDirsBySession.delete(sessionId);
517
+ }
518
+ cleanupAttachmentDirs(untrackedDirs);
519
+ }
520
+ rememberUntrackedDirsForSession(sessionId) {
521
+ const routeKey = this.attachmentRouteKeyForSession(sessionId);
522
+ if (!routeKey)
523
+ return;
524
+ const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey);
525
+ if (!dirs || dirs.length === 0)
526
+ return;
527
+ const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? [];
528
+ for (const dir of dirs) {
529
+ if (!sessionDirs.includes(dir))
530
+ sessionDirs.push(dir);
531
+ }
532
+ this.attachmentDirsBySession.set(sessionId, sessionDirs);
533
+ this.attachmentDirsWithoutMessageByRoute.delete(routeKey);
534
+ }
535
+ removeAttachmentDirsFromSessions(dirs) {
536
+ const removed = new Set(dirs);
537
+ for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) {
538
+ const remaining = sessionDirs.filter((dir) => !removed.has(dir));
539
+ if (remaining.length) {
540
+ this.attachmentDirsBySession.set(sessionId, remaining);
541
+ }
542
+ else {
543
+ this.attachmentDirsBySession.delete(sessionId);
544
+ }
545
+ }
546
+ }
547
+ removeAttachmentDirsFromMessages(dirs) {
548
+ const removed = new Set(dirs);
549
+ for (const [messageId, messageDirs] of this.attachmentDirsByMessage) {
550
+ const remaining = messageDirs.filter((dir) => !removed.has(dir));
551
+ if (remaining.length) {
552
+ this.attachmentDirsByMessage.set(messageId, remaining);
553
+ }
554
+ else {
555
+ this.attachmentDirsByMessage.delete(messageId);
556
+ this.bufferedAttachmentMessages.delete(messageId);
557
+ }
558
+ }
559
+ for (const dir of dirs) {
560
+ this.attachmentMessageByDir.delete(dir);
561
+ }
562
+ }
563
+ cleanupAllAttachmentDirs() {
564
+ const dirs = Array.from(new Set([
565
+ ...Array.from(this.attachmentDirsBySession.values()).flat(),
566
+ ...Array.from(this.attachmentDirsByMessage.values()).flat(),
567
+ ...Array.from(this.attachmentDirsWithoutMessageByRoute.values()).flat(),
568
+ ]));
569
+ this.attachmentDirsBySession.clear();
570
+ this.attachmentDirsByMessage.clear();
571
+ this.attachmentMessageByDir.clear();
572
+ this.attachmentDirsWithoutMessageByRoute.clear();
573
+ this.bufferedAttachmentMessages.clear();
574
+ this.coalescedAttachmentMessages.clear();
575
+ cleanupAttachmentDirs(dirs);
576
+ }
577
+ attachmentRouteKeyForSession(sessionId) {
578
+ const target = this.router.getTarget(sessionId);
579
+ if (!target || target.channelName !== this.name)
580
+ return undefined;
581
+ return this.attachmentRouteKey(target.senderId, target.chatId, target.threadId);
582
+ }
583
+ attachmentRouteKey(senderId, chatId, threadId) {
584
+ switch (this.config.sessionScope) {
585
+ case 'thread':
586
+ return `${this.name}:${threadId || chatId}`;
587
+ case 'single':
588
+ return `${this.name}:__single__`;
589
+ case 'user':
590
+ default:
591
+ return `${this.name}:${senderId}:${chatId}`;
592
+ }
593
+ }
594
+ detachClientHandlers(client, handlers = this.clientHandlers) {
595
+ if (!handlers)
596
+ return;
597
+ for (const event of MESSAGE_EVENTS) {
598
+ client.off?.(event, handlers.message);
599
+ }
600
+ client.off?.('error', handlers.error);
601
+ client.off?.('disconnected', handlers.disconnected);
602
+ client.off?.('event.disconnected_event', handlers.kicked);
603
+ if (this.clientHandlers === handlers)
604
+ this.clientHandlers = undefined;
605
+ }
606
+ disconnectClientOnly(err) {
607
+ const client = this.client ?? this.connectingClient;
608
+ this.authentication?.cancel(err);
609
+ this.authentication = undefined;
610
+ this.client = undefined;
611
+ this.connectingClient = undefined;
612
+ this.clearActivityWatchdog();
613
+ if (client)
614
+ this.detachClientHandlers(client);
615
+ try {
616
+ client?.disconnect();
617
+ }
618
+ catch (e) {
619
+ process.stderr.write(`[WeCom:${this.name}] client.disconnect() threw: ${sanitizeLogText(formatSdkError(e), 200)}\n`);
620
+ }
621
+ }
622
+ clearDisconnectReconnectFallback() {
623
+ if (!this.disconnectReconnectFallback)
624
+ return;
625
+ clearTimeout(this.disconnectReconnectFallback);
626
+ this.disconnectReconnectFallback = undefined;
627
+ }
628
+ recordActivity() {
629
+ this.lastActivityAt = Date.now();
630
+ }
631
+ clearActivityWatchdog() {
632
+ if (!this.activityWatchdog)
633
+ return;
634
+ clearInterval(this.activityWatchdog);
635
+ this.activityWatchdog = undefined;
636
+ }
637
+ startActivityWatchdog(disconnectGeneration) {
638
+ this.clearActivityWatchdog();
639
+ this.activityWatchdog = setInterval(() => {
640
+ if (this.disconnectGeneration !== disconnectGeneration)
641
+ return;
642
+ if (!this.client || this.reconnectingAfterKick)
643
+ return;
644
+ if (Date.now() - this.lastActivityAt < ACTIVITY_STALE_MS)
645
+ return;
646
+ process.stderr.write(`[WeCom:${this.name}] no SDK activity for ${ACTIVITY_STALE_MS / 60_000} minutes; reconnecting adapter.\n`);
647
+ this.kickReconnectAttempts = 0;
648
+ this.kickReconnectRetryCycles = 0;
649
+ this.startKickReconnect(new Error('WeCom SDK activity watchdog timed out.'), 'activity watchdog');
650
+ }, ACTIVITY_WATCHDOG_INTERVAL_MS);
651
+ this.activityWatchdog.unref?.();
652
+ }
653
+ scheduleDisconnectReconnectFallback(reason, client, disconnectGeneration) {
654
+ this.clearDisconnectReconnectFallback();
655
+ const formattedReason = formatDisconnectReason(reason);
656
+ this.disconnectReconnectFallback = setTimeout(() => {
657
+ this.disconnectReconnectFallback = undefined;
658
+ if (this.disconnectGeneration !== disconnectGeneration)
659
+ return;
660
+ if (this.client !== client)
661
+ return;
662
+ process.stderr.write(`[WeCom:${this.name}] SDK reconnect did not recover after WebSocket ${formattedReason}; reconnecting adapter.\n`);
663
+ this.kickReconnectAttempts = 0;
664
+ this.kickReconnectRetryCycles = 0;
665
+ this.startKickReconnect(reason, 'SDK disconnect');
666
+ }, DISCONNECT_RECONNECT_FALLBACK_MS);
667
+ this.disconnectReconnectFallback.unref?.();
668
+ }
669
+ async reconnectAfterKick(reason, reconnectReason = 'server kick') {
670
+ if (this.reconnectingAfterKick) {
671
+ this.pendingKickReconnect = true;
672
+ return;
673
+ }
674
+ if (this.kickReconnectRetry) {
675
+ clearTimeout(this.kickReconnectRetry);
676
+ this.kickReconnectRetry = undefined;
677
+ this.kickReconnectAttempts = 0;
678
+ }
679
+ if (this.kickReconnectReset) {
680
+ clearTimeout(this.kickReconnectReset);
681
+ this.kickReconnectReset = undefined;
682
+ }
683
+ this.reconnectingAfterKick = true;
684
+ const previousConnecting = this.connecting;
685
+ const disconnectGeneration = this.disconnectGeneration;
686
+ process.stderr.write(`[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after ${reconnectReason}.\n`);
687
+ try {
688
+ this.disconnectClientOnly(new Error(`WeCom connection was kicked: ${formatDisconnectReason(reason)}`));
689
+ if (previousConnecting) {
690
+ await previousConnecting.catch(() => { });
691
+ }
692
+ while (this.kickReconnectAttempts < KICK_RECONNECT_MAX_ATTEMPTS) {
693
+ const attempt = ++this.kickReconnectAttempts;
694
+ await delay(KICK_RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
695
+ if (this.disconnectGeneration !== disconnectGeneration) {
696
+ process.stderr.write(`[WeCom:${this.name}] reconnect after ${reconnectReason} abandoned: connection generation changed.\n`);
697
+ return;
698
+ }
699
+ try {
700
+ await this.connect();
701
+ if (this.disconnectGeneration !== disconnectGeneration) {
702
+ process.stderr.write(`[WeCom:${this.name}] reconnect after ${reconnectReason} abandoned: connection generation changed.\n`);
703
+ return;
704
+ }
705
+ this.kickReconnectAttempts = 0;
706
+ this.kickReconnectRetryCycles = 0;
707
+ this.scheduleKickReconnectReset();
708
+ process.stderr.write(`[WeCom:${this.name}] reconnected after ${reconnectReason}.\n`);
709
+ return;
710
+ }
711
+ catch (err) {
712
+ process.stderr.write(`[WeCom:${this.name}] reconnect after ${reconnectReason} attempt ${attempt} failed: ${sanitizeLogText(formatSdkError(err), 200)}\n`);
713
+ }
714
+ }
715
+ this.kickReconnectRetryCycles += 1;
716
+ if (this.kickReconnectRetryCycles >= KICK_RECONNECT_MAX_RETRY_CYCLES) {
717
+ process.stderr.write(`[WeCom:${this.name}] reconnect after ${reconnectReason} exhausted ${this.kickReconnectRetryCycles} retry cycles; next attempt in ${KICK_RECONNECT_LONG_RETRY_MS / 60_000} minutes.\n`);
718
+ this.scheduleKickReconnectRetry(reason, disconnectGeneration, KICK_RECONNECT_LONG_RETRY_MS, reconnectReason, true);
719
+ return;
720
+ }
721
+ process.stderr.write(`[WeCom:${this.name}] reconnect after ${reconnectReason} gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts; retrying later.\n`);
722
+ this.scheduleKickReconnectRetry(reason, disconnectGeneration, KICK_RECONNECT_RETRY_MS, reconnectReason);
723
+ }
724
+ finally {
725
+ this.reconnectingAfterKick = false;
726
+ const shouldRetryPendingKick = this.pendingKickReconnect &&
727
+ this.disconnectGeneration === disconnectGeneration;
728
+ this.pendingKickReconnect = false;
729
+ if (shouldRetryPendingKick && !this.client) {
730
+ this.kickReconnectAttempts = 0;
731
+ this.startKickReconnect(reason, reconnectReason);
732
+ }
733
+ }
734
+ }
735
+ scheduleKickReconnectReset() {
736
+ if (this.kickReconnectRetry) {
737
+ clearTimeout(this.kickReconnectRetry);
738
+ this.kickReconnectRetry = undefined;
739
+ }
740
+ if (this.kickReconnectReset)
741
+ clearTimeout(this.kickReconnectReset);
742
+ this.kickReconnectReset = setTimeout(() => {
743
+ this.kickReconnectAttempts = 0;
744
+ this.kickReconnectRetryCycles = 0;
745
+ this.kickReconnectReset = undefined;
746
+ }, KICK_RECONNECT_RESET_MS);
747
+ this.kickReconnectReset.unref?.();
748
+ }
749
+ scheduleKickReconnectRetry(reason, disconnectGeneration, delayMs = KICK_RECONNECT_RETRY_MS, reconnectReason = 'server kick', resetRetryCycles = false) {
750
+ this.kickReconnectRetry = setTimeout(() => {
751
+ this.kickReconnectRetry = undefined;
752
+ if (this.disconnectGeneration !== disconnectGeneration) {
753
+ process.stderr.write(`[WeCom:${this.name}] scheduled kick-reconnect cancelled; connection generation changed.\n`);
754
+ return;
755
+ }
756
+ this.kickReconnectAttempts = 0;
757
+ if (resetRetryCycles)
758
+ this.kickReconnectRetryCycles = 0;
759
+ this.startKickReconnect(reason, reconnectReason);
760
+ }, delayMs);
761
+ this.kickReconnectRetry.unref?.();
762
+ }
763
+ startKickReconnect(reason, reconnectReason = 'server kick') {
764
+ void this.reconnectAfterKick(reason, reconnectReason).catch((err) => {
765
+ process.stderr.write(`[WeCom:${this.name}] kick-reconnect failed: ${sanitizeLogText(formatSdkError(err), 200)}\n`);
766
+ if (this.kickReconnectRetry === undefined) {
767
+ this.scheduleKickReconnectRetry(reason, this.disconnectGeneration, KICK_RECONNECT_LONG_RETRY_MS, reconnectReason, true);
768
+ }
769
+ });
770
+ }
771
+ cleanupSeenMessages() {
772
+ const now = Date.now();
773
+ for (const [id, ts] of this.seenMessages) {
774
+ if (now - ts > DEDUP_TTL_MS) {
775
+ this.seenMessages.delete(id);
776
+ }
777
+ }
778
+ }
779
+ }
780
+ function waitForAuthentication(client) {
781
+ let timeout;
782
+ let settled = false;
783
+ let finish = () => { };
784
+ const onAuth = () => finish();
785
+ const onError = (err) => finish(new Error(`WeCom authentication failed: ${sanitizeLogText(String(err), 200)}`));
786
+ const onKicked = (reason) => finish(new Error(`WeCom authentication interrupted by server kick: ${formatDisconnectReason(reason)}`));
787
+ const promise = new Promise((resolvePromise, rejectPromise) => {
788
+ finish = (err) => {
789
+ if (settled)
790
+ return;
791
+ settled = true;
792
+ if (timeout)
793
+ clearTimeout(timeout);
794
+ client.off?.('authenticated', onAuth);
795
+ client.off?.('error', onError);
796
+ client.off?.('event.disconnected_event', onKicked);
797
+ if (err) {
798
+ rejectPromise(err);
799
+ }
800
+ else {
801
+ resolvePromise();
802
+ }
803
+ };
804
+ timeout = setTimeout(() => {
805
+ finish(new Error('WeCom authentication timed out.'));
806
+ }, AUTHENTICATION_TIMEOUT_MS);
807
+ timeout.unref?.();
808
+ client.on('authenticated', onAuth);
809
+ client.on('error', onError);
810
+ client.on('event.disconnected_event', onKicked);
811
+ });
812
+ return {
813
+ promise,
814
+ cancel: (err) => finish(err),
815
+ };
816
+ }
817
+ function withTimeout(promise, timeoutMs, message) {
818
+ let timeout;
819
+ return new Promise((resolvePromise, rejectPromise) => {
820
+ timeout = setTimeout(() => {
821
+ rejectPromise(new Error(message));
822
+ }, timeoutMs);
823
+ timeout.unref?.();
824
+ promise.then((value) => {
825
+ if (timeout)
826
+ clearTimeout(timeout);
827
+ resolvePromise(value);
828
+ }, (err) => {
829
+ if (timeout)
830
+ clearTimeout(timeout);
831
+ rejectPromise(err);
832
+ });
833
+ });
834
+ }
835
+ function delay(ms) {
836
+ return new Promise((resolveDelay) => {
837
+ const timer = setTimeout(resolveDelay, ms);
838
+ timer.unref?.();
839
+ });
840
+ }
841
+ function cleanupAttachmentDirs(dirs) {
842
+ for (const dir of dirs) {
843
+ try {
844
+ rmSync(dir, { recursive: true, force: true });
845
+ }
846
+ catch (err) {
847
+ process.stderr.write(`[WeCom] failed to remove attachment dir ${sanitizeLogText(dir, 200)}: ${sanitizeLogText(formatSdkError(err), 200)}.\n`);
848
+ }
849
+ }
850
+ }
851
+ function formatDisconnectReason(reason) {
852
+ const text = typeof reason === 'string' && reason ? reason : formatSdkError(reason);
853
+ return sanitizeLogText(text, 120);
854
+ }
855
+ function formatSdkError(err) {
856
+ if (err instanceof Error)
857
+ return err.message;
858
+ const record = asRecord(err);
859
+ if (record) {
860
+ const errcode = record['errcode'];
861
+ const errmsg = record['errmsg'];
862
+ if (typeof errcode === 'number' || typeof errmsg === 'string') {
863
+ return redactSensitiveErrorText(`errcode=${String(errcode)} errmsg=${String(errmsg)}`);
864
+ }
865
+ const code = record['code'];
866
+ const reason = record['reason'];
867
+ const wasClean = record['wasClean'];
868
+ if (typeof code === 'number' ||
869
+ typeof reason === 'string' ||
870
+ typeof wasClean === 'boolean') {
871
+ return [
872
+ typeof code === 'number' ? `code=${code}` : undefined,
873
+ typeof reason === 'string'
874
+ ? `reason=${redactSensitiveErrorText(reason)}`
875
+ : undefined,
876
+ typeof wasClean === 'boolean' ? `wasClean=${wasClean}` : undefined,
877
+ ]
878
+ .filter((part) => Boolean(part))
879
+ .join(' ');
880
+ }
881
+ try {
882
+ return JSON.stringify(record, (key, value) => SENSITIVE_ERROR_FIELDS.has(key.toLowerCase()) ? '[REDACTED]' : value);
883
+ }
884
+ catch {
885
+ // Fall through to String below.
886
+ }
887
+ }
888
+ return String(err);
889
+ }
890
+ function redactSensitiveErrorText(text) {
891
+ return text.replace(/(["']?(?:secret|aeskey|token|password|authorization)["']?\s*[:=]\s*)(["']?)[^"',\s}]+(\2)/giu, '$1$2[REDACTED]$3');
892
+ }
893
+ function isPromiseLike(value) {
894
+ return (value !== null &&
895
+ (typeof value === 'object' || typeof value === 'function') &&
896
+ 'then' in value &&
897
+ typeof value.then === 'function');
898
+ }
899
+ function parseWeComConfig(name, config) {
900
+ const botId = readRequiredString(config, 'botId');
901
+ const secret = readRequiredString(config, 'secret');
902
+ if (!botId || !secret) {
903
+ throw new Error(`Channel "${name}" requires botId and secret for WeCom.`);
904
+ }
905
+ const wsUrl = readOptionalString(config, 'wsUrl');
906
+ if (wsUrl && !isSecureWebSocketUrl(wsUrl)) {
907
+ throw new Error(`Channel "${name}" requires wsUrl to use wss://.`);
908
+ }
909
+ return wsUrl ? { botId, secret, wsUrl } : { botId, secret };
910
+ }
911
+ function readRequiredString(config, key) {
912
+ const value = config[key];
913
+ return typeof value === 'string' ? value.trim() : '';
914
+ }
915
+ function readOptionalString(config, key) {
916
+ const value = config[key];
917
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
918
+ }
919
+ function isSecureWebSocketUrl(value) {
920
+ try {
921
+ return new URL(value).protocol === 'wss:';
922
+ }
923
+ catch {
924
+ return false;
925
+ }
926
+ }
927
+ function createWeComLogger(name) {
928
+ const write = (level, message) => {
929
+ process.stderr.write(`[WeCom:${name}] SDK ${level}: ${sanitizeLogText(message, 200)}\n`);
930
+ };
931
+ return {
932
+ debug: () => { },
933
+ info: () => { },
934
+ warn: (message) => write('warn', message),
935
+ error: (message) => write('error', message),
936
+ };
937
+ }
938
+ function extractBody(payload) {
939
+ const raw = asRecord(payload);
940
+ if (!raw)
941
+ return undefined;
942
+ return getRecord(raw, 'body') ?? raw;
943
+ }
944
+ function getLogMessageId(payload) {
945
+ const body = extractBody(payload);
946
+ if (!body)
947
+ return '(unknown id)';
948
+ return sanitizeLogText(getString(body, 'msgid') || '(no id)', 100);
949
+ }
950
+ function extractText(body) {
951
+ const msgType = getString(body, 'msgtype');
952
+ if (msgType === 'mixed') {
953
+ const mixed = getRecord(body, 'mixed');
954
+ const items = getArray(mixed, 'msg_item');
955
+ return items
956
+ .map((item) => {
957
+ const record = asRecord(item);
958
+ if (!record)
959
+ return '';
960
+ const itemType = getString(record, 'msgtype');
961
+ if (itemType === 'text') {
962
+ return getString(getRecord(record, 'text'), 'content');
963
+ }
964
+ if (itemType === 'voice') {
965
+ return getString(getRecord(record, 'voice'), 'content');
966
+ }
967
+ return '';
968
+ })
969
+ .filter(Boolean)
970
+ .join('\n')
971
+ .trim();
972
+ }
973
+ const text = getString(getRecord(body, 'text'), 'content');
974
+ const voiceText = getString(getRecord(body, 'voice'), 'content');
975
+ if (text)
976
+ return text;
977
+ if (voiceText)
978
+ return voiceText;
979
+ if (msgType === 'image')
980
+ return '(image)';
981
+ if (msgType === 'voice')
982
+ return '(voice)';
983
+ if (msgType === 'video')
984
+ return '(video)';
985
+ if (msgType === 'file') {
986
+ const name = sanitizeFileName(getString(getRecord(body, 'file'), 'filename'));
987
+ return `(file: ${name || 'file'})`;
988
+ }
989
+ return '';
990
+ }
991
+ function extractQuoteText(quote) {
992
+ if (!quote)
993
+ return undefined;
994
+ return extractText(quote) || undefined;
995
+ }
996
+ function collectInboundMediaRefs(body, depth = 0, seenUrls = new Set()) {
997
+ if (depth > 3)
998
+ return [];
999
+ const refs = [];
1000
+ const add = (type, source) => {
1001
+ const url = getString(source, 'url');
1002
+ if (!url || seenUrls.has(url))
1003
+ return;
1004
+ seenUrls.add(url);
1005
+ refs.push({
1006
+ type,
1007
+ url,
1008
+ aesKey: getString(source, 'aeskey') || undefined,
1009
+ fileName: getString(source, 'filename') ||
1010
+ getString(source, 'file_name') ||
1011
+ undefined,
1012
+ });
1013
+ };
1014
+ const mixed = getRecord(body, 'mixed');
1015
+ for (const item of getArray(mixed, 'msg_item')) {
1016
+ const record = asRecord(item);
1017
+ if (!record)
1018
+ continue;
1019
+ const itemType = getString(record, 'msgtype');
1020
+ if (isWeComMediaType(itemType)) {
1021
+ add(itemType, getRecord(record, itemType) ?? {});
1022
+ }
1023
+ }
1024
+ add('image', getRecord(body, 'image') ?? {});
1025
+ add('file', getRecord(body, 'file') ?? {});
1026
+ add('video', getRecord(body, 'video') ?? {});
1027
+ add('voice', getRecord(body, 'voice') ?? {});
1028
+ const quote = getRecord(body, 'quote');
1029
+ if (quote)
1030
+ refs.push(...collectInboundMediaRefs(quote, depth + 1, seenUrls));
1031
+ return refs;
1032
+ }
1033
+ function parseOutboundMediaMarkers(text) {
1034
+ const codeRanges = findCodeRanges(text);
1035
+ const markerRe = /\[(IMAGE):\s*([^\]]+)\]/gi;
1036
+ const media = [];
1037
+ const rangesToRemove = [];
1038
+ for (const match of text.matchAll(markerRe)) {
1039
+ const start = match.index ?? 0;
1040
+ const end = start + match[0].length;
1041
+ if (codeRanges.some(([from, to]) => start >= from && start < to))
1042
+ continue;
1043
+ const path = match[2]?.trim();
1044
+ const rawType = match[1]?.toLowerCase();
1045
+ if (!path || !isWeComMediaType(rawType))
1046
+ continue;
1047
+ media.push({ type: rawType, path });
1048
+ rangesToRemove.push([start, end]);
1049
+ }
1050
+ let cleanedText = text;
1051
+ for (const [start, end] of rangesToRemove.toReversed()) {
1052
+ cleanedText = `${cleanedText.slice(0, start)}${cleanedText.slice(end)}`;
1053
+ }
1054
+ return {
1055
+ cleanedText: cleanedText.replace(/\n{3,}/g, '\n\n').trim(),
1056
+ media,
1057
+ };
1058
+ }
1059
+ function findCodeRanges(text) {
1060
+ const ranges = [];
1061
+ let fenceStart;
1062
+ let fenceToken;
1063
+ for (const match of text.matchAll(/```|~~~/g)) {
1064
+ const start = match.index ?? 0;
1065
+ const token = match[0];
1066
+ if (fenceStart === undefined) {
1067
+ fenceStart = start;
1068
+ fenceToken = token;
1069
+ }
1070
+ else if (token === fenceToken) {
1071
+ ranges.push([fenceStart, start + 3]);
1072
+ fenceStart = undefined;
1073
+ fenceToken = undefined;
1074
+ }
1075
+ }
1076
+ if (fenceStart !== undefined) {
1077
+ ranges.push([fenceStart, text.length]);
1078
+ }
1079
+ for (const match of text.matchAll(/(`+)[^`\n]*\1/g)) {
1080
+ const start = match.index ?? 0;
1081
+ if (ranges.some(([from, to]) => start >= from && start < to))
1082
+ continue;
1083
+ ranges.push([start, start + match[0].length]);
1084
+ }
1085
+ const lineRe = /^(?: {4,}|\t).*$/gm;
1086
+ for (const match of text.matchAll(lineRe)) {
1087
+ const start = match.index ?? 0;
1088
+ if (ranges.some(([from, to]) => start >= from && start < to))
1089
+ continue;
1090
+ ranges.push([start, start + match[0].length]);
1091
+ }
1092
+ return ranges;
1093
+ }
1094
+ function isWeComMediaType(value) {
1095
+ return (value === 'image' ||
1096
+ value === 'file' ||
1097
+ value === 'voice' ||
1098
+ value === 'video');
1099
+ }
1100
+ function splitMarkdownChunks(text) {
1101
+ if (!text)
1102
+ return [];
1103
+ const chunks = [];
1104
+ let current = '';
1105
+ let codeFence;
1106
+ const fits = (value, nextCodeFence = codeFence) => Buffer.byteLength(nextCodeFence ? `${value}\n${nextCodeFence}` : value, 'utf8') <= MARKDOWN_CHUNK_BYTES;
1107
+ const flush = (closeCode = true) => {
1108
+ if (!current)
1109
+ return;
1110
+ chunks.push(closeCode && codeFence ? `${current}\n${codeFence}` : current);
1111
+ current = closeCode && codeFence ? codeFence : '';
1112
+ };
1113
+ for (const line of text.split('\n')) {
1114
+ const candidate = current ? `${current}\n${line}` : line;
1115
+ const candidateCodeFence = toggleCodeFenceState(line, codeFence);
1116
+ if (fits(candidate, candidateCodeFence)) {
1117
+ current = candidate;
1118
+ codeFence = candidateCodeFence;
1119
+ continue;
1120
+ }
1121
+ flush();
1122
+ const retried = current ? `${current}\n${line}` : line;
1123
+ const retriedCodeFence = toggleCodeFenceState(line, codeFence);
1124
+ if (fits(retried, retriedCodeFence)) {
1125
+ current = retried;
1126
+ codeFence = retriedCodeFence;
1127
+ continue;
1128
+ }
1129
+ let needsLineBreak = Boolean(current);
1130
+ for (let index = 0; index < line.length;) {
1131
+ const codePoint = line.codePointAt(index);
1132
+ const token = line.startsWith('```', index)
1133
+ ? '```'
1134
+ : line.startsWith('~~~', index)
1135
+ ? '~~~'
1136
+ : codePoint === undefined
1137
+ ? ''
1138
+ : String.fromCodePoint(codePoint);
1139
+ if (!token)
1140
+ break;
1141
+ const nextCodeFence = token === codeFence
1142
+ ? undefined
1143
+ : !codeFence && isFenceToken(token)
1144
+ ? token
1145
+ : codeFence;
1146
+ const addition = needsLineBreak && current ? `\n${token}` : token;
1147
+ const candidate = `${current}${addition}`;
1148
+ if (!fits(candidate, nextCodeFence)) {
1149
+ flush();
1150
+ current = current ? `${current}\n${token}` : token;
1151
+ }
1152
+ else {
1153
+ current = candidate;
1154
+ }
1155
+ codeFence = nextCodeFence;
1156
+ needsLineBreak = false;
1157
+ index += token.length;
1158
+ }
1159
+ }
1160
+ flush();
1161
+ return chunks;
1162
+ }
1163
+ function toggleCodeFenceState(line, codeFence) {
1164
+ let nextCodeFence = codeFence;
1165
+ for (const match of line.matchAll(/```|~~~/g)) {
1166
+ const token = match[0];
1167
+ if (nextCodeFence === token) {
1168
+ nextCodeFence = undefined;
1169
+ }
1170
+ else if (!nextCodeFence) {
1171
+ nextCodeFence = token;
1172
+ }
1173
+ }
1174
+ return nextCodeFence;
1175
+ }
1176
+ function isFenceToken(token) {
1177
+ return token === '```' || token === '~~~';
1178
+ }
1179
+ async function readOutboundMedia(rawPath, cwd) {
1180
+ const resolved = resolve(cwd, rawPath);
1181
+ const real = realpathSync(resolved);
1182
+ const allowedDirs = [
1183
+ ensureDirectoryRealpath(join(tmpdir(), 'channel-files')),
1184
+ ];
1185
+ if (!allowedDirs.some((dir) => isInsideDir(real, dir))) {
1186
+ throw new Error('Media path outside allowed outbound directory');
1187
+ }
1188
+ const file = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW);
1189
+ try {
1190
+ const stat = await file.stat();
1191
+ if (!stat.isFile())
1192
+ throw new Error(`Not a regular file: ${basename(rawPath)}`);
1193
+ if (stat.size > MAX_MEDIA_BYTES) {
1194
+ throw new Error(`Media file too large: ${stat.size} bytes`);
1195
+ }
1196
+ return { data: await file.readFile(), fileName: basename(real) };
1197
+ }
1198
+ finally {
1199
+ await file.close();
1200
+ }
1201
+ }
1202
+ function ensureDirectoryRealpath(path) {
1203
+ try {
1204
+ mkdirSync(path, { recursive: true });
1205
+ const stat = lstatSync(path);
1206
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1207
+ throw new Error('not a safe directory');
1208
+ }
1209
+ return realpathSync(path);
1210
+ }
1211
+ catch (err) {
1212
+ const reason = err instanceof Error ? err.message : String(err);
1213
+ throw new Error(`Cannot prepare outbound media directory ${path}: ${reason}`);
1214
+ }
1215
+ }
1216
+ function isInsideDir(filePath, dir) {
1217
+ const windowsStyle = /^[a-zA-Z]:[\\/]/.test(filePath);
1218
+ const pathImpl = windowsStyle ? win32 : posix;
1219
+ const relative = pathImpl.relative(dir, filePath);
1220
+ return (relative === '' ||
1221
+ (!relative.startsWith('..') && !pathImpl.isAbsolute(relative)));
1222
+ }
1223
+ async function isSafeInboundMediaUrl(rawUrl) {
1224
+ let url;
1225
+ try {
1226
+ url = new URL(rawUrl);
1227
+ }
1228
+ catch {
1229
+ return { safe: false, reason: 'invalid URL' };
1230
+ }
1231
+ if (url.protocol !== 'https:') {
1232
+ return { safe: false, reason: 'non-HTTPS protocol' };
1233
+ }
1234
+ if (url.username || url.password) {
1235
+ return { safe: false, reason: 'URL contains embedded credentials' };
1236
+ }
1237
+ const host = url.hostname
1238
+ .toLowerCase()
1239
+ .replace(/^\[|\]$/g, '')
1240
+ .replace(/\.$/, '');
1241
+ if (!host || host === 'localhost' || host.endsWith('.localhost')) {
1242
+ return { safe: false, reason: 'local hostname' };
1243
+ }
1244
+ if (host.endsWith('.local')) {
1245
+ return { safe: false, reason: 'local hostname' };
1246
+ }
1247
+ if (isIP(host)) {
1248
+ return isPublicIpAddress(host)
1249
+ ? { safe: true }
1250
+ : { safe: false, reason: `private address ${host}` };
1251
+ }
1252
+ if (!host.includes('.'))
1253
+ return { safe: false, reason: 'bare hostname' };
1254
+ try {
1255
+ const records = await lookup(host, { all: true });
1256
+ if (records.length === 0) {
1257
+ return { safe: false, reason: 'no DNS records' };
1258
+ }
1259
+ const privateRecord = records.find((record) => !isPublicIpAddress(record.address));
1260
+ return privateRecord
1261
+ ? {
1262
+ safe: false,
1263
+ reason: `${host} resolved to private address ${privateRecord.address}`,
1264
+ }
1265
+ : { safe: true };
1266
+ }
1267
+ catch (err) {
1268
+ const message = err instanceof Error ? err.message : String(err);
1269
+ return { safe: false, reason: `DNS lookup failed for ${host}: ${message}` };
1270
+ }
1271
+ }
1272
+ async function downloadInboundMedia(ref) {
1273
+ const urlSafety = await isSafeInboundMediaUrl(ref.url);
1274
+ if (!urlSafety.safe) {
1275
+ throw new Error(`unsafe media URL (${urlSafety.reason})`);
1276
+ }
1277
+ // Intentionally bypass WSClient.downloadFile(): its axios path follows
1278
+ // redirects, lacks a byte cap, and does not pin resolved public IPs.
1279
+ const downloaded = await guardedHttpsDownload(ref.url);
1280
+ return {
1281
+ buffer: ref.aesKey
1282
+ ? decryptFile(downloaded.buffer, ref.aesKey)
1283
+ : downloaded.buffer,
1284
+ ...(ref.fileName || downloaded.filename
1285
+ ? { filename: ref.fileName || downloaded.filename }
1286
+ : {}),
1287
+ };
1288
+ }
1289
+ function guardedHttpsDownload(rawUrl) {
1290
+ return new Promise((resolvePromise, rejectPromise) => {
1291
+ let settled = false;
1292
+ const cleanup = {};
1293
+ const finish = (err, value) => {
1294
+ if (settled)
1295
+ return;
1296
+ settled = true;
1297
+ if (cleanup.absoluteTimeout)
1298
+ clearTimeout(cleanup.absoluteTimeout);
1299
+ if (err) {
1300
+ rejectPromise(err);
1301
+ }
1302
+ else {
1303
+ resolvePromise(value ?? { buffer: Buffer.alloc(0) });
1304
+ }
1305
+ };
1306
+ const req = httpsRequest(rawUrl, {
1307
+ method: 'GET',
1308
+ lookup: safePublicLookup,
1309
+ }, (res) => {
1310
+ const statusCode = res.statusCode ?? 0;
1311
+ if (statusCode >= 300 && statusCode < 400) {
1312
+ req.destroy();
1313
+ finish(new Error('redirected media URL'));
1314
+ return;
1315
+ }
1316
+ if (statusCode < 200 || statusCode >= 300) {
1317
+ req.destroy();
1318
+ finish(new Error(`media download failed: HTTP ${statusCode}`));
1319
+ return;
1320
+ }
1321
+ const contentLength = getHeaderNumber(res.headers, 'content-length');
1322
+ if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) {
1323
+ req.destroy();
1324
+ finish(new Error(`oversized attachment (${contentLength} bytes)`));
1325
+ return;
1326
+ }
1327
+ const chunks = [];
1328
+ let total = 0;
1329
+ res.on('data', (chunk) => {
1330
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1331
+ total += buffer.byteLength;
1332
+ if (total > MAX_MEDIA_BYTES) {
1333
+ req.destroy();
1334
+ finish(new Error('oversized attachment'));
1335
+ return;
1336
+ }
1337
+ chunks.push(buffer);
1338
+ });
1339
+ res.on('end', () => {
1340
+ const buffer = Buffer.concat(chunks);
1341
+ if (buffer.byteLength === 0) {
1342
+ finish(new Error('empty media response'));
1343
+ return;
1344
+ }
1345
+ finish(undefined, {
1346
+ buffer,
1347
+ ...parseContentDispositionFileName(res.headers),
1348
+ });
1349
+ });
1350
+ res.on('error', (err) => {
1351
+ req.destroy();
1352
+ finish(err);
1353
+ });
1354
+ });
1355
+ req.setTimeout(10_000, () => {
1356
+ req.destroy();
1357
+ finish(new Error('media download timed out'));
1358
+ });
1359
+ cleanup.absoluteTimeout = setTimeout(() => {
1360
+ req.destroy();
1361
+ finish(new Error('media download absolute timeout'));
1362
+ }, 60_000);
1363
+ cleanup.absoluteTimeout.unref?.();
1364
+ req.on('error', (err) => finish(err));
1365
+ req.end();
1366
+ });
1367
+ }
1368
+ const safePublicLookup = (hostname, options, callback) => {
1369
+ lookup(hostname, { all: true })
1370
+ .then((records) => {
1371
+ const unsafeRecord = records.find((record) => !isPublicIpAddress(record.address));
1372
+ if (records.length === 0 || unsafeRecord) {
1373
+ const reason = records.length === 0
1374
+ ? `no DNS records for ${hostname}`
1375
+ : `${hostname} resolved to private address ${unsafeRecord.address}`;
1376
+ callback(new Error(`unsafe resolved media address: ${reason}`), '', 0);
1377
+ return;
1378
+ }
1379
+ if (options.all) {
1380
+ callback(null, records);
1381
+ return;
1382
+ }
1383
+ const record = records[0];
1384
+ callback(null, record.address, record.family);
1385
+ })
1386
+ .catch((err) => callback(err instanceof Error ? err : new Error(String(err)), '', 0));
1387
+ };
1388
+ function getHeaderNumber(headers, name) {
1389
+ const value = getHeaderValue(headers, name);
1390
+ if (value === undefined)
1391
+ return undefined;
1392
+ const size = Number(value);
1393
+ return Number.isFinite(size) && size >= 0 ? size : undefined;
1394
+ }
1395
+ function getHeaderValue(headers, name) {
1396
+ const value = headers[name.toLowerCase()];
1397
+ if (Array.isArray(value))
1398
+ return value[0];
1399
+ return value;
1400
+ }
1401
+ function parseContentDispositionFileName(headers) {
1402
+ const value = getHeaderValue(headers, 'content-disposition');
1403
+ if (!value)
1404
+ return {};
1405
+ const encoded = value.match(/filename\*=UTF-8''([^;\s]+)/i)?.[1];
1406
+ if (encoded) {
1407
+ try {
1408
+ return { filename: decodeURIComponent(encoded) };
1409
+ }
1410
+ catch {
1411
+ return { filename: encoded };
1412
+ }
1413
+ }
1414
+ const plain = value.match(/filename="?([^";]+)"?/i)?.[1];
1415
+ return plain ? { filename: plain } : {};
1416
+ }
1417
+ function isPublicIpAddress(address) {
1418
+ const host = address.toLowerCase().replace(/^\[|\]$/g, '');
1419
+ const ipVersion = isIP(host);
1420
+ if (ipVersion === 4) {
1421
+ const parts = parseIpv4Parts(host);
1422
+ return parts ? isPublicIpv4(parts) : false;
1423
+ }
1424
+ if (ipVersion === 6) {
1425
+ const embedded = parseEmbeddedIpv4(host);
1426
+ if (embedded)
1427
+ return isPublicIpv4(embedded);
1428
+ const groups = expandIpv6Groups(host);
1429
+ if (!groups)
1430
+ return false;
1431
+ const first = groups[0] ?? 0;
1432
+ const isAllZeros = groups.every((group) => group === 0);
1433
+ const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
1434
+ const lowZeroEmbeddedIpv4 = hexGroupsToIpv4(groups[6], groups[7]);
1435
+ const hasLowZeroPrivateIpv4 = groups.slice(0, 5).every((group) => group === 0) &&
1436
+ lowZeroEmbeddedIpv4 !== undefined &&
1437
+ (groups[6] !== 0 || groups[7] !== 0) &&
1438
+ !isPublicIpv4(lowZeroEmbeddedIpv4);
1439
+ return !(isAllZeros ||
1440
+ isLoopback ||
1441
+ hasLowZeroPrivateIpv4 ||
1442
+ (first >= 0xfc00 && first <= 0xfdff) ||
1443
+ (first >= 0xff00 && first <= 0xffff) ||
1444
+ (first === 0x0100 && groups.slice(1, 4).every((g) => g === 0)) ||
1445
+ (first === 0x2001 && groups[1] === 0x0002) ||
1446
+ (first === 0x2001 && groups[1] === 0x0db8) ||
1447
+ (first === 0x2001 && (groups[1] & 0xfff0) === 0x0010) ||
1448
+ (first === 0x2001 && (groups[1] & 0xfff0) === 0x0030) ||
1449
+ isIpv6LinkLocalGroup(first));
1450
+ }
1451
+ return false;
1452
+ }
1453
+ function isIpv6LinkLocalGroup(firstGroup) {
1454
+ return firstGroup >= 0xfe80 && firstGroup <= 0xfeff;
1455
+ }
1456
+ function parseIpv4Parts(host) {
1457
+ const parts = host.split('.');
1458
+ if (parts.length !== 4) {
1459
+ return undefined;
1460
+ }
1461
+ const nums = parts.map((part) => {
1462
+ if (!/^(0|[1-9]\d{0,2})$/u.test(part))
1463
+ return NaN;
1464
+ const n = Number(part);
1465
+ return n >= 0 && n <= 255 ? n : NaN;
1466
+ });
1467
+ if (nums.some((part) => Number.isNaN(part))) {
1468
+ return undefined;
1469
+ }
1470
+ return nums;
1471
+ }
1472
+ function parseMappedIpv4(host) {
1473
+ if (!host.startsWith('::ffff:'))
1474
+ return undefined;
1475
+ const suffix = host.slice('::ffff:'.length);
1476
+ if (suffix.includes('.')) {
1477
+ return parseIpv4Parts(suffix);
1478
+ }
1479
+ const groups = suffix.split(':');
1480
+ if (groups.length !== 2)
1481
+ return undefined;
1482
+ const high = parseHexGroup(groups[0]);
1483
+ const low = parseHexGroup(groups[1]);
1484
+ if (high === undefined || low === undefined)
1485
+ return undefined;
1486
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
1487
+ }
1488
+ function parseEmbeddedIpv4(host) {
1489
+ const mapped = parseMappedIpv4(host);
1490
+ if (mapped)
1491
+ return mapped;
1492
+ const groups = expandIpv6Groups(host);
1493
+ if (!groups)
1494
+ return undefined;
1495
+ if (groups.slice(0, 4).every((group) => group === 0) &&
1496
+ groups[4] === 0xffff &&
1497
+ groups[5] === 0 &&
1498
+ (groups[6] !== 0 || groups[7] !== 0)) {
1499
+ return hexGroupsToIpv4(groups[6], groups[7]);
1500
+ }
1501
+ if (groups.slice(0, 5).every((group) => group === 0) &&
1502
+ groups[5] === 0xffff &&
1503
+ (groups[6] !== 0 || groups[7] !== 0)) {
1504
+ return hexGroupsToIpv4(groups[6], groups[7]);
1505
+ }
1506
+ if (groups.slice(0, 6).every((group) => group === 0) &&
1507
+ (groups[6] !== 0 || groups[7] !== 0)) {
1508
+ return hexGroupsToIpv4(groups[6], groups[7]);
1509
+ }
1510
+ if (groups[0] === 0x2002) {
1511
+ return hexGroupsToIpv4(groups[1], groups[2]);
1512
+ }
1513
+ if (groups[0] === 0x2001 && groups[1] === 0) {
1514
+ return hexGroupsToIpv4(groups[6] ^ 0xffff, groups[7] ^ 0xffff);
1515
+ }
1516
+ if (groups[0] === 0x0064 && groups[1] === 0xff9b) {
1517
+ return hexGroupsToIpv4(groups[6], groups[7]);
1518
+ }
1519
+ return undefined;
1520
+ }
1521
+ function expandIpv6Groups(host) {
1522
+ const normalized = normalizeIpv6DottedSuffix(host);
1523
+ if (!normalized)
1524
+ return undefined;
1525
+ const parts = normalized.split('::');
1526
+ if (parts.length > 2)
1527
+ return undefined;
1528
+ const left = parseIpv6GroupList(parts[0]);
1529
+ const right = parseIpv6GroupList(parts[1]);
1530
+ if (!left || !right)
1531
+ return undefined;
1532
+ if (parts.length === 1) {
1533
+ return left.length === 8 ? left : undefined;
1534
+ }
1535
+ const fill = 8 - left.length - right.length;
1536
+ if (fill < 1)
1537
+ return undefined;
1538
+ return [...left, ...Array(fill).fill(0), ...right];
1539
+ }
1540
+ function normalizeIpv6DottedSuffix(host) {
1541
+ if (!host.includes('.'))
1542
+ return host;
1543
+ const lastColon = host.lastIndexOf(':');
1544
+ if (lastColon === -1)
1545
+ return undefined;
1546
+ const ipv4 = parseIpv4Parts(host.slice(lastColon + 1));
1547
+ if (!ipv4)
1548
+ return undefined;
1549
+ const high = (ipv4[0] << 8) | ipv4[1];
1550
+ const low = (ipv4[2] << 8) | ipv4[3];
1551
+ return `${host.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
1552
+ }
1553
+ function parseIpv6GroupList(value) {
1554
+ if (!value)
1555
+ return [];
1556
+ const groups = [];
1557
+ for (const group of value.split(':')) {
1558
+ const parsed = parseHexGroup(group);
1559
+ if (parsed === undefined)
1560
+ return undefined;
1561
+ groups.push(parsed);
1562
+ }
1563
+ return groups;
1564
+ }
1565
+ function hexGroupsToIpv4(high, low) {
1566
+ if (high === undefined || low === undefined)
1567
+ return undefined;
1568
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
1569
+ }
1570
+ function parseHexGroup(value) {
1571
+ if (!value || !/^[\da-f]{1,4}$/i.test(value))
1572
+ return undefined;
1573
+ const parsed = Number.parseInt(value, 16);
1574
+ return parsed >= 0 && parsed <= 0xffff ? parsed : undefined;
1575
+ }
1576
+ function isPublicIpv4(parts) {
1577
+ const [a = 0, b = 0, c = 0] = parts;
1578
+ return !(a === 0 ||
1579
+ a === 10 ||
1580
+ a === 127 ||
1581
+ (a === 100 && b >= 64 && b <= 127) ||
1582
+ (a === 169 && b === 254) ||
1583
+ (a === 172 && b >= 16 && b <= 31) ||
1584
+ (a === 192 && b === 0 && (c === 0 || c === 2)) ||
1585
+ (a === 192 && b === 88 && c === 99) ||
1586
+ (a === 192 && b === 168) ||
1587
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
1588
+ (a === 203 && b === 0 && c === 113) ||
1589
+ a >= 224);
1590
+ }
1591
+ function extractMediaId(value) {
1592
+ const record = asRecord(value);
1593
+ return (getString(record, 'media_id') ||
1594
+ getString(record, 'mediaId') ||
1595
+ getString(getRecord(record, 'body'), 'media_id') ||
1596
+ undefined);
1597
+ }
1598
+ function detectImageMime(data) {
1599
+ if (data[0] === 0x89 &&
1600
+ data[1] === 0x50 &&
1601
+ data[2] === 0x4e &&
1602
+ data[3] === 0x47) {
1603
+ return 'image/png';
1604
+ }
1605
+ if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
1606
+ return 'image/gif';
1607
+ }
1608
+ if (data[0] === 0x52 &&
1609
+ data[1] === 0x49 &&
1610
+ data[2] === 0x46 &&
1611
+ data[3] === 0x46 &&
1612
+ data[8] === 0x57 &&
1613
+ data[9] === 0x45 &&
1614
+ data[10] === 0x42 &&
1615
+ data[11] === 0x50) {
1616
+ return 'image/webp';
1617
+ }
1618
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
1619
+ return 'image/jpeg';
1620
+ }
1621
+ return 'application/octet-stream';
1622
+ }
1623
+ function mediaTypeToMime(type) {
1624
+ switch (type) {
1625
+ case 'video':
1626
+ return 'video/mp4';
1627
+ case 'voice':
1628
+ return 'audio/amr';
1629
+ default:
1630
+ return 'application/octet-stream';
1631
+ }
1632
+ }
1633
+ function sanitizeFileName(name) {
1634
+ const base = basename(name || '').replace(/\0/g, '');
1635
+ return base.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_');
1636
+ }
1637
+ function asRecord(value) {
1638
+ return value && typeof value === 'object' && !Array.isArray(value)
1639
+ ? value
1640
+ : undefined;
1641
+ }
1642
+ function getRecord(value, key) {
1643
+ return asRecord(value?.[key]);
1644
+ }
1645
+ function getArray(value, key) {
1646
+ const raw = value?.[key];
1647
+ return Array.isArray(raw) ? raw : [];
1648
+ }
1649
+ function getString(value, key) {
1650
+ const raw = value?.[key];
1651
+ return typeof raw === 'string' ? raw : '';
1652
+ }
1653
+ //# sourceMappingURL=WeComAdapter.js.map