@sunnoy/wecom 1.1.2 → 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,1077 +1,25 @@
1
- import { WecomWebhook } from "./webhook.js";
2
1
  import { logger } from "./logger.js";
3
2
  import { streamManager } from "./stream-manager.js";
4
- import {
5
- generateAgentId,
6
- getDynamicAgentConfig,
7
- shouldTriggerGroupResponse,
8
- extractGroupMessageContent,
9
- } from "./dynamic-agent.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";
10
6
 
11
-
12
- const DEFAULT_ACCOUNT_ID = "default";
13
-
14
- // =============================================================================
15
- // 命令白名单配置
16
- // =============================================================================
17
-
18
- // 默认允许的斜杠命令(用户操作安全的命令)
19
- const DEFAULT_COMMAND_ALLOWLIST = [
20
- "/new", // 新建会话
21
- "/compact", // 压缩会话
22
- "/help", // 帮助
23
- "/status", // 状态
24
- ];
25
-
26
- // 默认拦截消息
27
- const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
28
-
29
- 支持的命令:
30
- • **/new** - 新建会话
31
- • **/compact** - 压缩会话(保留上下文摘要)
32
- • **/help** - 查看帮助
33
- • **/status** - 查看状态`;
34
-
35
- /**
36
- * 获取命令白名单配置
37
- */
38
- function getCommandConfig(config) {
39
- const wecom = config?.channels?.wecom || {};
40
- const commands = wecom.commands || {};
41
- return {
42
- allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
43
- blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
44
- enabled: commands.enabled !== false, // 默认启用白名单
45
- };
46
- }
47
-
48
- /**
49
- * 检查命令是否在白名单中
50
- * @param {string} message - 用户消息
51
- * @param {Object} config - 配置
52
- * @returns {{ isCommand: boolean, allowed: boolean, command: string | null }}
53
- */
54
- function checkCommandAllowlist(message, config) {
55
- const trimmed = message.trim();
56
-
57
- // 不是斜杠命令
58
- if (!trimmed.startsWith("/")) {
59
- return { isCommand: false, allowed: true, command: null };
60
- }
61
-
62
- // 提取命令(取第一个空格之前的部分)
63
- const command = trimmed.split(/\s+/)[0].toLowerCase();
64
-
65
- const cmdConfig = getCommandConfig(config);
66
-
67
- // 如果白名单功能禁用,允许所有命令
68
- if (!cmdConfig.enabled) {
69
- return { isCommand: true, allowed: true, command };
70
- }
71
-
72
- // 检查是否在白名单中
73
- const allowed = cmdConfig.allowlist.some(cmd =>
74
- cmd.toLowerCase() === command
75
- );
76
-
77
- return { isCommand: true, allowed, command };
78
- }
79
-
80
- // Runtime state (module-level singleton)
81
- let _runtime = null;
82
- let _openclawConfig = null;
83
- const ensuredDynamicAgentIds = new Set();
84
- let ensureDynamicAgentWriteQueue = Promise.resolve();
85
-
86
- /**
87
- * Set the plugin runtime (called during plugin registration)
88
- */
89
- function setRuntime(runtime) {
90
- _runtime = runtime;
91
- }
92
-
93
- function getRuntime() {
94
- if (!_runtime) {
95
- throw new Error("[wecom] Runtime not initialized");
96
- }
97
- return _runtime;
98
- }
99
-
100
- function upsertAgentIdOnlyEntry(cfg, agentId) {
101
- const normalizedId = String(agentId || "").trim().toLowerCase();
102
- if (!normalizedId) return false;
103
-
104
- if (!cfg.agents || typeof cfg.agents !== "object") {
105
- cfg.agents = {};
106
- }
107
-
108
- const currentList = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
109
- const existingIds = new Set(
110
- currentList
111
- .map((entry) => (entry && typeof entry.id === "string" ? entry.id.trim().toLowerCase() : ""))
112
- .filter(Boolean),
113
- );
114
-
115
- let changed = false;
116
- const nextList = [...currentList];
117
-
118
- // Keep "main" as the explicit default when creating agents.list for the first time.
119
- if (nextList.length === 0) {
120
- nextList.push({ id: "main" });
121
- existingIds.add("main");
122
- changed = true;
123
- }
124
-
125
- if (!existingIds.has(normalizedId)) {
126
- nextList.push({ id: normalizedId });
127
- changed = true;
128
- }
129
-
130
- if (changed) {
131
- cfg.agents.list = nextList;
132
- }
133
-
134
- return changed;
135
- }
136
-
137
- async function ensureDynamicAgentListed(agentId) {
138
- const normalizedId = String(agentId || "").trim().toLowerCase();
139
- if (!normalizedId) return;
140
- if (ensuredDynamicAgentIds.has(normalizedId)) return;
141
-
142
- const runtime = getRuntime();
143
- const configRuntime = runtime?.config;
144
- if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) return;
145
-
146
- ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
147
- .then(async () => {
148
- if (ensuredDynamicAgentIds.has(normalizedId)) return;
149
-
150
- const latestConfig = configRuntime.loadConfig();
151
- if (!latestConfig || typeof latestConfig !== "object") return;
152
-
153
- const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
154
- if (changed) {
155
- await configRuntime.writeConfigFile(latestConfig);
156
- logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
157
- }
158
-
159
- // Keep runtime in-memory config aligned to avoid stale reads in this process.
160
- if (_openclawConfig && typeof _openclawConfig === "object") {
161
- upsertAgentIdOnlyEntry(_openclawConfig, normalizedId);
162
- }
163
-
164
- ensuredDynamicAgentIds.add(normalizedId);
165
- })
166
- .catch((err) => {
167
- logger.warn("WeCom: failed to sync dynamic agent into agents.list", {
168
- agentId: normalizedId,
169
- error: err?.message || String(err),
170
- });
171
- });
172
-
173
- await ensureDynamicAgentWriteQueue;
174
- }
175
-
176
- // Webhook targets registry (similar to Google Chat)
177
- const webhookTargets = new Map();
178
-
179
- // Track active stream for each user, so outbound messages (like reset confirmation)
180
- // can be added to the correct stream instead of using response_url
181
- const activeStreams = new Map();
182
-
183
- function normalizeWecomAllowFromEntry(raw) {
184
- const trimmed = String(raw ?? "").trim();
185
- if (!trimmed) return null;
186
- if (trimmed === "*") return "*";
187
- return trimmed.replace(/^(wecom|wework):/i, "").replace(/^user:/i, "").toLowerCase();
188
- }
189
-
190
- function resolveWecomAllowFrom(cfg, accountId) {
191
- const wecom = cfg?.channels?.wecom;
192
- if (!wecom) return [];
193
-
194
- const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID).trim().toLowerCase();
195
- const accounts = wecom.accounts;
196
- const account =
197
- accounts && typeof accounts === "object"
198
- ? accounts[accountId] ??
199
- accounts[
200
- Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId) ?? ""
201
- ]
202
- : undefined;
203
-
204
- const allowFromRaw =
205
- account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
206
-
207
- if (!Array.isArray(allowFromRaw)) return [];
208
-
209
- return allowFromRaw
210
- .map(normalizeWecomAllowFromEntry)
211
- .filter((entry) => Boolean(entry));
212
- }
213
-
214
- function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
215
- const sender = String(senderId ?? "").trim().toLowerCase();
216
- if (!sender) return false;
217
-
218
- const allowFrom = resolveWecomAllowFrom(cfg, accountId);
219
- if (allowFrom.includes("*") || allowFrom.length === 0) return true;
220
- return allowFrom.includes(sender);
221
- }
222
-
223
- function normalizeWebhookPath(raw) {
224
- const trimmed = (raw || "").trim();
225
- if (!trimmed) return "/";
226
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
227
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
228
- return withSlash.slice(0, -1);
229
- }
230
- return withSlash;
231
- }
232
-
233
- function registerWebhookTarget(target) {
234
- const key = normalizeWebhookPath(target.path);
235
- const existing = webhookTargets.get(key) ?? [];
236
- webhookTargets.set(key, [...existing, { ...target, path: key }]);
237
- return () => {
238
- const updated = (webhookTargets.get(key) ?? []).filter((e) => e !== target);
239
- if (updated.length > 0) {
240
- webhookTargets.set(key, updated);
241
- } else {
242
- webhookTargets.delete(key);
243
- }
244
- };
245
- }
246
-
247
- // =============================================================================
248
- // Channel Plugin Definition
249
- // =============================================================================
250
-
251
- const wecomChannelPlugin = {
252
- id: "wecom",
253
- meta: {
254
- id: "wecom",
255
- label: "Enterprise WeChat",
256
- selectionLabel: "Enterprise WeChat (AI Bot)",
257
- docsPath: "/channels/wecom",
258
- blurb: "Enterprise WeChat AI Bot channel plugin.",
259
- aliases: ["wecom", "wework"],
260
- },
261
- capabilities: {
262
- chatTypes: ["direct", "group"], // 支持私聊和群聊
263
- reactions: false,
264
- threads: false,
265
- media: true, // Supports image sending via base64 encoding
266
- nativeCommands: false,
267
- blockStreaming: true, // WeCom AI Bot uses stream response format
268
- },
269
- reload: { configPrefixes: ["channels.wecom"] },
270
- configSchema: {
271
- schema: {
272
- "$schema": "http://json-schema.org/draft-07/schema#",
273
- "type": "object",
274
- "additionalProperties": false,
275
- "properties": {
276
- "enabled": {
277
- "type": "boolean",
278
- "description": "Enable WeCom channel",
279
- "default": true
280
- },
281
- "token": {
282
- "type": "string",
283
- "description": "WeCom bot token from admin console"
284
- },
285
- "encodingAesKey": {
286
- "type": "string",
287
- "description": "WeCom message encryption key (43 characters)",
288
- "minLength": 43,
289
- "maxLength": 43
290
- },
291
- "commands": {
292
- "type": "object",
293
- "description": "Command whitelist configuration",
294
- "additionalProperties": false,
295
- "properties": {
296
- "enabled": {
297
- "type": "boolean",
298
- "description": "Enable command whitelist filtering",
299
- "default": true
300
- },
301
- "allowlist": {
302
- "type": "array",
303
- "description": "Allowed commands (e.g., /new, /status, /help)",
304
- "items": {
305
- "type": "string"
306
- },
307
- "default": ["/new", "/status", "/help", "/compact"]
308
- }
309
- }
310
- },
311
- "dynamicAgents": {
312
- "type": "object",
313
- "description": "Dynamic agent routing configuration",
314
- "additionalProperties": false,
315
- "properties": {
316
- "enabled": {
317
- "type": "boolean",
318
- "description": "Enable per-user/per-group agent isolation",
319
- "default": true
320
- }
321
- }
322
- },
323
- "dm": {
324
- "type": "object",
325
- "description": "Direct message (private chat) configuration",
326
- "additionalProperties": false,
327
- "properties": {
328
- "createAgentOnFirstMessage": {
329
- "type": "boolean",
330
- "description": "Create separate agent for each user",
331
- "default": true
332
- }
333
- }
334
- },
335
- "groupChat": {
336
- "type": "object",
337
- "description": "Group chat configuration",
338
- "additionalProperties": false,
339
- "properties": {
340
- "enabled": {
341
- "type": "boolean",
342
- "description": "Enable group chat support",
343
- "default": true
344
- },
345
- "requireMention": {
346
- "type": "boolean",
347
- "description": "Only respond when @mentioned in groups",
348
- "default": true
349
- }
350
- }
351
- }
352
- }
353
- },
354
- uiHints: {
355
- "token": {
356
- "sensitive": true,
357
- "label": "Bot Token"
358
- },
359
- "encodingAesKey": {
360
- "sensitive": true,
361
- "label": "Encoding AES Key",
362
- "help": "43-character encryption key from WeCom admin console"
363
- }
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);
364
14
  }
365
- },
366
- config: {
367
- listAccountIds: (cfg) => {
368
- const wecom = cfg?.channels?.wecom;
369
- if (!wecom || !wecom.enabled) return [];
370
- return [DEFAULT_ACCOUNT_ID];
371
- },
372
- resolveAccount: (cfg, accountId) => {
373
- const wecom = cfg?.channels?.wecom;
374
- if (!wecom) return null;
375
- return {
376
- id: accountId || DEFAULT_ACCOUNT_ID,
377
- accountId: accountId || DEFAULT_ACCOUNT_ID,
378
- enabled: wecom.enabled !== false,
379
- token: wecom.token || "",
380
- encodingAesKey: wecom.encodingAesKey || "",
381
- webhookPath: wecom.webhookPath || "/webhooks/wecom",
382
- config: wecom,
383
- };
384
- },
385
- defaultAccountId: (cfg) => {
386
- const wecom = cfg?.channels?.wecom;
387
- if (!wecom || !wecom.enabled) return null;
388
- return DEFAULT_ACCOUNT_ID;
389
- },
390
- setAccountEnabled: ({ cfg, accountId, enabled }) => {
391
- if (!cfg.channels) cfg.channels = {};
392
- if (!cfg.channels.wecom) cfg.channels.wecom = {};
393
- cfg.channels.wecom.enabled = enabled;
394
- return cfg;
395
- },
396
- deleteAccount: ({ cfg, accountId }) => {
397
- if (cfg.channels?.wecom) delete cfg.channels.wecom;
398
- return cfg;
399
- },
400
- },
401
- directory: {
402
- self: async () => null,
403
- listPeers: async () => [],
404
- listGroups: async () => [],
405
- },
406
- // Outbound adapter: Send messages via stream (all messages go through stream now)
407
- outbound: {
408
- sendText: async ({ cfg, to, text, accountId }) => {
409
- // to格式: \"wecom:userid\" 或 \"userid\"
410
- const userId = to.replace(/^wecom:/, "");
411
-
412
- // 获取该用户当前活跃的 streamId
413
- const streamId = activeStreams.get(userId);
414
-
415
- if (streamId && streamManager.hasStream(streamId)) {
416
- logger.debug("Appending outbound text to stream", { userId, streamId, text: text.substring(0, 30) });
417
- // 使用 appendStream 追加内容,保留之前的内容
418
- const stream = streamManager.getStream(streamId);
419
- const separator = stream && stream.content.length > 0 ? "\n\n" : "";
420
- streamManager.appendStream(streamId, separator + text);
421
-
422
- return {
423
- channel: "wecom",
424
- messageId: `msg_stream_${Date.now()}`,
425
- };
426
- }
427
-
428
- // 如果没有活跃的流,记录警告
429
- logger.warn("WeCom outbound: no active stream for user", { userId });
430
-
431
- return {
432
- channel: "wecom",
433
- messageId: `fake_${Date.now()}`,
434
- };
435
- },
436
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
437
- const userId = to.replace(/^wecom:/, "");
438
- const streamId = activeStreams.get(userId);
439
-
440
- if (streamId && streamManager.hasStream(streamId)) {
441
- // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
442
- const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
443
-
444
- if (isLocalPath) {
445
- // Convert sandbox: URLs to absolute paths
446
- // Support both sandbox:/ and sandbox:// formats
447
- const absolutePath = mediaUrl
448
- .replace(/^sandbox:\/\//, "")
449
- .replace(/^sandbox:\//, "");
450
-
451
- logger.debug("Queueing local image for stream", {
452
- userId,
453
- streamId,
454
- mediaUrl,
455
- absolutePath
456
- });
457
-
458
- // Queue the image for processing when stream finishes
459
- const queued = streamManager.queueImage(streamId, absolutePath);
460
-
461
- if (queued) {
462
- // Append text content to stream (without markdown image)
463
- if (text) {
464
- const stream = streamManager.getStream(streamId);
465
- const separator = stream && stream.content.length > 0 ? "\n\n" : "";
466
- streamManager.appendStream(streamId, separator + text);
467
- }
468
-
469
- // Append placeholder indicating image will follow
470
- const imagePlaceholder = "\n\n[图片]";
471
- streamManager.appendStream(streamId, imagePlaceholder);
472
-
473
- return {
474
- channel: "wecom",
475
- messageId: `msg_stream_img_${Date.now()}`,
476
- };
477
- } else {
478
- logger.warn("Failed to queue image, falling back to markdown", {
479
- userId,
480
- streamId,
481
- mediaUrl
482
- });
483
- // Fallback to old behavior
484
- }
485
- }
486
-
487
- // OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
488
- const content = text ? `${text}\n\n![image](${mediaUrl})` : `![image](${mediaUrl})`;
489
- logger.debug("Appending outbound media to stream (markdown)", {
490
- userId,
491
- streamId,
492
- mediaUrl
493
- });
494
-
495
- // 使用 appendStream 追加内容
496
- const stream = streamManager.getStream(streamId);
497
- const separator = stream && stream.content.length > 0 ? "\n\n" : "";
498
- streamManager.appendStream(streamId, separator + content);
499
-
500
- return {
501
- channel: "wecom",
502
- messageId: `msg_stream_${Date.now()}`,
503
- };
504
- }
505
-
506
- logger.warn("WeCom outbound sendMedia: no active stream", { userId });
507
-
508
- return {
509
- channel: "wecom",
510
- messageId: `fake_${Date.now()}`,
511
- };
512
- },
513
- },
514
- gateway: {
515
- startAccount: async (ctx) => {
516
- const account = ctx.account;
517
- logger.info("WeCom gateway starting", { accountId: account.accountId, webhookPath: account.webhookPath });
518
-
519
- const unregister = registerWebhookTarget({
520
- path: account.webhookPath || "/webhooks/wecom",
521
- account,
522
- config: ctx.cfg,
523
- });
524
-
525
- return {
526
- shutdown: async () => {
527
- logger.info("WeCom gateway shutting down");
528
- unregister();
529
- },
530
- };
531
- },
532
- },
533
- };
534
-
535
- // =============================================================================
536
- // HTTP Webhook Handler
537
- // =============================================================================
538
-
539
- async function wecomHttpHandler(req, res) {
540
- const url = new URL(req.url || "", "http://localhost");
541
- const path = normalizeWebhookPath(url.pathname);
542
- const targets = webhookTargets.get(path);
543
-
544
- if (!targets || targets.length === 0) {
545
- return false; // Not handled by this plugin
546
15
  }
547
-
548
- const query = Object.fromEntries(url.searchParams);
549
- logger.debug("WeCom HTTP request", { method: req.method, path });
550
-
551
- // GET: URL Verification
552
- if (req.method === "GET") {
553
- const target = targets[0]; // Use first target for verification
554
- if (!target) {
555
- res.writeHead(503, { "Content-Type": "text/plain" });
556
- res.end("No webhook target configured");
557
- return true;
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);
558
20
  }
559
-
560
- const webhook = new WecomWebhook({
561
- token: target.account.token,
562
- encodingAesKey: target.account.encodingAesKey,
563
- });
564
-
565
- const echo = webhook.handleVerify(query);
566
- if (echo) {
567
- res.writeHead(200, { "Content-Type": "text/plain" });
568
- res.end(echo);
569
- logger.info("WeCom URL verification successful");
570
- return true;
571
- }
572
-
573
- res.writeHead(403, { "Content-Type": "text/plain" });
574
- res.end("Verification failed");
575
- logger.warn("WeCom URL verification failed");
576
- return true;
577
21
  }
578
-
579
- // POST: Message handling
580
- if (req.method === "POST") {
581
- const target = targets[0];
582
- if (!target) {
583
- res.writeHead(503, { "Content-Type": "text/plain" });
584
- res.end("No webhook target configured");
585
- return true;
586
- }
587
-
588
- // Read request body
589
- const chunks = [];
590
- for await (const chunk of req) {
591
- chunks.push(chunk);
592
- }
593
- const body = Buffer.concat(chunks).toString("utf-8");
594
- logger.debug("WeCom message received", { bodyLength: body.length });
595
-
596
- const webhook = new WecomWebhook({
597
- token: target.account.token,
598
- encodingAesKey: target.account.encodingAesKey,
599
- });
600
-
601
- const result = await webhook.handleMessage(query, body);
602
- if (!result) {
603
- res.writeHead(400, { "Content-Type": "text/plain" });
604
- res.end("Bad Request");
605
- return true;
606
- }
607
-
608
- // Handle text message
609
- if (result.message) {
610
- const msg = result.message;
611
- const { timestamp, nonce } = result.query;
612
- const content = (msg.content || "").trim();
613
-
614
- // 统一使用流式回复处理所有消息(包括命令)
615
- // 企业微信 AI Bot 的 response_url 只能使用一次,
616
- // 所以必须通过流式来发送所有回复内容
617
- const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
618
- streamManager.createStream(streamId);
619
-
620
- // 被动回复:返回流式消息ID (同步响应)
621
- const streamResponse = webhook.buildStreamResponse(
622
- streamId,
623
- "", // 初始内容为空
624
- false, // 未完成
625
- timestamp,
626
- nonce
627
- );
628
-
629
- res.writeHead(200, { "Content-Type": "application/json" });
630
- res.end(streamResponse);
631
-
632
- logger.info("Stream initiated", { streamId, from: msg.fromUser, isCommand: content.startsWith("/") });
633
- // 异步处理消息 - 调用AI并更新流内容
634
- processInboundMessage({
635
- message: msg,
636
- streamId,
637
- timestamp,
638
- nonce,
639
- account: target.account,
640
- config: target.config,
641
- }).catch(async (err) => {
642
- logger.error("WeCom message processing failed", { error: err.message });
643
- // 即使失败也要标记流为完成
644
- await streamManager.finishStream(streamId);
645
- });
646
-
647
- return true;
648
- }
649
-
650
- // Handle stream refresh - return current stream state
651
- if (result.stream) {
652
- const { timestamp, nonce } = result.query;
653
- const streamId = result.stream.id;
654
-
655
- // 获取流的当前状态
656
- const stream = streamManager.getStream(streamId);
657
-
658
- if (!stream) {
659
- // 流不存在或已过期,返回空的完成响应
660
- logger.warn("Stream not found for refresh", { streamId });
661
- const streamResponse = webhook.buildStreamResponse(
662
- streamId,
663
- "会话已过期",
664
- true,
665
- timestamp,
666
- nonce
667
- );
668
- res.writeHead(200, { "Content-Type": "application/json" });
669
- res.end(streamResponse);
670
- return true;
671
- }
672
-
673
- // 返回当前流的内容
674
- const streamResponse = webhook.buildStreamResponse(
675
- streamId,
676
- stream.content,
677
- stream.finished,
678
- timestamp,
679
- nonce,
680
- // Pass msgItem when stream is finished and has images
681
- stream.finished && stream.msgItem.length > 0
682
- ? { msgItem: stream.msgItem }
683
- : {}
684
- );
685
-
686
- res.writeHead(200, { "Content-Type": "application/json" });
687
- res.end(streamResponse);
688
-
689
- logger.debug("Stream refresh response sent", {
690
- streamId,
691
- contentLength: stream.content.length,
692
- finished: stream.finished
693
- });
694
-
695
- // 如果流已完成,在一段时间后清理
696
- if (stream.finished) {
697
- setTimeout(() => {
698
- streamManager.deleteStream(streamId);
699
- }, 30 * 1000); // 30秒后清理
700
- }
701
-
702
- return true;
703
- }
704
-
705
- // Handle event
706
- if (result.event) {
707
- logger.info("WeCom event received", { event: result.event });
708
-
709
- // 处理进入会话事件 - 发送欢迎语
710
- if (result.event?.event_type === "enter_chat") {
711
- const { timestamp, nonce } = result.query;
712
- const fromUser = result.event?.from?.userid || "";
713
-
714
- // 欢迎语内容
715
- const welcomeMessage = `你好!👋 我是 AI 助手。
716
-
717
- 你可以使用下面的指令管理会话:
718
- • **/new** - 新建会话(清空上下文)
719
- • **/compact** - 压缩会话(保留上下文摘要)
720
- • **/help** - 查看更多命令
721
-
722
- 有什么我可以帮你的吗?`;
723
-
724
- // 创建流并返回欢迎语
725
- const streamId = `welcome_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
726
- streamManager.createStream(streamId);
727
- streamManager.appendStream(streamId, welcomeMessage);
728
- await streamManager.finishStream(streamId);
729
-
730
- const streamResponse = webhook.buildStreamResponse(
731
- streamId,
732
- welcomeMessage,
733
- true, // 直接完成
734
- timestamp,
735
- nonce
736
- );
737
-
738
- logger.info("Sending welcome message", { fromUser, streamId });
739
- res.writeHead(200, { "Content-Type": "application/json" });
740
- res.end(streamResponse);
741
- return true;
742
- }
743
-
744
- res.writeHead(200, { "Content-Type": "text/plain" });
745
- res.end("success");
746
- return true;
747
- }
748
-
749
- res.writeHead(200, { "Content-Type": "text/plain" });
750
- res.end("success");
751
- return true;
752
- }
753
-
754
- res.writeHead(405, { "Content-Type": "text/plain" });
755
- res.end("Method Not Allowed");
756
- return true;
757
- }
758
-
759
- // =============================================================================
760
- // Inbound Message Processing (triggers AI response)
761
- // =============================================================================
762
-
763
- async function processInboundMessage({ message, streamId, timestamp, nonce, account, config }) {
764
- const runtime = getRuntime();
765
- const core = runtime.channel;
766
-
767
- const senderId = message.fromUser;
768
- const rawContent = message.content || "";
769
- const responseUrl = message.responseUrl;
770
- const chatType = message.chatType || "single"; // "single" 或 "group"
771
- const chatId = message.chatId || ""; // 群聊 ID
772
- const isGroupChat = chatType === "group" && chatId;
773
-
774
- // 确定 peerId:群聊用 chatId,私聊用 senderId
775
- const peerId = isGroupChat ? chatId : senderId;
776
- const peerKind = isGroupChat ? "group" : "dm";
777
- const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
778
-
779
- // 设置用户当前活跃的 streamId,供 outbound.sendText 使用
780
- // 群聊时用 chatId 作为 key
781
- const streamKey = isGroupChat ? chatId : senderId;
782
- if (streamId) {
783
- activeStreams.set(streamKey, streamId);
784
- }
785
-
786
- // 群聊消息检查:是否满足触发条件(@提及)
787
- let rawBody = rawContent;
788
- if (isGroupChat) {
789
- if (!shouldTriggerGroupResponse(rawContent, config)) {
790
- logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
791
- return;
792
- }
793
- // 提取实际内容(移除 @提及)
794
- rawBody = extractGroupMessageContent(rawContent, config);
795
- }
796
-
797
- const commandAuthorized = resolveWecomCommandAuthorized({
798
- cfg: config,
799
- accountId: account.accountId,
800
- senderId,
801
- });
802
-
803
- if (!rawBody.trim()) {
804
- logger.debug("WeCom: empty message, skipping");
805
- return;
806
- }
807
-
808
- // ========================================================================
809
- // 命令白名单检查
810
- // ========================================================================
811
- const commandCheck = checkCommandAllowlist(rawBody, config);
812
-
813
- if (commandCheck.isCommand && !commandCheck.allowed) {
814
- // 命令不在白名单中,返回拒绝消息
815
- const cmdConfig = getCommandConfig(config);
816
- logger.warn("WeCom: blocked command", {
817
- command: commandCheck.command,
818
- from: senderId,
819
- chatType: peerKind
820
- });
821
-
822
- // 通过流式响应返回拦截消息
823
- if (streamId) {
824
- streamManager.appendStream(streamId, cmdConfig.blockMessage);
825
- await streamManager.finishStream(streamId);
826
- activeStreams.delete(streamKey);
827
- }
828
- return;
829
- }
830
-
831
- logger.info("WeCom processing message", {
832
- from: senderId,
833
- chatType: peerKind,
834
- peerId,
835
- content: rawBody.substring(0, 50),
836
- streamId,
837
- isCommand: commandCheck.isCommand,
838
- command: commandCheck.command
839
- });
840
-
841
- // ========================================================================
842
- // 动态 Agent 逻辑(极简版)
843
- // 只需要生成 agentId 和构造 SessionKey,OpenClaw 会自动创建 workspace
844
- // ========================================================================
845
- const dynamicConfig = getDynamicAgentConfig(config);
846
-
847
- // 生成目标 AgentId
848
- const targetAgentId = dynamicConfig.enabled ? generateAgentId(peerKind, peerId) : null;
849
-
850
- if (targetAgentId) {
851
- await ensureDynamicAgentListed(targetAgentId);
852
- logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
853
- }
854
-
855
- // ========================================================================
856
- // 路由到目标 Agent
857
- // ========================================================================
858
- const route = core.routing.resolveAgentRoute({
859
- cfg: config,
860
- channel: "wecom",
861
- accountId: account.accountId,
862
- peer: {
863
- kind: peerKind,
864
- id: peerId,
865
- },
866
- });
867
-
868
- // 使用动态 Agent,覆盖默认路由
869
- if (targetAgentId) {
870
- route.agentId = targetAgentId;
871
- route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
872
- }
873
-
874
-
875
- // Build inbound context
876
- const storePath = core.session.resolveStorePath(config.session?.store, {
877
- agentId: route.agentId,
878
- });
879
- const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
880
- const previousTimestamp = core.session.readSessionUpdatedAt({
881
- storePath,
882
- sessionKey: route.sessionKey,
883
- });
884
-
885
- // 构建消息头,群聊时显示发送者
886
- const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
887
- const body = core.reply.formatAgentEnvelope({
888
- channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
889
- from: senderLabel,
890
- timestamp: Date.now(),
891
- previousTimestamp,
892
- envelope: envelopeOptions,
893
- body: rawBody,
894
- });
895
-
896
- const ctxPayload = core.reply.finalizeInboundContext({
897
- Body: body,
898
- RawBody: rawBody,
899
- CommandBody: rawBody,
900
- From: `wecom:${senderId}`,
901
- To: conversationId,
902
- SessionKey: route.sessionKey,
903
- AccountId: route.accountId,
904
- ChatType: isGroupChat ? "group" : "direct",
905
- ConversationLabel: isGroupChat ? `群聊 ${chatId}` : senderId,
906
- SenderName: senderId,
907
- SenderId: senderId,
908
- GroupId: isGroupChat ? chatId : undefined,
909
- Provider: "wecom",
910
- Surface: "wecom",
911
- OriginatingChannel: "wecom",
912
- OriginatingTo: conversationId,
913
- CommandAuthorized: commandAuthorized,
914
- });
915
-
916
- // Record session meta
917
- void core.session.recordSessionMetaFromInbound({
918
- storePath,
919
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
920
- ctx: ctxPayload,
921
- }).catch((err) => {
922
- logger.error("WeCom: failed updating session meta", { error: err.message });
923
- });
924
-
925
- // Dispatch reply with AI processing
926
- await core.reply.dispatchReplyWithBufferedBlockDispatcher({
927
- ctx: ctxPayload,
928
- cfg: config,
929
- dispatcherOptions: {
930
- deliver: async (payload, info) => {
931
- logger.info("Dispatcher deliver called", {
932
- kind: info.kind,
933
- hasText: !!(payload.text && payload.text.trim()),
934
- textPreview: (payload.text || "").substring(0, 50),
935
- });
936
-
937
- await deliverWecomReply({
938
- payload,
939
- account,
940
- responseUrl,
941
- senderId: streamKey, // 使用 streamKey(群聊时是 chatId)
942
- streamId,
943
- });
944
-
945
- // 如果是最终回复,标记流为完成
946
- if (streamId && info.kind === "final") {
947
- await streamManager.finishStream(streamId);
948
- logger.info("WeCom stream finished", { streamId });
949
- }
950
- },
951
- onError: async (err, info) => {
952
- logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
953
- // 发生错误时也标记流为完成
954
- if (streamId) {
955
- await streamManager.finishStream(streamId);
956
- }
957
- },
958
- },
959
- });
960
-
961
- // 确保在dispatch完成后标记流为完成(兜底机制)
962
- if (streamId) {
963
- await streamManager.finishStream(streamId);
964
- activeStreams.delete(streamKey); // 清理活跃流映射
965
- logger.info("WeCom stream finished (dispatch complete)", { streamId });
966
- }
967
- }
968
-
969
- // =============================================================================
970
- // Outbound Reply Delivery (Stream-only mode)
971
- // =============================================================================
972
-
973
- async function deliverWecomReply({ payload, account, responseUrl, senderId, streamId }) {
974
- const text = payload.text || "";
975
-
976
- logger.debug("deliverWecomReply called", {
977
- hasText: !!text.trim(),
978
- textPreview: text.substring(0, 50),
979
- streamId,
980
- senderId,
981
- });
982
-
983
- // 处理绝对路径的 MEDIA: 行(OpenClaw 会拒绝它们,所以我们需要手动处理)
984
- const mediaRegex = /^MEDIA:\s*(.+)$/gm;
985
- const mediaMatches = [];
986
- let match;
987
- while ((match = mediaRegex.exec(text)) !== null) {
988
- const mediaPath = match[1].trim();
989
- // 检查是否是绝对路径(以 / 开头)
990
- if (mediaPath.startsWith("/")) {
991
- mediaMatches.push({
992
- fullMatch: match[0],
993
- path: mediaPath
994
- });
995
- logger.debug("Detected absolute path MEDIA line", {
996
- streamId,
997
- mediaPath,
998
- line: match[0]
999
- });
1000
- }
1001
- }
1002
-
1003
- // 如果检测到绝对路径的 MEDIA 行,将图片加入队列并从文本中移除
1004
- let processedText = text;
1005
- if (mediaMatches.length > 0 && streamId) {
1006
- for (const media of mediaMatches) {
1007
- const queued = streamManager.queueImage(streamId, media.path);
1008
- if (queued) {
1009
- // 从文本中移除这行
1010
- processedText = processedText.replace(media.fullMatch, "").trim();
1011
- logger.info("Queued absolute path image for stream", {
1012
- streamId,
1013
- imagePath: media.path
1014
- });
1015
- }
1016
- }
1017
- }
1018
-
1019
- // 所有消息都通过流式发送
1020
- if (!processedText.trim()) {
1021
- logger.debug("WeCom: empty block after processing, skipping stream update");
1022
- return;
1023
- }
1024
-
1025
- // 辅助函数:追加内容到流(带去重)
1026
- const appendToStream = (targetStreamId, content) => {
1027
- const stream = streamManager.getStream(targetStreamId);
1028
- if (!stream) return false;
1029
-
1030
- // 去重:检查流内容是否已包含此消息(避免 block + final 重复)
1031
- if (stream.content.includes(content.trim())) {
1032
- logger.debug("WeCom: duplicate content, skipping", {
1033
- streamId: targetStreamId,
1034
- contentPreview: content.substring(0, 30)
1035
- });
1036
- return true; // 返回 true 表示不需要再发送
1037
- }
1038
-
1039
- const separator = stream.content.length > 0 ? "\n\n" : "";
1040
- streamManager.appendStream(targetStreamId, separator + content);
1041
- return true;
1042
- };
1043
-
1044
- if (!streamId) {
1045
- // 尝试从 activeStreams 获取
1046
- const activeStreamId = activeStreams.get(senderId);
1047
- if (activeStreamId && streamManager.hasStream(activeStreamId)) {
1048
- appendToStream(activeStreamId, processedText);
1049
- logger.debug("WeCom stream appended (via activeStreams)", {
1050
- streamId: activeStreamId,
1051
- contentLength: processedText.length,
1052
- });
1053
- return;
1054
- }
1055
- logger.warn("WeCom: no active stream for this message", { senderId });
1056
- return;
1057
- }
1058
-
1059
- if (!streamManager.hasStream(streamId)) {
1060
- logger.warn("WeCom: stream not found, cannot update", { streamId });
1061
- return;
1062
- }
1063
-
1064
- appendToStream(streamId, processedText);
1065
- logger.debug("WeCom stream appended", {
1066
- streamId,
1067
- contentLength: processedText.length,
1068
- to: senderId
1069
- });
1070
- }
1071
-
1072
- // =============================================================================
1073
- // Plugin Registration
1074
- // =============================================================================
22
+ }, 60 * 1000).unref();
1075
23
 
1076
24
  const plugin = {
1077
25
  // Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
@@ -1084,7 +32,7 @@ const plugin = {
1084
32
 
1085
33
  // Save runtime for message processing
1086
34
  setRuntime(api.runtime);
1087
- _openclawConfig = api.config;
35
+ setOpenclawConfig(api.config);
1088
36
 
1089
37
  // Register channel
1090
38
  api.registerChannel({ plugin: wecomChannelPlugin });