@sunnoy/wecom 1.2.0 → 1.3.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/index.js CHANGED
@@ -1,1611 +1,25 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import * as crypto from "node:crypto";
3
- import { mkdirSync, existsSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { WecomCrypto } from "./crypto.js";
6
- import {
7
- generateAgentId,
8
- getDynamicAgentConfig,
9
- shouldUseDynamicAgent,
10
- shouldTriggerGroupResponse,
11
- extractGroupMessageContent,
12
- } from "./dynamic-agent.js";
13
1
  import { logger } from "./logger.js";
14
2
  import { streamManager } from "./stream-manager.js";
15
- import { WecomWebhook } from "./webhook.js";
3
+ import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
4
+ import { wecomHttpHandler } from "./wecom/http-handler.js";
5
+ import { responseUrls, setOpenclawConfig, setRuntime, streamMeta } from "./wecom/state.js";
16
6
 
17
- const DEFAULT_ACCOUNT_ID = "default";
18
-
19
- // Placeholder shown while the LLM is processing or the message is queued.
20
- const THINKING_PLACEHOLDER = "思考中...";
21
-
22
- // Image cache directory.
23
- const MEDIA_CACHE_DIR = join(process.env.HOME || "/tmp", ".openclaw", "media", "wecom");
24
-
25
- // =============================================================================
26
- // Command allowlist configuration
27
- // =============================================================================
28
-
29
- // Slash commands that are allowed by default.
30
- const DEFAULT_COMMAND_ALLOWLIST = ["/new", "/compact", "/help", "/status"];
31
-
32
- // Default message shown when a command is blocked.
33
- const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
34
-
35
- 支持的命令:
36
- • **/new** - 新建会话
37
- • **/compact** - 压缩会话(保留上下文摘要)
38
- • **/help** - 查看帮助
39
- • **/status** - 查看状态`;
40
-
41
- /**
42
- * Read command allowlist settings from config.
43
- */
44
- function getCommandConfig(config) {
45
- const wecom = config?.channels?.wecom || {};
46
- const commands = wecom.commands || {};
47
- return {
48
- allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
49
- blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
50
- enabled: commands.enabled !== false,
51
- };
52
- }
53
-
54
- /**
55
- * Check whether a slash command is allowed.
56
- * @param {string} message - User message
57
- * @param {Object} config - OpenClaw config
58
- * @returns {{ isCommand: boolean, allowed: boolean, command: string | null }}
59
- */
60
- function checkCommandAllowlist(message, config) {
61
- const trimmed = message.trim();
62
-
63
- // Not a slash command.
64
- if (!trimmed.startsWith("/")) {
65
- return { isCommand: false, allowed: true, command: null };
66
- }
67
-
68
- // Use the first token as the command.
69
- const command = trimmed.split(/\s+/)[0].toLowerCase();
70
-
71
- const cmdConfig = getCommandConfig(config);
72
-
73
- // Allow all commands when command gating is disabled.
74
- if (!cmdConfig.enabled) {
75
- return { isCommand: true, allowed: true, command };
76
- }
77
-
78
- // Require explicit allowlist match.
79
- const allowed = cmdConfig.allowlist.some((cmd) => cmd.toLowerCase() === command);
80
-
81
- return { isCommand: true, allowed, command };
82
- }
83
-
84
- /**
85
- * Read admin user list from channels.wecom.adminUsers.
86
- * Admins bypass the command allowlist and skip dynamic agent routing.
87
- */
88
- function getWecomAdminUsers(config) {
89
- const raw = config?.channels?.wecom?.adminUsers;
90
- if (!Array.isArray(raw)) {
91
- return [];
92
- }
93
- return raw
94
- .map((u) => String(u ?? "").trim().toLowerCase())
95
- .filter(Boolean);
96
- }
97
-
98
- function isWecomAdmin(userId, config) {
99
- if (!userId) {
100
- return false;
101
- }
102
- const admins = getWecomAdminUsers(config);
103
- return admins.length > 0 && admins.includes(String(userId).trim().toLowerCase());
104
- }
105
-
106
- /**
107
- * Download and decrypt a WeCom encrypted image.
108
- * @param {string} imageUrl - Encrypted image URL from WeCom
109
- * @param {string} encodingAesKey - AES key
110
- * @param {string} token - Token
111
- * @returns {Promise<string>} Local path to decrypted image
112
- */
113
- async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
114
- if (!existsSync(MEDIA_CACHE_DIR)) {
115
- mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
116
- }
117
-
118
- logger.info("Downloading encrypted image", { url: imageUrl.substring(0, 80) });
119
- const response = await fetch(imageUrl);
120
- if (!response.ok) {
121
- throw new Error(`Failed to download image: ${response.status}`);
122
- }
123
- const encryptedBuffer = Buffer.from(await response.arrayBuffer());
124
- logger.debug("Downloaded encrypted image", { size: encryptedBuffer.length });
125
-
126
- const wecomCrypto = new WecomCrypto(token, encodingAesKey);
127
- const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
128
-
129
- // Detect image type via magic bytes.
130
- let ext = "jpg";
131
- if (decryptedBuffer[0] === 0x89 && decryptedBuffer[1] === 0x50) {
132
- ext = "png";
133
- } else if (decryptedBuffer[0] === 0x47 && decryptedBuffer[1] === 0x49) {
134
- ext = "gif";
135
- }
136
-
137
- const filename = `wecom_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
138
- const localPath = join(MEDIA_CACHE_DIR, filename);
139
- writeFileSync(localPath, decryptedBuffer);
140
-
141
- const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
142
- logger.info("Image decrypted and saved", { path: localPath, size: decryptedBuffer.length, mimeType });
143
- return { localPath, mimeType };
144
- }
145
-
146
- /**
147
- * Download a file from WeCom (not encrypted, unlike images).
148
- * @param {string} fileUrl - File download URL
149
- * @param {string} fileName - Original file name
150
- * @returns {Promise<string>} Local path to downloaded file
151
- */
152
- async function downloadWecomFile(fileUrl, fileName) {
153
- if (!existsSync(MEDIA_CACHE_DIR)) {
154
- mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
155
- }
156
-
157
- logger.info("Downloading file", { url: fileUrl.substring(0, 80), name: fileName });
158
- const response = await fetch(fileUrl);
159
- if (!response.ok) {
160
- throw new Error(`Failed to download file: ${response.status}`);
161
- }
162
- const buffer = Buffer.from(await response.arrayBuffer());
163
-
164
- const safeName = (fileName || `file_${Date.now()}`).replace(/[/\\:*?"<>|]/g, "_");
165
- const localPath = join(MEDIA_CACHE_DIR, `${Date.now()}_${safeName}`);
166
- writeFileSync(localPath, buffer);
167
-
168
- logger.info("File downloaded and saved", { path: localPath, size: buffer.length });
169
- return localPath;
170
- }
171
-
172
- /**
173
- * Guess MIME type from file extension.
174
- */
175
- function guessMimeType(fileName) {
176
- const ext = (fileName || "").split(".").pop()?.toLowerCase() || "";
177
- const mimeMap = {
178
- pdf: "application/pdf",
179
- doc: "application/msword",
180
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
181
- xls: "application/vnd.ms-excel",
182
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
183
- ppt: "application/vnd.ms-powerpoint",
184
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
185
- txt: "text/plain",
186
- csv: "text/csv",
187
- zip: "application/zip",
188
- png: "image/png",
189
- jpg: "image/jpeg",
190
- jpeg: "image/jpeg",
191
- gif: "image/gif",
192
- mp4: "video/mp4",
193
- mp3: "audio/mpeg",
194
- };
195
- return mimeMap[ext] || "application/octet-stream";
196
- }
197
-
198
- // Runtime state (module-level singleton)
199
- let _runtime = null;
200
- let _openclawConfig = null;
201
- const ensuredDynamicAgentIds = new Set();
202
- let ensureDynamicAgentWriteQueue = Promise.resolve();
203
-
204
- // Per-user dispatch serialization lock.
205
- const dispatchLocks = new Map();
206
-
207
- // Per-user message debounce buffer.
208
- // Collects messages arriving within DEBOUNCE_MS into a single dispatch.
209
- const DEBOUNCE_MS = 2000;
210
- const messageBuffers = new Map();
211
-
212
- /**
213
- * Handle stream error: replace placeholder with error message, finish stream, unregister.
214
- */
215
- async function handleStreamError(streamId, streamKey, errorMessage) {
216
- if (!streamId) return;
217
- const stream = streamManager.getStream(streamId);
218
- if (stream && !stream.finished) {
219
- if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
220
- streamManager.replaceIfPlaceholder(streamId, errorMessage, THINKING_PLACEHOLDER);
221
- }
222
- await streamManager.finishStream(streamId);
223
- }
224
- unregisterActiveStream(streamKey, streamId);
225
- }
226
-
227
- /**
228
- * Set the plugin runtime (called during plugin registration)
229
- */
230
- function setRuntime(runtime) {
231
- _runtime = runtime;
232
- }
233
-
234
- function getRuntime() {
235
- if (!_runtime) {
236
- throw new Error("[wecom] Runtime not initialized");
237
- }
238
- return _runtime;
239
- }
240
-
241
- function upsertAgentIdOnlyEntry(cfg, agentId) {
242
- const normalizedId = String(agentId || "")
243
- .trim()
244
- .toLowerCase();
245
- if (!normalizedId) {
246
- return false;
247
- }
248
-
249
- if (!cfg.agents || typeof cfg.agents !== "object") {
250
- cfg.agents = {};
251
- }
252
-
253
- const currentList = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
254
- const existingIds = new Set(
255
- currentList
256
- .map((entry) => (entry && typeof entry.id === "string" ? entry.id.trim().toLowerCase() : ""))
257
- .filter(Boolean),
258
- );
259
-
260
- let changed = false;
261
- const nextList = [...currentList];
262
-
263
- // Keep "main" as the explicit default when creating agents.list for the first time.
264
- if (nextList.length === 0) {
265
- nextList.push({ id: "main" });
266
- existingIds.add("main");
267
- changed = true;
268
- }
269
-
270
- if (!existingIds.has(normalizedId)) {
271
- nextList.push({ id: normalizedId });
272
- changed = true;
273
- }
274
-
275
- if (changed) {
276
- cfg.agents.list = nextList;
277
- }
278
-
279
- return changed;
280
- }
281
-
282
- async function ensureDynamicAgentListed(agentId) {
283
- const normalizedId = String(agentId || "")
284
- .trim()
285
- .toLowerCase();
286
- if (!normalizedId) {
287
- return;
288
- }
289
- if (ensuredDynamicAgentIds.has(normalizedId)) {
290
- return;
291
- }
292
-
293
- const runtime = getRuntime();
294
- const configRuntime = runtime?.config;
295
- if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) {
296
- return;
297
- }
298
-
299
- ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
300
- .then(async () => {
301
- if (ensuredDynamicAgentIds.has(normalizedId)) {
302
- return;
303
- }
304
-
305
- const latestConfig = configRuntime.loadConfig();
306
- if (!latestConfig || typeof latestConfig !== "object") {
307
- return;
308
- }
309
-
310
- const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
311
- if (changed) {
312
- await configRuntime.writeConfigFile(latestConfig);
313
- logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
314
- }
315
-
316
- // Keep runtime in-memory config aligned to avoid stale reads in this process.
317
- if (_openclawConfig && typeof _openclawConfig === "object") {
318
- upsertAgentIdOnlyEntry(_openclawConfig, normalizedId);
319
- }
320
-
321
- ensuredDynamicAgentIds.add(normalizedId);
322
- })
323
- .catch((err) => {
324
- logger.warn("WeCom: failed to sync dynamic agent into agents.list", {
325
- agentId: normalizedId,
326
- error: err?.message || String(err),
327
- });
328
- });
329
-
330
- await ensureDynamicAgentWriteQueue;
331
- }
332
-
333
- // Webhook targets registry (similar to Google Chat)
334
- const webhookTargets = new Map();
335
-
336
- // Track active stream for each user, so outbound messages (like reset confirmation)
337
- // can be added to the correct stream instead of using response_url
338
- const activeStreams = new Map();
339
- const activeStreamHistory = new Map();
340
-
341
- // AsyncLocalStorage for propagating the correct streamId through the async
342
- // processing chain. Prevents outbound adapter from resolving the wrong stream
343
- // when multiple messages from the same user are in flight.
344
- const streamContext = new AsyncLocalStorage();
345
-
346
- function getMessageStreamKey(message) {
347
- if (!message || typeof message !== "object") {
348
- return "";
349
- }
350
- const chatType = message.chatType || "single";
351
- const chatId = message.chatId || "";
352
- if (chatType === "group" && chatId) {
353
- return chatId;
354
- }
355
- return message.fromUser || "";
356
- }
357
-
358
- function registerActiveStream(streamKey, streamId) {
359
- if (!streamKey || !streamId) {
360
- return;
361
- }
362
-
363
- const history = activeStreamHistory.get(streamKey) ?? [];
364
- const deduped = history.filter((id) => id !== streamId);
365
- deduped.push(streamId);
366
- activeStreamHistory.set(streamKey, deduped);
367
- activeStreams.set(streamKey, streamId);
368
- }
369
-
370
- function unregisterActiveStream(streamKey, streamId) {
371
- if (!streamKey || !streamId) {
372
- return;
373
- }
374
-
375
- const history = activeStreamHistory.get(streamKey);
376
- if (!history || history.length === 0) {
377
- if (activeStreams.get(streamKey) === streamId) {
378
- activeStreams.delete(streamKey);
379
- }
380
- return;
381
- }
382
-
383
- const remaining = history.filter((id) => id !== streamId);
384
- if (remaining.length === 0) {
385
- activeStreamHistory.delete(streamKey);
386
- activeStreams.delete(streamKey);
387
- return;
388
- }
389
-
390
- activeStreamHistory.set(streamKey, remaining);
391
- activeStreams.set(streamKey, remaining[remaining.length - 1]);
392
- }
393
-
394
- function resolveActiveStream(streamKey) {
395
- if (!streamKey) {
396
- return null;
397
- }
398
-
399
- const history = activeStreamHistory.get(streamKey);
400
- if (!history || history.length === 0) {
401
- activeStreams.delete(streamKey);
402
- return null;
403
- }
404
-
405
- const remaining = history.filter((id) => streamManager.hasStream(id));
406
- if (remaining.length === 0) {
407
- activeStreamHistory.delete(streamKey);
408
- activeStreams.delete(streamKey);
409
- return null;
410
- }
411
-
412
- activeStreamHistory.set(streamKey, remaining);
413
- const latest = remaining[remaining.length - 1];
414
- activeStreams.set(streamKey, latest);
415
- return latest;
416
- }
417
-
418
- function normalizeWecomAllowFromEntry(raw) {
419
- const trimmed = String(raw ?? "").trim();
420
- if (!trimmed) {
421
- return null;
422
- }
423
- if (trimmed === "*") {
424
- return "*";
425
- }
426
- return trimmed
427
- .replace(/^(wecom|wework):/i, "")
428
- .replace(/^user:/i, "")
429
- .toLowerCase();
430
- }
431
-
432
- function resolveWecomAllowFrom(cfg, accountId) {
433
- const wecom = cfg?.channels?.wecom;
434
- if (!wecom) {
435
- return [];
436
- }
437
-
438
- const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID)
439
- .trim()
440
- .toLowerCase();
441
- const accounts = wecom.accounts;
442
- const account =
443
- accounts && typeof accounts === "object"
444
- ? (accounts[accountId] ??
445
- accounts[
446
- Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId) ?? ""
447
- ])
448
- : undefined;
449
-
450
- const allowFromRaw =
451
- account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
452
-
453
- if (!Array.isArray(allowFromRaw)) {
454
- return [];
455
- }
456
-
457
- return allowFromRaw.map(normalizeWecomAllowFromEntry).filter((entry) => Boolean(entry));
458
- }
459
-
460
- function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
461
- const sender = String(senderId ?? "")
462
- .trim()
463
- .toLowerCase();
464
- if (!sender) {
465
- return false;
466
- }
467
-
468
- const allowFrom = resolveWecomAllowFrom(cfg, accountId);
469
- if (allowFrom.includes("*") || allowFrom.length === 0) {
470
- return true;
471
- }
472
- return allowFrom.includes(sender);
473
- }
474
-
475
- function normalizeWebhookPath(raw) {
476
- const trimmed = (raw || "").trim();
477
- if (!trimmed) {
478
- return "/";
479
- }
480
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
481
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
482
- return withSlash.slice(0, -1);
483
- }
484
- return withSlash;
485
- }
486
-
487
- function registerWebhookTarget(target) {
488
- const key = normalizeWebhookPath(target.path);
489
- const entry = { ...target, path: key };
490
- const existing = webhookTargets.get(key) ?? [];
491
- webhookTargets.set(key, [...existing, entry]);
492
- return () => {
493
- const updated = (webhookTargets.get(key) ?? []).filter((e) => e !== entry);
494
- if (updated.length > 0) {
495
- webhookTargets.set(key, updated);
496
- } else {
497
- webhookTargets.delete(key);
498
- }
499
- };
500
- }
501
-
502
- // =============================================================================
503
- // Channel Plugin Definition
504
- // =============================================================================
505
-
506
- const wecomChannelPlugin = {
507
- id: "wecom",
508
- meta: {
509
- id: "wecom",
510
- label: "Enterprise WeChat",
511
- selectionLabel: "Enterprise WeChat (AI Bot)",
512
- docsPath: "/channels/wecom",
513
- blurb: "Enterprise WeChat AI Bot channel plugin.",
514
- aliases: ["wecom", "wework"],
515
- },
516
- capabilities: {
517
- chatTypes: ["direct", "group"],
518
- reactions: false,
519
- threads: false,
520
- media: true,
521
- nativeCommands: false,
522
- blockStreaming: true, // WeCom AI Bot requires stream-style responses.
523
- },
524
- reload: { configPrefixes: ["channels.wecom"] },
525
- configSchema: {
526
- schema: {
527
- $schema: "http://json-schema.org/draft-07/schema#",
528
- type: "object",
529
- additionalProperties: false,
530
- properties: {
531
- enabled: {
532
- type: "boolean",
533
- description: "Enable WeCom channel",
534
- default: true,
535
- },
536
- token: {
537
- type: "string",
538
- description: "WeCom bot token from admin console",
539
- },
540
- encodingAesKey: {
541
- type: "string",
542
- description: "WeCom message encryption key (43 characters)",
543
- minLength: 43,
544
- maxLength: 43,
545
- },
546
- commands: {
547
- type: "object",
548
- description: "Command whitelist configuration",
549
- additionalProperties: false,
550
- properties: {
551
- enabled: {
552
- type: "boolean",
553
- description: "Enable command whitelist filtering",
554
- default: true,
555
- },
556
- allowlist: {
557
- type: "array",
558
- description: "Allowed commands (e.g., /new, /status, /help)",
559
- items: {
560
- type: "string",
561
- },
562
- default: ["/new", "/status", "/help", "/compact"],
563
- },
564
- },
565
- },
566
- dynamicAgents: {
567
- type: "object",
568
- description: "Dynamic agent routing configuration",
569
- additionalProperties: false,
570
- properties: {
571
- enabled: {
572
- type: "boolean",
573
- description: "Enable per-user/per-group agent isolation",
574
- default: true,
575
- },
576
- },
577
- },
578
- dm: {
579
- type: "object",
580
- description: "Direct message (private chat) configuration",
581
- additionalProperties: false,
582
- properties: {
583
- createAgentOnFirstMessage: {
584
- type: "boolean",
585
- description: "Create separate agent for each user",
586
- default: true,
587
- },
588
- },
589
- },
590
- groupChat: {
591
- type: "object",
592
- description: "Group chat configuration",
593
- additionalProperties: false,
594
- properties: {
595
- enabled: {
596
- type: "boolean",
597
- description: "Enable group chat support",
598
- default: true,
599
- },
600
- requireMention: {
601
- type: "boolean",
602
- description: "Only respond when @mentioned in groups",
603
- default: true,
604
- },
605
- },
606
- },
607
- adminUsers: {
608
- type: "array",
609
- description: "Admin users who bypass command allowlist and dynamic agent routing",
610
- items: { type: "string" },
611
- default: [],
612
- },
613
- },
614
- },
615
- uiHints: {
616
- token: {
617
- sensitive: true,
618
- label: "Bot Token",
619
- },
620
- encodingAesKey: {
621
- sensitive: true,
622
- label: "Encoding AES Key",
623
- help: "43-character encryption key from WeCom admin console",
624
- },
625
- },
626
- },
627
- config: {
628
- listAccountIds: (cfg) => {
629
- const wecom = cfg?.channels?.wecom;
630
- if (!wecom || !wecom.enabled) {
631
- return [];
632
- }
633
- return [DEFAULT_ACCOUNT_ID];
634
- },
635
- resolveAccount: (cfg, accountId) => {
636
- const wecom = cfg?.channels?.wecom;
637
- if (!wecom) {
638
- return null;
639
- }
640
- return {
641
- id: accountId || DEFAULT_ACCOUNT_ID,
642
- accountId: accountId || DEFAULT_ACCOUNT_ID,
643
- enabled: wecom.enabled !== false,
644
- token: wecom.token || "",
645
- encodingAesKey: wecom.encodingAesKey || "",
646
- webhookPath: wecom.webhookPath || "/webhooks/wecom",
647
- config: wecom,
648
- };
649
- },
650
- defaultAccountId: (cfg) => {
651
- const wecom = cfg?.channels?.wecom;
652
- if (!wecom || !wecom.enabled) {
653
- return null;
654
- }
655
- return DEFAULT_ACCOUNT_ID;
656
- },
657
- setAccountEnabled: ({ cfg, accountId: _accountId, enabled }) => {
658
- if (!cfg.channels) {
659
- cfg.channels = {};
660
- }
661
- if (!cfg.channels.wecom) {
662
- cfg.channels.wecom = {};
663
- }
664
- cfg.channels.wecom.enabled = enabled;
665
- return cfg;
666
- },
667
- deleteAccount: ({ cfg, accountId: _accountId }) => {
668
- if (cfg.channels?.wecom) {
669
- delete cfg.channels.wecom;
670
- }
671
- return cfg;
672
- },
673
- },
674
- directory: {
675
- self: async () => null,
676
- listPeers: async () => [],
677
- listGroups: async () => [],
678
- },
679
- // Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
680
- outbound: {
681
- sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
682
- // `to` format: "wecom:userid" or "userid".
683
- const userId = to.replace(/^wecom:/, "");
684
-
685
- // Prefer stream from async context (correct for concurrent processing).
686
- const ctx = streamContext.getStore();
687
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
688
-
689
- if (streamId && streamManager.hasStream(streamId)) {
690
- logger.debug("Appending outbound text to stream", {
691
- userId,
692
- streamId,
693
- source: ctx ? "asyncContext" : "activeStreams",
694
- text: text.substring(0, 30),
695
- });
696
- // Replace placeholder or append content.
697
- streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
698
-
699
- return {
700
- channel: "wecom",
701
- messageId: `msg_stream_${Date.now()}`,
702
- };
703
- }
704
-
705
- // No active stream means nothing can be delivered right now.
706
- logger.warn("WeCom outbound: no active stream for user", { userId });
707
-
708
- return {
709
- channel: "wecom",
710
- messageId: `fake_${Date.now()}`,
711
- };
712
- },
713
- sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
714
- const userId = to.replace(/^wecom:/, "");
715
-
716
- // Prefer stream from async context (correct for concurrent processing).
717
- const ctx = streamContext.getStore();
718
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
719
-
720
- if (streamId && streamManager.hasStream(streamId)) {
721
- // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
722
- const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
723
-
724
- if (isLocalPath) {
725
- // Convert sandbox: URLs to absolute paths.
726
- // sandbox:///tmp/a -> /tmp/a, sandbox://tmp/a -> /tmp/a, sandbox:/tmp/a -> /tmp/a
727
- let absolutePath = mediaUrl;
728
- if (absolutePath.startsWith("sandbox:")) {
729
- absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
730
- // Ensure the result is an absolute path.
731
- if (!absolutePath.startsWith("/")) {
732
- absolutePath = "/" + absolutePath;
733
- }
734
- }
735
-
736
- logger.debug("Queueing local image for stream", {
737
- userId,
738
- streamId,
739
- mediaUrl,
740
- absolutePath,
741
- });
742
-
743
- // Queue the image for processing when stream finishes
744
- const queued = streamManager.queueImage(streamId, absolutePath);
745
-
746
- if (queued) {
747
- // Append text content to stream (without markdown image)
748
- if (text) {
749
- streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
750
- }
751
-
752
- // Append placeholder indicating image will follow
753
- const imagePlaceholder = "\n\n[图片]";
754
- streamManager.appendStream(streamId, imagePlaceholder);
755
-
756
- return {
757
- channel: "wecom",
758
- messageId: `msg_stream_img_${Date.now()}`,
759
- };
760
- } else {
761
- logger.warn("Failed to queue image, falling back to markdown", {
762
- userId,
763
- streamId,
764
- mediaUrl,
765
- });
766
- // Fallback to old behavior
767
- }
768
- }
769
-
770
- // OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
771
- const content = text ? `${text}\n\n![image](${mediaUrl})` : `![image](${mediaUrl})`;
772
- logger.debug("Appending outbound media to stream (markdown)", {
773
- userId,
774
- streamId,
775
- mediaUrl,
776
- });
777
-
778
- // Replace placeholder or append media markdown to the current stream content.
779
- streamManager.replaceIfPlaceholder(streamId, content, THINKING_PLACEHOLDER);
780
-
781
- return {
782
- channel: "wecom",
783
- messageId: `msg_stream_${Date.now()}`,
784
- };
785
- }
786
-
787
- logger.warn("WeCom outbound sendMedia: no active stream", { userId });
788
-
789
- return {
790
- channel: "wecom",
791
- messageId: `fake_${Date.now()}`,
792
- };
793
- },
794
- },
795
- gateway: {
796
- startAccount: async (ctx) => {
797
- const account = ctx.account;
798
- logger.info("WeCom gateway starting", {
799
- accountId: account.accountId,
800
- webhookPath: account.webhookPath,
801
- });
802
-
803
- const unregister = registerWebhookTarget({
804
- path: account.webhookPath || "/webhooks/wecom",
805
- account,
806
- config: ctx.cfg,
807
- });
808
-
809
- return {
810
- shutdown: async () => {
811
- logger.info("WeCom gateway shutting down");
812
- // Clear pending debounce timers to prevent post-shutdown dispatches.
813
- for (const [, buf] of messageBuffers) {
814
- clearTimeout(buf.timer);
815
- }
816
- messageBuffers.clear();
817
- unregister();
818
- },
819
- };
820
- },
821
- },
822
- };
823
-
824
- // =============================================================================
825
- // HTTP Webhook Handler
826
- // =============================================================================
827
-
828
- async function wecomHttpHandler(req, res) {
829
- const url = new URL(req.url || "", "http://localhost");
830
- const path = normalizeWebhookPath(url.pathname);
831
- const targets = webhookTargets.get(path);
832
-
833
- if (!targets || targets.length === 0) {
834
- return false; // Not handled by this plugin
835
- }
836
-
837
- const query = Object.fromEntries(url.searchParams);
838
- logger.debug("WeCom HTTP request", { method: req.method, path });
839
-
840
- // GET: URL Verification
841
- if (req.method === "GET") {
842
- const target = targets[0]; // Use first target for verification
843
- if (!target) {
844
- res.writeHead(503, { "Content-Type": "text/plain" });
845
- res.end("No webhook target configured");
846
- return true;
847
- }
848
-
849
- const webhook = new WecomWebhook({
850
- token: target.account.token,
851
- encodingAesKey: target.account.encodingAesKey,
852
- });
853
-
854
- const echo = webhook.handleVerify(query);
855
- if (echo) {
856
- res.writeHead(200, { "Content-Type": "text/plain" });
857
- res.end(echo);
858
- logger.info("WeCom URL verification successful");
859
- return true;
860
- }
861
-
862
- res.writeHead(403, { "Content-Type": "text/plain" });
863
- res.end("Verification failed");
864
- logger.warn("WeCom URL verification failed");
865
- return true;
866
- }
867
-
868
- // POST: Message handling
869
- if (req.method === "POST") {
870
- const target = targets[0];
871
- if (!target) {
872
- res.writeHead(503, { "Content-Type": "text/plain" });
873
- res.end("No webhook target configured");
874
- return true;
875
- }
876
-
877
- // Read request body
878
- const chunks = [];
879
- for await (const chunk of req) {
880
- chunks.push(chunk);
881
- }
882
- const body = Buffer.concat(chunks).toString("utf-8");
883
- logger.debug("WeCom message received", { bodyLength: body.length });
884
-
885
- const webhook = new WecomWebhook({
886
- token: target.account.token,
887
- encodingAesKey: target.account.encodingAesKey,
888
- });
889
-
890
- const result = await webhook.handleMessage(query, body);
891
- if (result === WecomWebhook.DUPLICATE) {
892
- // Duplicate message — ACK 200 to prevent platform retry storm.
893
- res.writeHead(200, { "Content-Type": "text/plain" });
894
- res.end("success");
895
- return true;
896
- }
897
- if (!result) {
898
- res.writeHead(400, { "Content-Type": "text/plain" });
899
- res.end("Bad Request");
900
- return true;
901
- }
902
-
903
- // Handle text message
904
- if (result.message) {
905
- const msg = result.message;
906
- const { timestamp, nonce } = result.query;
907
- const content = (msg.content || "").trim();
908
-
909
- // Use stream responses for every inbound message, including commands.
910
- // WeCom AI Bot response_url is single-use, so streaming is mandatory.
911
- const streamId = `stream_${crypto.randomUUID()}`;
912
- streamManager.createStream(streamId);
913
- streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
914
-
915
- // Passive reply: return stream id immediately in the sync response.
916
- // Include the placeholder so the client displays it right away.
917
- const streamResponse = webhook.buildStreamResponse(streamId, THINKING_PLACEHOLDER, false, timestamp, nonce);
918
-
919
- res.writeHead(200, { "Content-Type": "application/json" });
920
- res.end(streamResponse);
921
-
922
- logger.info("Stream initiated", {
923
- streamId,
924
- from: msg.fromUser,
925
- isCommand: content.startsWith("/"),
926
- });
927
-
928
- const streamKey = getMessageStreamKey(msg);
929
- const isCommand = content.startsWith("/");
930
-
931
- // Commands bypass debounce — process immediately.
932
- if (isCommand) {
933
- processInboundMessage({
934
- message: msg,
935
- streamId,
936
- timestamp,
937
- nonce,
938
- account: target.account,
939
- config: target.config,
940
- }).catch(async (err) => {
941
- logger.error("WeCom message processing failed", { error: err.message });
942
- await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
943
- });
944
- return true;
945
- }
946
-
947
- // Debounce: buffer non-command messages per user/group.
948
- // If multiple messages arrive within DEBOUNCE_MS, merge into one dispatch.
949
- const existing = messageBuffers.get(streamKey);
950
- if (existing) {
951
- // A previous message is still buffered — merge this one in.
952
- existing.messages.push(msg);
953
- existing.streamIds.push(streamId);
954
- clearTimeout(existing.timer);
955
- existing.timer = setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS);
956
- logger.info("WeCom: message buffered for merge", {
957
- streamKey,
958
- streamId,
959
- buffered: existing.messages.length,
960
- });
961
- } else {
962
- // First message — start a new buffer with a debounce timer.
963
- const buffer = {
964
- messages: [msg],
965
- streamIds: [streamId],
966
- target,
967
- timestamp,
968
- nonce,
969
- timer: setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS),
970
- };
971
- messageBuffers.set(streamKey, buffer);
972
- logger.info("WeCom: message buffered (first)", { streamKey, streamId });
973
- }
974
-
975
- return true;
976
- }
977
-
978
- // Handle stream refresh - return current stream state
979
- if (result.stream) {
980
- const { timestamp, nonce } = result.query;
981
- const streamId = result.stream.id;
982
-
983
- // Return latest stream state.
984
- const stream = streamManager.getStream(streamId);
985
-
986
- if (!stream) {
987
- // Stream already expired or missing.
988
- logger.warn("Stream not found for refresh", { streamId });
989
- const streamResponse = webhook.buildStreamResponse(
990
- streamId,
991
- "会话已过期",
992
- true,
993
- timestamp,
994
- nonce,
995
- );
996
- res.writeHead(200, { "Content-Type": "application/json" });
997
- res.end(streamResponse);
998
- return true;
999
- }
1000
-
1001
- // Return current stream payload.
1002
- const streamResponse = webhook.buildStreamResponse(
1003
- streamId,
1004
- stream.content,
1005
- stream.finished,
1006
- timestamp,
1007
- nonce,
1008
- // Pass msgItem when stream is finished and has images
1009
- stream.finished && stream.msgItem.length > 0 ? { msgItem: stream.msgItem } : {},
1010
- );
1011
-
1012
- res.writeHead(200, { "Content-Type": "application/json" });
1013
- res.end(streamResponse);
1014
-
1015
- logger.debug("Stream refresh response sent", {
1016
- streamId,
1017
- contentLength: stream.content.length,
1018
- finished: stream.finished,
1019
- });
1020
-
1021
- // Clean up completed streams after a short delay.
1022
- if (stream.finished) {
1023
- setTimeout(() => {
1024
- streamManager.deleteStream(streamId);
1025
- }, 30 * 1000);
1026
- }
1027
-
1028
- return true;
1029
- }
1030
-
1031
- // Handle event
1032
- if (result.event) {
1033
- logger.info("WeCom event received", { event: result.event });
1034
-
1035
- // Handle enter_chat with an immediate welcome stream.
1036
- if (result.event?.event_type === "enter_chat") {
1037
- const { timestamp, nonce } = result.query;
1038
- const fromUser = result.event?.from?.userid || "";
1039
-
1040
- // Welcome message body.
1041
- const welcomeMessage = `你好!👋 我是 AI 助手。
1042
-
1043
- 你可以使用下面的指令管理会话:
1044
- • **/new** - 新建会话(清空上下文)
1045
- • **/compact** - 压缩会话(保留上下文摘要)
1046
- • **/help** - 查看更多命令
1047
-
1048
- 有什么我可以帮你的吗?`;
1049
-
1050
- // Build and finish stream in a single pass.
1051
- const streamId = `welcome_${crypto.randomUUID()}`;
1052
- streamManager.createStream(streamId);
1053
- streamManager.appendStream(streamId, welcomeMessage);
1054
- await streamManager.finishStream(streamId);
1055
-
1056
- const streamResponse = webhook.buildStreamResponse(
1057
- streamId,
1058
- welcomeMessage,
1059
- true,
1060
- timestamp,
1061
- nonce,
1062
- );
1063
-
1064
- logger.info("Sending welcome message", { fromUser, streamId });
1065
- res.writeHead(200, { "Content-Type": "application/json" });
1066
- res.end(streamResponse);
1067
- return true;
1068
- }
1069
-
1070
- res.writeHead(200, { "Content-Type": "text/plain" });
1071
- res.end("success");
1072
- return true;
1073
- }
1074
-
1075
- res.writeHead(200, { "Content-Type": "text/plain" });
1076
- res.end("success");
1077
- return true;
1078
- }
1079
-
1080
- res.writeHead(405, { "Content-Type": "text/plain" });
1081
- res.end("Method Not Allowed");
1082
- return true;
1083
- }
1084
-
1085
- // =============================================================================
1086
- // Inbound Message Processing (triggers AI response)
1087
- // =============================================================================
1088
-
1089
- /**
1090
- * Flush the debounce buffer for a given streamKey.
1091
- * Merges buffered messages into a single dispatch call.
1092
- * The first message's stream receives the LLM response.
1093
- * Subsequent streams get "消息已合并到第一条回复" and finish immediately.
1094
- */
1095
- function flushMessageBuffer(streamKey, target) {
1096
- const buffer = messageBuffers.get(streamKey);
1097
- if (!buffer) {
1098
- return;
1099
- }
1100
- messageBuffers.delete(streamKey);
1101
-
1102
- const { messages, streamIds } = buffer;
1103
- const primaryStreamId = streamIds[0];
1104
- const primaryMsg = messages[0];
1105
-
1106
- // Merge content from all buffered messages.
1107
- if (messages.length > 1) {
1108
- const mergedContent = messages.map((m) => m.content || "").filter(Boolean).join("\n");
1109
- primaryMsg.content = mergedContent;
1110
-
1111
- // Merge image attachments.
1112
- const allImageUrls = messages.flatMap((m) => m.imageUrls || []);
1113
- if (allImageUrls.length > 0) {
1114
- primaryMsg.imageUrls = allImageUrls;
1115
- }
1116
- const singleImages = messages.map((m) => m.imageUrl).filter(Boolean);
1117
- if (singleImages.length > 0 && !primaryMsg.imageUrl) {
1118
- primaryMsg.imageUrl = singleImages[0];
1119
- if (singleImages.length > 1) {
1120
- primaryMsg.imageUrls = [...(primaryMsg.imageUrls || []), ...singleImages.slice(1)];
1121
- }
1122
- }
1123
-
1124
- // Finish extra streams with merge notice.
1125
- for (let i = 1; i < streamIds.length; i++) {
1126
- const extraStreamId = streamIds[i];
1127
- streamManager.replaceIfPlaceholder(
1128
- extraStreamId, "消息已合并到第一条回复中。", THINKING_PLACEHOLDER,
1129
- );
1130
- streamManager.finishStream(extraStreamId).then(() => {
1131
- unregisterActiveStream(streamKey, extraStreamId);
1132
- });
1133
- }
1134
-
1135
- logger.info("WeCom: flushing merged messages", {
1136
- streamKey,
1137
- count: messages.length,
1138
- primaryStreamId,
1139
- mergedContentPreview: mergedContent.substring(0, 60),
1140
- });
1141
- } else {
1142
- logger.info("WeCom: flushing single message", { streamKey, primaryStreamId });
1143
- }
1144
-
1145
- // Dispatch the merged message.
1146
- processInboundMessage({
1147
- message: primaryMsg,
1148
- streamId: primaryStreamId,
1149
- timestamp: buffer.timestamp,
1150
- nonce: buffer.nonce,
1151
- account: target.account,
1152
- config: target.config,
1153
- }).catch(async (err) => {
1154
- logger.error("WeCom message processing failed", { error: err.message });
1155
- await handleStreamError(primaryStreamId, streamKey, "处理消息时出错,请稍后再试。");
1156
- });
1157
- }
1158
-
1159
- async function processInboundMessage({
1160
- message,
1161
- streamId,
1162
- timestamp: _timestamp,
1163
- nonce: _nonce,
1164
- account,
1165
- config,
1166
- }) {
1167
- const runtime = getRuntime();
1168
- const core = runtime.channel;
1169
-
1170
- const senderId = message.fromUser;
1171
- const msgType = message.msgType || "text";
1172
- const imageUrl = message.imageUrl || "";
1173
- const imageUrls = message.imageUrls || [];
1174
- const fileUrl = message.fileUrl || "";
1175
- const fileName = message.fileName || "";
1176
- const rawContent = message.content || "";
1177
- const chatType = message.chatType || "single";
1178
- const chatId = message.chatId || "";
1179
- const isGroupChat = chatType === "group" && chatId;
1180
-
1181
- // Use chat id for group sessions and sender id for direct messages.
1182
- const peerId = isGroupChat ? chatId : senderId;
1183
- const peerKind = isGroupChat ? "group" : "dm";
1184
- const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
1185
-
1186
- // Track active stream by chat context for outbound adapter callbacks.
1187
- const streamKey = isGroupChat ? chatId : senderId;
1188
- if (streamId) {
1189
- registerActiveStream(streamKey, streamId);
1190
- }
1191
-
1192
- // Apply group mention gating rules.
1193
- let rawBody = rawContent;
1194
- if (isGroupChat) {
1195
- if (!shouldTriggerGroupResponse(rawContent, config)) {
1196
- logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
1197
- if (streamId) {
1198
- streamManager.replaceIfPlaceholder(
1199
- streamId, "请@提及我以获取回复。", THINKING_PLACEHOLDER,
1200
- );
1201
- await streamManager.finishStream(streamId);
1202
- unregisterActiveStream(streamKey, streamId);
1203
- }
1204
- return;
1205
- }
1206
- // Strip mention markers from the effective prompt.
1207
- rawBody = extractGroupMessageContent(rawContent, config);
1208
- }
1209
-
1210
- const commandAuthorized = resolveWecomCommandAuthorized({
1211
- cfg: config,
1212
- accountId: account.accountId,
1213
- senderId,
1214
- });
1215
-
1216
- // Skip empty messages, but allow image/mixed/file messages.
1217
- if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
1218
- logger.debug("WeCom: empty message, skipping");
1219
- if (streamId) {
1220
- await streamManager.finishStream(streamId);
1221
- unregisterActiveStream(streamKey, streamId);
1222
- }
1223
- return;
1224
- }
1225
-
1226
- // ========================================================================
1227
- // Command allowlist enforcement
1228
- // Admins bypass the allowlist entirely.
1229
- // ========================================================================
1230
- const senderIsAdmin = isWecomAdmin(senderId, config);
1231
- const commandCheck = checkCommandAllowlist(rawBody, config);
1232
-
1233
- if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
1234
- // Return block message when command is outside the allowlist.
1235
- const cmdConfig = getCommandConfig(config);
1236
- logger.warn("WeCom: blocked command", {
1237
- command: commandCheck.command,
1238
- from: senderId,
1239
- chatType: peerKind,
1240
- });
1241
-
1242
- // Send blocked-command response through the same stream.
1243
- if (streamId) {
1244
- streamManager.replaceIfPlaceholder(streamId, cmdConfig.blockMessage, THINKING_PLACEHOLDER);
1245
- await streamManager.finishStream(streamId);
1246
- unregisterActiveStream(streamKey, streamId);
1247
- }
1248
- return;
1249
- }
1250
-
1251
- if (commandCheck.isCommand && !commandCheck.allowed && senderIsAdmin) {
1252
- logger.info("WeCom: admin bypassed command allowlist", {
1253
- command: commandCheck.command,
1254
- from: senderId,
1255
- });
1256
- }
1257
-
1258
- logger.info("WeCom processing message", {
1259
- from: senderId,
1260
- chatType: peerKind,
1261
- peerId,
1262
- content: rawBody.substring(0, 50),
1263
- streamId,
1264
- isCommand: commandCheck.isCommand,
1265
- command: commandCheck.command,
1266
- });
1267
-
1268
- // ========================================================================
1269
- // Dynamic agent routing
1270
- // Admins route to the main agent directly.
1271
- // ========================================================================
1272
- const dynamicConfig = getDynamicAgentConfig(config);
1273
-
1274
- // Compute deterministic agent target for this conversation.
1275
- const targetAgentId =
1276
- !senderIsAdmin && dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config })
1277
- ? generateAgentId(peerKind, peerId)
1278
- : null;
1279
-
1280
- if (targetAgentId) {
1281
- await ensureDynamicAgentListed(targetAgentId);
1282
- logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
1283
- } else if (senderIsAdmin) {
1284
- logger.debug("Admin user, routing to main agent", { senderId });
1285
- }
1286
-
1287
- // ========================================================================
1288
- // Resolve route and override with dynamic agent when enabled
1289
- // ========================================================================
1290
- const route = core.routing.resolveAgentRoute({
1291
- cfg: config,
1292
- channel: "wecom",
1293
- accountId: account.accountId,
1294
- peer: {
1295
- kind: peerKind,
1296
- id: peerId,
1297
- },
1298
- });
1299
-
1300
- // Override default route with deterministic dynamic agent session key.
1301
- if (targetAgentId) {
1302
- route.agentId = targetAgentId;
1303
- route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
1304
- }
1305
-
1306
- // Build inbound context
1307
- const storePath = core.session.resolveStorePath(config.session?.store, {
1308
- agentId: route.agentId,
1309
- });
1310
- const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
1311
- const previousTimestamp = core.session.readSessionUpdatedAt({
1312
- storePath,
1313
- sessionKey: route.sessionKey,
1314
- });
1315
-
1316
- // Prefix sender id in group contexts so attribution stays explicit.
1317
- const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
1318
- const body = core.reply.formatAgentEnvelope({
1319
- channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
1320
- from: senderLabel,
1321
- timestamp: Date.now(),
1322
- previousTimestamp,
1323
- envelope: envelopeOptions,
1324
- body: rawBody,
1325
- });
1326
-
1327
- // Build context payload with optional image attachment.
1328
- const ctxBase = {
1329
- Body: body,
1330
- RawBody: rawBody,
1331
- CommandBody: rawBody,
1332
- From: `wecom:${senderId}`,
1333
- To: conversationId,
1334
- SessionKey: route.sessionKey,
1335
- AccountId: route.accountId,
1336
- ChatType: isGroupChat ? "group" : "direct",
1337
- ConversationLabel: isGroupChat ? `Group ${chatId}` : senderId,
1338
- SenderName: senderId,
1339
- SenderId: senderId,
1340
- GroupId: isGroupChat ? chatId : undefined,
1341
- Provider: "wecom",
1342
- Surface: "wecom",
1343
- OriginatingChannel: "wecom",
1344
- OriginatingTo: conversationId,
1345
- CommandAuthorized: commandAuthorized,
1346
- };
1347
-
1348
- // Download, decrypt, and attach media when present.
1349
- const allImageUrls = imageUrl ? [imageUrl] : imageUrls;
1350
-
1351
- if (allImageUrls.length > 0) {
1352
- const mediaPaths = [];
1353
- const mediaTypes = [];
1354
- const fallbackUrls = [];
1355
-
1356
- for (const url of allImageUrls) {
1357
- try {
1358
- const result = await downloadAndDecryptImage(url, account.encodingAesKey, account.token);
1359
- mediaPaths.push(result.localPath);
1360
- mediaTypes.push(result.mimeType);
1361
- } catch (e) {
1362
- logger.warn("Image decryption failed, using URL fallback", { error: e.message, url: url.substring(0, 80) });
1363
- fallbackUrls.push(url);
1364
- mediaTypes.push("image/jpeg");
1365
- }
1366
- }
1367
-
1368
- if (mediaPaths.length > 0) {
1369
- ctxBase.MediaPaths = mediaPaths;
1370
- }
1371
- if (fallbackUrls.length > 0) {
1372
- ctxBase.MediaUrls = fallbackUrls;
1373
- }
1374
- ctxBase.MediaTypes = mediaTypes;
1375
-
1376
- logger.info("Image attachments prepared", {
1377
- decrypted: mediaPaths.length,
1378
- fallback: fallbackUrls.length,
1379
- });
1380
-
1381
- // For image-only messages (no text), set a placeholder body.
1382
- if (!rawBody.trim()) {
1383
- const count = allImageUrls.length;
1384
- ctxBase.Body = count > 1
1385
- ? `[用户发送了${count}张图片]`
1386
- : "[用户发送了一张图片]";
1387
- ctxBase.RawBody = "[图片]";
1388
- ctxBase.CommandBody = "";
7
+ // Periodic cleanup for streamMeta and expired responseUrls to prevent memory leaks.
8
+ setInterval(() => {
9
+ const now = Date.now();
10
+ // Clean streamMeta entries whose stream no longer exists in streamManager.
11
+ for (const streamId of streamMeta.keys()) {
12
+ if (!streamManager.hasStream(streamId)) {
13
+ streamMeta.delete(streamId);
1389
14
  }
1390
15
  }
1391
-
1392
- // Handle file attachment.
1393
- if (fileUrl) {
1394
- try {
1395
- const localFilePath = await downloadWecomFile(fileUrl, fileName);
1396
- ctxBase.MediaPaths = [...(ctxBase.MediaPaths || []), localFilePath];
1397
- ctxBase.MediaTypes = [...(ctxBase.MediaTypes || []), guessMimeType(fileName)];
1398
- logger.info("File attachment prepared", { path: localFilePath, name: fileName });
1399
- } catch (e) {
1400
- logger.warn("File download failed", { error: e.message });
1401
- // Inform the agent about the file via text.
1402
- const label = fileName ? `[文件: ${fileName}]` : "[文件]";
1403
- if (!rawBody.trim()) {
1404
- ctxBase.Body = `[用户发送了文件] ${label}`;
1405
- ctxBase.RawBody = label;
1406
- ctxBase.CommandBody = "";
1407
- }
1408
- }
1409
- if (!rawBody.trim() && !ctxBase.Body) {
1410
- const label = fileName ? `[文件: ${fileName}]` : "[文件]";
1411
- ctxBase.Body = `[用户发送了文件] ${label}`;
1412
- ctxBase.RawBody = label;
1413
- ctxBase.CommandBody = "";
16
+ // Clean expired responseUrls (older than 1 hour).
17
+ for (const [key, entry] of responseUrls.entries()) {
18
+ if (now > entry.expiresAt) {
19
+ responseUrls.delete(key);
1414
20
  }
1415
21
  }
1416
-
1417
- const ctxPayload = core.reply.finalizeInboundContext(ctxBase);
1418
-
1419
- // Record session meta
1420
- void core.session
1421
- .recordSessionMetaFromInbound({
1422
- storePath,
1423
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1424
- ctx: ctxPayload,
1425
- })
1426
- .catch((err) => {
1427
- logger.error("WeCom: failed updating session meta", { error: err.message });
1428
- });
1429
-
1430
- // Serialize dispatches per user/group. Each message gets its own full dispatch
1431
- // cycle with proper deliver callbacks.
1432
- const prevLock = dispatchLocks.get(streamKey) ?? Promise.resolve();
1433
- const currentDispatch = prevLock.then(async () => {
1434
- // Dispatch reply with AI processing.
1435
- // Wrap in streamContext so outbound adapters resolve the correct stream.
1436
- await streamContext.run({ streamId, streamKey }, async () => {
1437
- await core.reply.dispatchReplyWithBufferedBlockDispatcher({
1438
- ctx: ctxPayload,
1439
- cfg: config,
1440
- dispatcherOptions: {
1441
- deliver: async (payload, info) => {
1442
- logger.info("Dispatcher deliver called", {
1443
- kind: info.kind,
1444
- hasText: !!(payload.text && payload.text.trim()),
1445
- textPreview: (payload.text || "").substring(0, 50),
1446
- });
1447
-
1448
- await deliverWecomReply({
1449
- payload,
1450
- senderId: streamKey,
1451
- streamId,
1452
- });
1453
-
1454
- // Mark stream complete on final payload.
1455
- if (streamId && info.kind === "final") {
1456
- await streamManager.finishStream(streamId);
1457
- logger.info("WeCom stream finished", { streamId });
1458
- }
1459
- },
1460
- onError: async (err, info) => {
1461
- logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
1462
- await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
1463
- },
1464
- },
1465
- });
1466
- });
1467
-
1468
- // Safety net: ensure stream finishes after dispatch.
1469
- if (streamId) {
1470
- const stream = streamManager.getStream(streamId);
1471
- if (!stream || stream.finished) {
1472
- unregisterActiveStream(streamKey, streamId);
1473
- } else {
1474
- await streamManager.finishStream(streamId);
1475
- unregisterActiveStream(streamKey, streamId);
1476
- logger.info("WeCom stream finished (safety net)", { streamId });
1477
- }
1478
- }
1479
- }).catch(async (err) => {
1480
- logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
1481
- await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
1482
- });
1483
-
1484
- dispatchLocks.set(streamKey, currentDispatch);
1485
- await currentDispatch;
1486
- if (dispatchLocks.get(streamKey) === currentDispatch) {
1487
- dispatchLocks.delete(streamKey);
1488
- }
1489
- }
1490
-
1491
- // =============================================================================
1492
- // Outbound Reply Delivery (Stream-only mode)
1493
- // =============================================================================
1494
-
1495
- async function deliverWecomReply({ payload, senderId, streamId }) {
1496
- const text = payload.text || "";
1497
-
1498
- logger.debug("deliverWecomReply called", {
1499
- hasText: !!text.trim(),
1500
- textPreview: text.substring(0, 50),
1501
- streamId,
1502
- senderId,
1503
- });
1504
-
1505
- // Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
1506
- const mediaRegex = /^MEDIA:\s*(.+)$/gm;
1507
- const mediaMatches = [];
1508
- let match;
1509
- while ((match = mediaRegex.exec(text)) !== null) {
1510
- const mediaPath = match[1].trim();
1511
- // Only intercept absolute filesystem paths.
1512
- if (mediaPath.startsWith("/")) {
1513
- mediaMatches.push({
1514
- fullMatch: match[0],
1515
- path: mediaPath,
1516
- });
1517
- logger.debug("Detected absolute path MEDIA line", {
1518
- streamId,
1519
- mediaPath,
1520
- line: match[0],
1521
- });
1522
- }
1523
- }
1524
-
1525
- // Queue absolute-path images and remove corresponding MEDIA lines from text.
1526
- let processedText = text;
1527
- if (mediaMatches.length > 0 && streamId) {
1528
- for (const media of mediaMatches) {
1529
- const queued = streamManager.queueImage(streamId, media.path);
1530
- if (queued) {
1531
- // Remove this MEDIA line once image was queued.
1532
- processedText = processedText.replace(media.fullMatch, "").trim();
1533
- logger.info("Queued absolute path image for stream", {
1534
- streamId,
1535
- imagePath: media.path,
1536
- });
1537
- }
1538
- }
1539
- }
1540
-
1541
- // All outbound content is sent via stream updates.
1542
- if (!processedText.trim()) {
1543
- logger.debug("WeCom: empty block after processing, skipping stream update");
1544
- return;
1545
- }
1546
-
1547
- // Helper: append content with duplicate suppression and placeholder awareness.
1548
- const appendToStream = (targetStreamId, content) => {
1549
- const stream = streamManager.getStream(targetStreamId);
1550
- if (!stream) {
1551
- return false;
1552
- }
1553
-
1554
- // If stream still has the placeholder, replace it entirely.
1555
- if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
1556
- streamManager.replaceIfPlaceholder(targetStreamId, content, THINKING_PLACEHOLDER);
1557
- return true;
1558
- }
1559
-
1560
- // Skip duplicate chunks (for example, block + final overlap).
1561
- if (stream.content.includes(content.trim())) {
1562
- logger.debug("WeCom: duplicate content, skipping", {
1563
- streamId: targetStreamId,
1564
- contentPreview: content.substring(0, 30),
1565
- });
1566
- return true;
1567
- }
1568
-
1569
- const separator = stream.content.length > 0 ? "\n\n" : "";
1570
- streamManager.appendStream(targetStreamId, separator + content);
1571
- return true;
1572
- };
1573
-
1574
- if (!streamId) {
1575
- // Try async context first, then fallback to active stream map.
1576
- const ctx = streamContext.getStore();
1577
- const contextStreamId = ctx?.streamId;
1578
- const activeStreamId = contextStreamId ?? resolveActiveStream(senderId);
1579
-
1580
- if (activeStreamId && streamManager.hasStream(activeStreamId)) {
1581
- appendToStream(activeStreamId, processedText);
1582
- logger.debug("WeCom stream appended (via context/activeStreams)", {
1583
- streamId: activeStreamId,
1584
- source: contextStreamId ? "asyncContext" : "activeStreams",
1585
- contentLength: processedText.length,
1586
- });
1587
- return;
1588
- }
1589
- logger.warn("WeCom: no active stream for this message", { senderId });
1590
- return;
1591
- }
1592
-
1593
- if (!streamManager.hasStream(streamId)) {
1594
- logger.warn("WeCom: stream not found, cannot update", { streamId });
1595
- return;
1596
- }
1597
-
1598
- appendToStream(streamId, processedText);
1599
- logger.debug("WeCom stream appended", {
1600
- streamId,
1601
- contentLength: processedText.length,
1602
- to: senderId,
1603
- });
1604
- }
1605
-
1606
- // =============================================================================
1607
- // Plugin Registration
1608
- // =============================================================================
22
+ }, 60 * 1000).unref();
1609
23
 
1610
24
  const plugin = {
1611
25
  // Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
@@ -1618,7 +32,7 @@ const plugin = {
1618
32
 
1619
33
  // Save runtime for message processing
1620
34
  setRuntime(api.runtime);
1621
- _openclawConfig = api.config;
35
+ setOpenclawConfig(api.config);
1622
36
 
1623
37
  // Register channel
1624
38
  api.registerChannel({ plugin: wecomChannelPlugin });