@yanhaidao/wecom 2.2.5 → 2.2.7

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,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx tsc:*)",
5
+ "Bash(./node_modules/.bin/tsc:*)",
6
+ "Bash(npm install)",
7
+ "Bash(npx vitest:*)",
8
+ "Bash(node --input-type=module -e:*)"
9
+ ]
10
+ }
11
+ }
package/CLAUDE.md ADDED
@@ -0,0 +1,238 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ This is an **OpenClaw Channel Plugin** for WeCom (企业微信 / WeChat Work). It enables AI bot integration with enterprise WeChat through a dual-mode architecture.
8
+
9
+ - **Package**: `@yanhaidao/wecom`
10
+ - **Type**: ES Module (NodeNext)
11
+ - **Entry**: `index.ts`
12
+
13
+ ## Architecture
14
+
15
+ ### Dual-Mode Design (Bot + Agent)
16
+
17
+ The plugin implements a unique dual-mode architecture:
18
+
19
+ | Mode | Purpose | Webhook Path | Capabilities |
20
+ |------|---------|--------------|--------------|
21
+ | **Bot** (智能体) | Real-time streaming chat | `/wecom`, `/wecom/bot` | Streaming responses, low latency, text/image only |
22
+ | **Agent** (自建应用) | Fallback & broadcast | `/wecom/agent` | File sending, broadcasts, long tasks (>6min) |
23
+
24
+ **Key Design Principle**: Bot is preferred for conversations; Agent is used as fallback when Bot cannot deliver (files, timeouts) or for proactive broadcasts.
25
+
26
+ ### Core Components
27
+
28
+ ```
29
+ index.ts # Plugin entry - registers channel and HTTP handlers
30
+ src/
31
+ channel.ts # ChannelPlugin implementation, lifecycle management
32
+ monitor.ts # Core webhook handler, message flow, stream state
33
+ runtime.ts # Runtime state singleton
34
+ http.ts # HTTP client with undici + proxy support
35
+ crypto.ts # AES-CBC encryption/decryption for webhooks
36
+ media.ts # Media file download/decryption
37
+ outbound.ts # Outbound message adapter
38
+ target.ts # Target resolution (user/party/tag/chat)
39
+ dynamic-agent.ts # Dynamic agent routing (per-user/per-group isolation)
40
+ agent/
41
+ api-client.ts # WeCom API client with AccessToken caching
42
+ handler.ts # XML webhook handler for Agent mode
43
+ config/
44
+ schema.ts # Zod schemas for configuration
45
+ monitor/
46
+ state.ts # StreamStore and ActiveReplyStore with TTL pruning
47
+ types/constants.ts # API endpoints and limits
48
+ ```
49
+
50
+ ### Stream State Management
51
+
52
+ The plugin uses a sophisticated stream state system (`src/monitor/state.ts`):
53
+
54
+ - **StreamStore**: Manages message streams with 6-minute timeout window
55
+ - **ActiveReplyStore**: Tracks `response_url` for proactive pushes
56
+ - **Pending Queue**: Debounces rapid messages (500ms default)
57
+ - **Message Deduplication**: Uses `msgid` to prevent duplicate processing
58
+
59
+ ### Token Management
60
+
61
+ Agent mode uses automatic AccessToken caching (`src/agent/api-client.ts`):
62
+ - Token cached with 60-second refresh buffer
63
+ - Automatic retry on expiration
64
+ - Thread-safe refresh deduplication
65
+
66
+ ## Development Commands
67
+
68
+ ### Testing
69
+
70
+ This project uses **Vitest**. Tests extend from a base config at `../../vitest.config.ts`:
71
+
72
+ ```bash
73
+ # Run all tests
74
+ npx vitest --config vitest.config.ts
75
+
76
+ # Run specific test file
77
+ npx vitest --config vitest.config.ts src/crypto.test.ts
78
+
79
+ # Run tests matching pattern
80
+ npx vitest --config vitest.config.ts --testNamePattern="should encrypt"
81
+
82
+ # Watch mode
83
+ npx vitest --config vitest.config.ts --watch
84
+ ```
85
+
86
+ Test files are located alongside source files with `.test.ts` suffix:
87
+ - `src/crypto.test.ts`
88
+ - `src/monitor.integration.test.ts`
89
+ - `src/monitor/state.queue.test.ts`
90
+ - etc.
91
+
92
+ ### Type Checking
93
+
94
+ ```bash
95
+ npx tsc --noEmit
96
+ ```
97
+
98
+ ### Build
99
+
100
+ The plugin is loaded directly as TypeScript by OpenClaw. No build step is required for development, but type checking is recommended.
101
+
102
+ ## Configuration Schema
103
+
104
+ Configuration is validated via Zod (`src/config/schema.ts`):
105
+
106
+ ```typescript
107
+ {
108
+ enabled: boolean,
109
+ bot: {
110
+ token: string, // Bot webhook token
111
+ encodingAESKey: string, // AES encryption key
112
+ receiveId: string?, // Optional receive ID
113
+ streamPlaceholderContent: string?, // "Thinking..."
114
+ welcomeText: string?,
115
+ dm: { policy, allowFrom }
116
+ },
117
+ agent: {
118
+ corpId: string,
119
+ corpSecret: string,
120
+ agentId: number,
121
+ token: string, // Callback token
122
+ encodingAESKey: string, // Callback AES key
123
+ welcomeText: string?,
124
+ dm: { policy, allowFrom }
125
+ },
126
+ network: {
127
+ egressProxyUrl: string? // For dynamic IP scenarios
128
+ },
129
+ media: {
130
+ maxBytes: number? // Default 25MB
131
+ },
132
+ dynamicAgents: {
133
+ enabled: boolean? // Enable per-user/per-group agents
134
+ dmCreateAgent: boolean? // Create agent per DM user
135
+ groupEnabled: boolean? // Enable for group chats
136
+ adminUsers: string[]? // Admin users (bypass dynamic routing)
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Dynamic Agent Routing
142
+
143
+ When `dynamicAgents.enabled` is `true`, the plugin automatically creates isolated Agent instances for each user/group:
144
+
145
+ ```bash
146
+ # Enable dynamic agents
147
+ openclaw config set channels.wecom.dynamicAgents.enabled true
148
+
149
+ # Configure admin users (use main agent)
150
+ openclaw config set channels.wecom.dynamicAgents.adminUsers '["admin1","admin2"]'
151
+ ```
152
+
153
+ **Generated Agent ID format**: `wecom-{type}-{peerId}`
154
+ - DM: `wecom-dm-zhangsan`
155
+ - Group: `wecom-group-wr123456`
156
+
157
+ Dynamic agents are automatically added to `agents.list` in the config file.
158
+
159
+ ## Key Technical Details
160
+
161
+ ### Webhook Security
162
+
163
+ - **Signature Verification**: HMAC-SHA256 with token
164
+ - **Encryption**: AES-CBC with PKCS#7 padding (32-byte blocks)
165
+ - **Paths**: `/wecom` (legacy), `/wecom/bot`, `/wecom/agent`
166
+
167
+ ### Timeout Handling
168
+
169
+ Bot mode has a 6-minute window (360s) for streaming responses. The plugin:
170
+ - Tracks deadline: `createdAt + 6 * 60 * 1000`
171
+ - Switches to Agent fallback at `deadline - 30s` margin
172
+ - Sends DM via Agent for remaining content
173
+
174
+ ### Media Handling
175
+
176
+ - **Inbound**: Decrypts WeCom encrypted media URLs
177
+ - **Outbound Images**: Base64 encoded via `msg_item` in stream
178
+ - **Outbound Files**: Requires Agent mode, sent via `media/upload` + `message/send`
179
+ - **Max Size**: 25MB default (configurable via `channels.wecom.media.maxBytes`)
180
+
181
+ ### Proxy Support
182
+
183
+ For servers with dynamic IPs (common error: `60020 not allow to access from your ip`):
184
+
185
+ ```bash
186
+ openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
187
+ ```
188
+
189
+ ## Testing Notes
190
+
191
+ - Tests use Vitest with `../../vitest.config.ts` as base
192
+ - Integration tests mock WeCom API responses
193
+ - Crypto tests verify AES encryption round-trips
194
+ - Monitor tests cover stream state transitions and queue behavior
195
+
196
+ ## Common Patterns
197
+
198
+ ### Adding a New Message Type Handler
199
+
200
+ 1. Update `buildInboundBody()` in `src/monitor.ts` to parse the message
201
+ 2. Add type definitions in `src/types/message.ts`
202
+ 3. Update `processInboundMessage()` if media handling is needed
203
+
204
+ ### Agent API Calls
205
+
206
+ Always use `api-client.ts` methods which handle token management:
207
+
208
+ ```typescript
209
+ import { sendText, uploadMedia } from "./agent/api-client.js";
210
+
211
+ // Token is automatically cached and refreshed
212
+ await sendText({ agent, toUser: "userid", text: "Hello" });
213
+ ```
214
+
215
+ ### Stream Content Updates
216
+
217
+ Use `streamStore.updateStream()` for thread-safe updates:
218
+
219
+ ```typescript
220
+ streamStore.updateStream(streamId, (state) => {
221
+ state.content = "new content";
222
+ state.finished = true;
223
+ });
224
+ ```
225
+
226
+ ## Dependencies
227
+
228
+ - `undici`: HTTP client with proxy support
229
+ - `fast-xml-parser`: XML parsing for Agent callbacks
230
+ - `zod`: Configuration validation
231
+ - `openclaw`: Peer dependency (>=2026.1.26)
232
+
233
+ ## WeCom API Endpoints Used
234
+
235
+ - `GET_TOKEN`: `https://qyapi.weixin.qq.com/cgi-bin/gettoken`
236
+ - `SEND_MESSAGE`: `https://qyapi.weixin.qq.com/cgi-bin/message/send`
237
+ - `UPLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/upload`
238
+ - `DOWNLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/get`
package/GEMINI.md ADDED
@@ -0,0 +1,76 @@
1
+ # 企业微信 (WeCom) 插件上下文
2
+
3
+ ## 平台架构:机器人 (Bot) vs 自建应用 (Agent)
4
+
5
+ 本插件采用 **双模 (Dual-Mode)** 架构,结合了企业微信“智能机器人”和“自建应用”的优势。
6
+
7
+ ### 1. 定义与边界
8
+
9
+ | 特性 | **Bot (智能机器人)** | **Agent (自建应用)** |
10
+ | :--- | :--- | :--- |
11
+ | **身份** | 虚拟用户/助手。 | 工作台中的服务应用。 |
12
+ | **位置** | 存在于 **会话** 中 (单聊或群聊)。 | 存在于 **工作台** 或应用列表。 |
13
+ | **协议** | **JSON** (加密)。 | **XML** (加密)。 |
14
+ | **交互** | 对话式 (回复用户)。 | 事务式 (通知用户/系统)。 |
15
+ | **流式 (Stream)** | ✅ **支持** (打字机效果)。 | ❌ 不支持。 |
16
+ | **文件能力** | ❌ **受限** (被动回复无法发文件)。 | ✅ **完整支持** (API 发送视频、文件、图片)。 |
17
+ | **群聊支持** | ✅ 原生支持 (可被 @)。 | ⚠️ 受限 (仅能在自建群或通过 API 推送)。 |
18
+
19
+ ### 2. 通信协议
20
+
21
+ #### A. Bot (智能机器人)
22
+ * **接收 (回调)**:
23
+ * **格式**: JSON。
24
+ * **单聊**: `chattype: "single"`. 无 `chatid`。`cid` = `from.userid`。
25
+ * **群聊**: `chattype: "group"`. 有 `chatid`。`cid` = `chatid`。
26
+ * **发送 (回复)**:
27
+ * **交互回复**: 使用回调中的 `response_url`。支持 `markdown`, `template_card`。**不支持文件/视频**。
28
+ * **主动推送**: 使用 Webhook Key。
29
+
30
+ #### B. Agent (自建应用)
31
+ * **接收 (回调)**:
32
+ * **格式**: XML。
33
+ * **结构**: `<ToUserName>`, `<FromUserName>`, `<MsgType>`, `<Content>`。
34
+ * **发送 (推送)**:
35
+ * **API**: `message/send`。
36
+ * **能力**: 支持所有媒体类型 (文件, 视频, 语音, 文本, 卡片)。
37
+ * **对象**: 用户 (`touser`), 部门 (`toparty`), 标签 (`totag`)。
38
+
39
+ ---
40
+
41
+ ## 核心策略:机器人优先,智能体兜底 (接近 6 分钟切换)
42
+
43
+ ### 目标
44
+ * **默认**: 用户在 Bot 会话触发,结果在 Bot 会话内完成回复(含流式)。
45
+ * **兜底**: 当 Bot 因超时(接近 6 分钟限制)、异常或流式窗口结束无法完成交付时,若配置了 Agent,则由 Agent 私信触发用户发送最终结果。
46
+
47
+ ### 关键约束
48
+ * **6 分钟窗口**: 企业微信智能机器人流式链路最多维持 6 分钟。超过后连接断开。
49
+
50
+ ### 流程详解
51
+
52
+ #### 1. 启动阶段 (Bot)
53
+ 1. **接收消息**: 解析 JSON 回调。
54
+ 2. **生成 StreamID**: 确定性生成 `streamId = hash(accountId + aibotid + msgid)`,防止并发重试导致重复创建流。
55
+ 3. **快速响应**: 立即返回“已接收,处理中”或流式首包,建立连接。
56
+
57
+ #### 2. 执行阶段 (Worker)
58
+ 4. **异步处理**: 任务入队执行。
59
+ 5. **流式刷新**: 当收到企微的“流式消息刷新”回调时,根据 `streamId` 返回最新生成的内容。
60
+
61
+ #### 3. 正常交付 (Bot)
62
+ 6. **完成**: 任务在 6 分钟内完成,发送 `finish=true` 信号结束流式消息。
63
+
64
+ #### 4. 异常切换 (Agent 兜底)
65
+ 7. **触发条件**:
66
+ * **超时临界**: `now >= createdAt + 6min - 安全阈值(30s)`。
67
+ * **链路中断**: Bot 刷新回调长时间未到达 (如 > 60s)。
68
+ * **发送异常**: Bot 接口报错。
69
+ 8. **切换动作**:
70
+ * **状态标记**: 将任务标记为 `agent_fallback`。
71
+ * **用户提示**: (可选) Bot 在群内尝试发送“结果将私信送达”。
72
+ * **私信推送**: Agent 调用 `message/send` 接口,将最终结果(文本/文件)推送给触发者 `userId`。
73
+ * **注意**: 即使原会话是群聊,兜底结果通常通过 Agent **私信** 发送给触发者,因为 Agent 难以直接向普通群推送消息。
74
+
75
+ ### 总结
76
+ 该策略确保了用户体验(首选流式)和交付可靠性(超时/发文件走 Agent)的平衡。
package/README.md CHANGED
@@ -338,6 +338,14 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
338
338
 
339
339
  ## 更新日志
340
340
 
341
+ ### 2026.2.7
342
+
343
+ - ✨ **动态 Agent 路由**:新增按用户/群组隔离的动态路由功能,支持为每个对话主体自动分配独立的 Agent 实例(如 `wecom-dm-xxx`)。
344
+ - 🛠 **自动注册机制**:动态生成的 Agent 会自动异步注册到 `agents.list` 配置中,实现多租户 Agent 的无感扩容与管理。
345
+ - 🛡 **权限与兼容性**:支持 `adminUsers` 白名单绕过机制以使用主 Agent;功能默认关闭,确保对现有配置完全兼容。
346
+ - ⚙️ **精细化配置**:新增 `channels.wecom.dynamicAgents` 配置项,可独立控制私聊和群聊的动态路由行为。
347
+ - 🙏 **特别致谢**:感谢 [WhyMeta](https://github.com/WhyMeta) 开发者对动态路由实施方案的贡献。
348
+
341
349
  ### 2026.2.5
342
350
 
343
351
  - 🛠 **体验优化**:WeCom 媒体(图片/语音/视频/文件)处理的默认大小上限提升到 25MB,减少大文件因超限导致的“下载/保存失败”。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
@@ -43,4 +43,4 @@
43
43
  "@types/node": "^25.2.0",
44
44
  "typescript": "^5.9.3"
45
45
  }
46
- }
46
+ }
@@ -17,6 +17,7 @@ import { getWecomRuntime } from "../runtime.js";
17
17
  import type { WecomAgentInboundMessage } from "../types/index.js";
18
18
  import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
19
19
  import { resolveWecomMediaMaxBytes } from "../config/index.js";
20
+ import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
20
21
 
21
22
  /** 错误提示信息 */
22
23
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
@@ -442,6 +443,26 @@ async function processAgentMessage(params: {
442
443
  peer: { kind: isGroup ? "group" : "dm", id: peerId },
443
444
  });
444
445
 
446
+ // ===== 动态 Agent 路由注入 =====
447
+ const useDynamicAgent = shouldUseDynamicAgent({
448
+ chatType: isGroup ? "group" : "dm",
449
+ senderId: fromUser,
450
+ config,
451
+ });
452
+
453
+ if (useDynamicAgent) {
454
+ const targetAgentId = generateAgentId(
455
+ isGroup ? "group" : "dm",
456
+ peerId
457
+ );
458
+ route.agentId = targetAgentId;
459
+ route.sessionKey = `agent:${targetAgentId}:${isGroup ? "group" : "dm"}:${peerId}`;
460
+ // 异步添加到 agents.list(不阻塞)
461
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
462
+ log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
463
+ }
464
+ // ===== 动态 Agent 路由注入结束 =====
465
+
445
466
  // 构建上下文
446
467
  const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
447
468
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
@@ -92,6 +92,22 @@ const agentSchema = z.object({
92
92
  dm: dmSchema,
93
93
  }).optional();
94
94
 
95
+ /**
96
+ * **dynamicAgentsSchema (动态 Agent 配置)**
97
+ *
98
+ * 控制是否按用户/群组自动创建独立 Agent 实例。
99
+ * @property enabled - 是否启用动态 Agent
100
+ * @property dmCreateAgent - 私聊是否为每个用户创建独立 Agent
101
+ * @property groupEnabled - 群聊是否启用动态 Agent
102
+ * @property adminUsers - 管理员列表(绕过动态路由)
103
+ */
104
+ const dynamicAgentsSchema = z.object({
105
+ enabled: z.boolean().optional(),
106
+ dmCreateAgent: z.boolean().optional(),
107
+ groupEnabled: z.boolean().optional(),
108
+ adminUsers: z.array(z.string()).optional(),
109
+ }).optional();
110
+
95
111
  /** 顶层 WeCom 配置 Schema */
96
112
  export const WecomConfigSchema = z.object({
97
113
  enabled: z.boolean().optional(),
@@ -99,6 +115,7 @@ export const WecomConfigSchema = z.object({
99
115
  agent: agentSchema,
100
116
  media: mediaSchema,
101
117
  network: networkSchema,
118
+ dynamicAgents: dynamicAgentsSchema,
102
119
  });
103
120
 
104
121
  export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * **动态 Agent 路由模块**
3
+ *
4
+ * 为每个用户/群组自动生成独立的 Agent ID,实现会话隔离。
5
+ * 参考: openclaw-plugin-wecom/dynamic-agent.js
6
+ */
7
+
8
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
9
+
10
+ export interface DynamicAgentConfig {
11
+ enabled: boolean;
12
+ dmCreateAgent: boolean;
13
+ groupEnabled: boolean;
14
+ adminUsers: string[];
15
+ }
16
+
17
+ /**
18
+ * **getDynamicAgentConfig (读取动态 Agent 配置)**
19
+ *
20
+ * 从全局配置中读取动态 Agent 配置,提供默认值。
21
+ */
22
+ export function getDynamicAgentConfig(config: OpenClawConfig): DynamicAgentConfig {
23
+ const dynamicAgents = (config as { channels?: { wecom?: { dynamicAgents?: Partial<DynamicAgentConfig> } } })?.channels?.wecom?.dynamicAgents;
24
+ return {
25
+ enabled: dynamicAgents?.enabled ?? false,
26
+ dmCreateAgent: dynamicAgents?.dmCreateAgent ?? true,
27
+ groupEnabled: dynamicAgents?.groupEnabled ?? true,
28
+ adminUsers: dynamicAgents?.adminUsers ?? [],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * **generateAgentId (生成动态 Agent ID)**
34
+ *
35
+ * 根据聊天类型和对端 ID 生成确定性的 Agent ID。
36
+ * 格式: wecom-{type}-{sanitizedPeerId}
37
+ * - type: dm | group
38
+ * - sanitizedPeerId: 小写,非字母数字下划线横线替换为下划线
39
+ *
40
+ * @example
41
+ * generateAgentId("dm", "ZhangSan") // "wecom-dm-zhangsan"
42
+ * generateAgentId("group", "wr123456") // "wecom-group-wr123456"
43
+ */
44
+ export function generateAgentId(chatType: "dm" | "group", peerId: string): string {
45
+ const sanitized = String(peerId)
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9_-]/g, "_");
48
+ return `wecom-${chatType}-${sanitized}`;
49
+ }
50
+
51
+ /**
52
+ * **shouldUseDynamicAgent (检查是否使用动态 Agent)**
53
+ *
54
+ * 根据配置和发送者信息判断是否应使用动态 Agent。
55
+ * 管理员(adminUsers)始终绕过动态路由,使用主 Agent。
56
+ */
57
+ export function shouldUseDynamicAgent(params: {
58
+ chatType: "dm" | "group";
59
+ senderId: string;
60
+ config: OpenClawConfig;
61
+ }): boolean {
62
+ const { chatType, senderId, config } = params;
63
+ const dynamicConfig = getDynamicAgentConfig(config);
64
+
65
+ if (!dynamicConfig.enabled) {
66
+ return false;
67
+ }
68
+
69
+ // 管理员绕过动态路由
70
+ const sender = String(senderId).trim().toLowerCase();
71
+ const isAdmin = dynamicConfig.adminUsers.some(
72
+ (admin) => admin.trim().toLowerCase() === sender
73
+ );
74
+ if (isAdmin) {
75
+ return false;
76
+ }
77
+
78
+ if (chatType === "group") {
79
+ return dynamicConfig.groupEnabled;
80
+ }
81
+ return dynamicConfig.dmCreateAgent;
82
+ }
83
+
84
+ /**
85
+ * 内存中已确保的 Agent ID(避免重复写入)
86
+ */
87
+ const ensuredDynamicAgentIds = new Set<string>();
88
+
89
+ /**
90
+ * 写入队列(避免并发冲突)
91
+ */
92
+ let ensureDynamicAgentWriteQueue: Promise<void> = Promise.resolve();
93
+
94
+ /**
95
+ * 将 Agent ID 插入 agents.list(如果不存在)
96
+ */
97
+ function upsertAgentIdOnlyEntry(cfg: Record<string, unknown>, agentId: string): boolean {
98
+ if (!cfg.agents || typeof cfg.agents !== "object") {
99
+ cfg.agents = {};
100
+ }
101
+
102
+ const agentsObj = cfg.agents as Record<string, unknown>;
103
+ const currentList: Array<{ id: string }> = Array.isArray(agentsObj.list) ? agentsObj.list as Array<{ id: string }> : [];
104
+ const existingIds = new Set(
105
+ currentList
106
+ .map((entry) => entry?.id?.trim().toLowerCase())
107
+ .filter((id): id is string => Boolean(id))
108
+ );
109
+
110
+ let changed = false;
111
+ const nextList = [...currentList];
112
+
113
+ // 首次创建时保留 main 作为默认
114
+ if (nextList.length === 0) {
115
+ nextList.push({ id: "main" });
116
+ existingIds.add("main");
117
+ changed = true;
118
+ }
119
+
120
+ if (!existingIds.has(agentId.toLowerCase())) {
121
+ nextList.push({ id: agentId });
122
+ changed = true;
123
+ }
124
+
125
+ if (changed) {
126
+ agentsObj.list = nextList;
127
+ }
128
+
129
+ return changed;
130
+ }
131
+
132
+ /**
133
+ * **ensureDynamicAgentListed (确保动态 Agent 已添加到 agents.list)**
134
+ *
135
+ * 将动态生成的 Agent ID 添加到 OpenClaw 配置中的 agents.list。
136
+ * 特性:
137
+ * - 幂等:使用内存 Set 避免重复写入
138
+ * - 串行:使用 Promise 队列避免并发冲突
139
+ * - 异步:不阻塞消息处理流程
140
+ */
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ export async function ensureDynamicAgentListed(agentId: string, runtime: any): Promise<void> {
143
+ const normalizedId = String(agentId).trim().toLowerCase();
144
+ if (!normalizedId) return;
145
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
146
+
147
+ const configRuntime = runtime?.config;
148
+ if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) return;
149
+
150
+ ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
151
+ .then(async () => {
152
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
153
+
154
+ const latestConfig = configRuntime.loadConfig!();
155
+ if (!latestConfig || typeof latestConfig !== "object") return;
156
+
157
+ const changed = upsertAgentIdOnlyEntry(latestConfig as Record<string, unknown>, normalizedId);
158
+ if (changed) {
159
+ await configRuntime.writeConfigFile!(latestConfig as unknown);
160
+ }
161
+
162
+ ensuredDynamicAgentIds.add(normalizedId);
163
+ })
164
+ .catch((err) => {
165
+ console.warn(`[wecom] 动态 Agent 添加失败: ${normalizedId}`, err);
166
+ });
167
+
168
+ await ensureDynamicAgentWriteQueue;
169
+ }
170
+
171
+ /**
172
+ * **resetEnsuredCache (重置已确保缓存)**
173
+ *
174
+ * 主要用于测试场景,重置内存中的缓存状态。
175
+ */
176
+ export function resetEnsuredCache(): void {
177
+ ensuredDynamicAgentIds.clear();
178
+ }
package/src/monitor.ts CHANGED
@@ -28,6 +28,7 @@ import axios from "axios";
28
28
  import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState, PendingInbound, ActiveReplyState } from "./monitor/types.js";
29
29
  import { monitorState, LIMITS } from "./monitor/state.js";
30
30
  import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "./shared/command-auth.js";
31
+ import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "./dynamic-agent.js";
31
32
 
32
33
  // Global State
33
34
  monitorState.streamStore.setFlushHandler((pending) => void flushPending(pending));
@@ -927,6 +928,26 @@ async function startAgentForStream(params: {
927
928
  peer: { kind: chatType === "group" ? "group" : "dm", id: chatId },
928
929
  });
929
930
 
931
+ // ===== 动态 Agent 路由注入 =====
932
+ const useDynamicAgent = shouldUseDynamicAgent({
933
+ chatType: chatType === "group" ? "group" : "dm",
934
+ senderId: userid,
935
+ config,
936
+ });
937
+
938
+ if (useDynamicAgent) {
939
+ const targetAgentId = generateAgentId(
940
+ chatType === "group" ? "group" : "dm",
941
+ chatId
942
+ );
943
+ route.agentId = targetAgentId;
944
+ route.sessionKey = `agent:${targetAgentId}:${chatType === "group" ? "group" : "dm"}:${chatId}`;
945
+ // 异步添加到 agents.list(不阻塞)
946
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
947
+ logVerbose(target, `dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
948
+ }
949
+ // ===== 动态 Agent 路由注入结束 =====
950
+
930
951
  logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
931
952
  logVerbose(target, `启动 Agent 处理: streamId=${streamId} 路由=${route.agentId} 类型=${chatType} ID=${chatId}`);
932
953
 
@@ -70,6 +70,18 @@ export type WecomAgentConfig = {
70
70
  dm?: WecomDmConfig;
71
71
  };
72
72
 
73
+ /** 动态 Agent 配置 */
74
+ export type WecomDynamicAgentsConfig = {
75
+ /** 是否启用动态 Agent */
76
+ enabled?: boolean;
77
+ /** 私聊:是否为每个用户创建独立 Agent */
78
+ dmCreateAgent?: boolean;
79
+ /** 群聊:是否启用动态 Agent */
80
+ groupEnabled?: boolean;
81
+ /** 管理员列表(绕过动态路由,使用主 Agent) */
82
+ adminUsers?: string[];
83
+ };
84
+
73
85
  /**
74
86
  * 顶层 WeCom 配置
75
87
  * 通过 bot / agent 字段隐式指定模式
@@ -85,4 +97,6 @@ export type WecomConfig = {
85
97
  media?: WecomMediaConfig;
86
98
  /** 网络配置 */
87
99
  network?: WecomNetworkConfig;
100
+ /** 动态 Agent 配置 */
101
+ dynamicAgents?: WecomDynamicAgentsConfig;
88
102
  };
@@ -0,0 +1,360 @@
1
+ # 动态 Agent 路由实施方案
2
+
3
+ 参考 `openclaw-plugin-wecom/dynamic-agent.js` 实现,为 `@yanhaidao/wecom` 添加按用户/群隔离的动态 Agent 功能。
4
+
5
+ ## 1. 目标
6
+
7
+ - 每个用户/群组使用独立的 Agent 实例
8
+ - 自动将动态 Agent 添加到 `agents.list`
9
+ - 完全向后兼容(默认关闭)
10
+
11
+ ## 2. 配置设计
12
+
13
+ ### 2.1 类型定义 (src/types/config.ts)
14
+
15
+ ```typescript
16
+ /** 动态 Agent 配置 */
17
+ export type WecomDynamicAgentsConfig = {
18
+ /** 是否启用动态 Agent */
19
+ enabled?: boolean;
20
+ /** 私聊:是否为每个用户创建独立 Agent */
21
+ dmCreateAgent?: boolean;
22
+ /** 群聊:是否启用动态 Agent */
23
+ groupEnabled?: boolean;
24
+ /** 管理员列表(绕过动态路由,使用主 Agent) */
25
+ adminUsers?: string[];
26
+ };
27
+
28
+ export type WecomConfig = {
29
+ enabled?: boolean;
30
+ bot?: WecomBotConfig;
31
+ agent?: WecomAgentConfig;
32
+ media?: WecomMediaConfig;
33
+ network?: WecomNetworkConfig;
34
+ dynamicAgents?: WecomDynamicAgentsConfig; // 新增
35
+ };
36
+ ```
37
+
38
+ ### 2.2 Schema 定义 (src/config/schema.ts)
39
+
40
+ ```typescript
41
+ const dynamicAgentsSchema = z.object({
42
+ enabled: z.boolean().optional(),
43
+ dmCreateAgent: z.boolean().optional(),
44
+ groupEnabled: z.boolean().optional(),
45
+ adminUsers: z.array(z.string()).optional(),
46
+ }).optional();
47
+
48
+ export const WecomConfigSchema = z.object({
49
+ enabled: z.boolean().optional(),
50
+ bot: botSchema,
51
+ agent: agentSchema,
52
+ media: mediaSchema,
53
+ network: networkSchema,
54
+ dynamicAgents: dynamicAgentsSchema, // 新增
55
+ });
56
+ ```
57
+
58
+ ## 3. 核心实现 (src/dynamic-agent.ts)
59
+
60
+ ```typescript
61
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
62
+
63
+ export interface DynamicAgentConfig {
64
+ enabled: boolean;
65
+ dmCreateAgent: boolean;
66
+ groupEnabled: boolean;
67
+ adminUsers: string[];
68
+ }
69
+
70
+ /**
71
+ * 读取动态 Agent 配置(带默认值)
72
+ */
73
+ export function getDynamicAgentConfig(config: OpenClawConfig): DynamicAgentConfig {
74
+ const dynamicAgents = (config as any)?.channels?.wecom?.dynamicAgents;
75
+ return {
76
+ enabled: dynamicAgents?.enabled ?? false,
77
+ dmCreateAgent: dynamicAgents?.dmCreateAgent ?? true,
78
+ groupEnabled: dynamicAgents?.groupEnabled ?? true,
79
+ adminUsers: dynamicAgents?.adminUsers ?? [],
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 生成动态 Agent ID
85
+ * 算法:wecom-{type}-{sanitizedPeerId}
86
+ * type: dm | group
87
+ */
88
+ export function generateAgentId(chatType: "dm" | "group", peerId: string): string {
89
+ const sanitized = String(peerId)
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9_-]/g, "_");
92
+ return `wecom-${chatType}-${sanitized}`;
93
+ }
94
+
95
+ /**
96
+ * 检查是否应该使用动态 Agent
97
+ */
98
+ export function shouldUseDynamicAgent(params: {
99
+ chatType: "dm" | "group";
100
+ senderId: string;
101
+ config: OpenClawConfig;
102
+ }): boolean {
103
+ const { chatType, senderId, config } = params;
104
+ const dynamicConfig = getDynamicAgentConfig(config);
105
+
106
+ if (!dynamicConfig.enabled) {
107
+ return false;
108
+ }
109
+
110
+ // 管理员绕过动态路由
111
+ const sender = String(senderId).trim().toLowerCase();
112
+ const isAdmin = dynamicConfig.adminUsers.some(
113
+ admin => admin.trim().toLowerCase() === sender
114
+ );
115
+ if (isAdmin) {
116
+ return false;
117
+ }
118
+
119
+ if (chatType === "group") {
120
+ return dynamicConfig.groupEnabled;
121
+ }
122
+ return dynamicConfig.dmCreateAgent;
123
+ }
124
+
125
+ /**
126
+ * 内存中已确保的 Agent ID(避免重复写入)
127
+ */
128
+ const ensuredDynamicAgentIds = new Set<string>();
129
+
130
+ /**
131
+ * 写入队列(避免并发冲突)
132
+ */
133
+ let ensureDynamicAgentWriteQueue: Promise<void> = Promise.resolve();
134
+
135
+ /**
136
+ * 将动态 Agent 添加到 agents.list
137
+ */
138
+ export async function ensureDynamicAgentListed(
139
+ agentId: string,
140
+ runtime: { config?: { loadConfig?: () => any; writeConfigFile?: (cfg: any) => Promise<void> } }
141
+ ): Promise<void> {
142
+ const normalizedId = String(agentId).trim().toLowerCase();
143
+ if (!normalizedId) return;
144
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
145
+
146
+ const configRuntime = runtime?.config;
147
+ if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) return;
148
+
149
+ ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
150
+ .then(async () => {
151
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
152
+
153
+ const latestConfig = configRuntime.loadConfig!();
154
+ if (!latestConfig || typeof latestConfig !== "object") return;
155
+
156
+ const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
157
+ if (changed) {
158
+ await configRuntime.writeConfigFile!(latestConfig);
159
+ console.log(`[wecom] 动态 Agent 已添加: ${normalizedId}`);
160
+ }
161
+
162
+ ensuredDynamicAgentIds.add(normalizedId);
163
+ })
164
+ .catch((err) => {
165
+ console.warn(`[wecom] 动态 Agent 添加失败: ${normalizedId}`, err);
166
+ });
167
+
168
+ await ensureDynamicAgentWriteQueue;
169
+ }
170
+
171
+ /**
172
+ * 将 Agent ID 插入 agents.list(如果不存在)
173
+ */
174
+ function upsertAgentIdOnlyEntry(cfg: any, agentId: string): boolean {
175
+ if (!cfg.agents || typeof cfg.agents !== "object") {
176
+ cfg.agents = {};
177
+ }
178
+
179
+ const currentList: Array<{ id: string }> = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
180
+ const existingIds = new Set(
181
+ currentList
182
+ .map((entry) => entry?.id?.trim().toLowerCase())
183
+ .filter(Boolean)
184
+ );
185
+
186
+ let changed = false;
187
+ const nextList = [...currentList];
188
+
189
+ // 首次创建时保留 main 作为默认
190
+ if (nextList.length === 0) {
191
+ nextList.push({ id: "main" });
192
+ existingIds.add("main");
193
+ changed = true;
194
+ }
195
+
196
+ if (!existingIds.has(agentId.toLowerCase())) {
197
+ nextList.push({ id: agentId });
198
+ changed = true;
199
+ }
200
+
201
+ if (changed) {
202
+ cfg.agents.list = nextList;
203
+ }
204
+
205
+ return changed;
206
+ }
207
+ ```
208
+
209
+ ## 4. 路由拦截点修改
210
+
211
+ ### 4.1 Bot 模式 (src/monitor.ts)
212
+
213
+ 在 `startAgentForStream` 函数中,路由解析后注入动态 Agent:
214
+
215
+ ```typescript
216
+ // 约第 923 行,路由解析后
217
+ const route = core.channel.routing.resolveAgentRoute({
218
+ cfg: config,
219
+ channel: "wecom",
220
+ accountId: account.accountId,
221
+ peer: { kind: chatType === "group" ? "group" : "dm", id: chatId },
222
+ });
223
+
224
+ // ===== 动态 Agent 注入开始 =====
225
+ import { shouldUseDynamicAgent, generateAgentId, ensureDynamicAgentListed } from "./dynamic-agent.js";
226
+
227
+ const useDynamicAgent = shouldUseDynamicAgent({
228
+ chatType: chatType === "group" ? "group" : "dm",
229
+ senderId: userid,
230
+ config,
231
+ });
232
+
233
+ if (useDynamicAgent) {
234
+ const targetAgentId = generateAgentId(
235
+ chatType === "group" ? "group" : "dm",
236
+ chatId
237
+ );
238
+
239
+ // 覆盖路由
240
+ route.agentId = targetAgentId;
241
+ route.sessionKey = `agent:${targetAgentId}:${chatType === "group" ? "group" : "dm"}:${chatId}`;
242
+
243
+ // 异步添加到 agents.list(不阻塞)
244
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
245
+ }
246
+ // ===== 动态 Agent 注入结束 =====
247
+ ```
248
+
249
+ ### 4.2 Agent 模式 (src/agent/handler.ts)
250
+
251
+ 在 `processAgentMessage` 函数中,路由解析后注入动态 Agent:
252
+
253
+ ```typescript
254
+ // 约第 438 行,路由解析后
255
+ const route = core.channel.routing.resolveAgentRoute({
256
+ cfg: config,
257
+ channel: "wecom",
258
+ accountId: agent.accountId,
259
+ peer: { kind: isGroup ? "group" : "dm", id: peerId },
260
+ });
261
+
262
+ // ===== 动态 Agent 注入开始 =====
263
+ import { shouldUseDynamicAgent, generateAgentId, ensureDynamicAgentListed } from "../dynamic-agent.js";
264
+
265
+ const useDynamicAgent = shouldUseDynamicAgent({
266
+ chatType: isGroup ? "group" : "dm",
267
+ senderId: fromUser,
268
+ config,
269
+ });
270
+
271
+ if (useDynamicAgent) {
272
+ const targetAgentId = generateAgentId(
273
+ isGroup ? "group" : "dm",
274
+ peerId
275
+ );
276
+
277
+ // 覆盖路由
278
+ route.agentId = targetAgentId;
279
+ route.sessionKey = `agent:${targetAgentId}:${isGroup ? "group" : "dm"}:${peerId}`;
280
+
281
+ // 异步添加到 agents.list
282
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
283
+ }
284
+ // ===== 动态 Agent 注入结束 =====
285
+ ```
286
+
287
+ ## 5. 配置示例
288
+
289
+ ```bash
290
+ # 启用动态 Agent
291
+ openclaw config set channels.wecom.dynamicAgents.enabled true
292
+
293
+ # 私聊为每个用户创建独立 Agent(默认 true)
294
+ openclaw config set channels.wecom.dynamicAgents.dmCreateAgent true
295
+
296
+ # 群聊启用动态 Agent(默认 true)
297
+ openclaw config set channels.wecom.dynamicAgents.groupEnabled true
298
+
299
+ # 设置管理员(管理员使用主 Agent)
300
+ openclaw config set channels.wecom.dynamicAgents.adminUsers '["admin1","admin2"]'
301
+ ```
302
+
303
+ 生成的配置结构:
304
+
305
+ ```json
306
+ {
307
+ "channels": {
308
+ "wecom": {
309
+ "enabled": true,
310
+ "bot": { ... },
311
+ "agent": { ... },
312
+ "dynamicAgents": {
313
+ "enabled": true,
314
+ "dmCreateAgent": true,
315
+ "groupEnabled": true,
316
+ "adminUsers": ["admin1"]
317
+ }
318
+ }
319
+ },
320
+ "agents": {
321
+ "list": [
322
+ { "id": "main" },
323
+ { "id": "wecom-dm-zhangsan" },
324
+ { "id": "wecom-group-wr123456" }
325
+ ]
326
+ }
327
+ }
328
+ ```
329
+
330
+ ## 6. Agent ID 生成规则
331
+
332
+ | 场景 | Peer ID | 生成的 Agent ID |
333
+ |------|---------|-----------------|
334
+ | 私聊 | zhangsan | `wecom-dm-zhangsan` |
335
+ | 群聊 | wr123456 | `wecom-group-wr123456` |
336
+ | 特殊字符 | zhang.san | `wecom-dm-zhang_san` |
337
+ | 大写 | ZhangSan | `wecom-dm-zhangsan` |
338
+
339
+ ## 7. 实现步骤
340
+
341
+ 1. **新增文件**
342
+ - `src/dynamic-agent.ts` - 核心逻辑
343
+
344
+ 2. **修改文件**
345
+ - `src/types/config.ts` - 添加 `WecomDynamicAgentsConfig` 类型
346
+ - `src/config/schema.ts` - 添加 `dynamicAgentsSchema`
347
+ - `src/monitor.ts` - Bot 模式路由拦截
348
+ - `src/agent/handler.ts` - Agent 模式路由拦截
349
+
350
+ 3. **测试验证**
351
+ - 未启用时行为不变
352
+ - 启用后每个用户有独立会话
353
+ - 管理员正确使用主 Agent
354
+ - 自动写入 agents.list
355
+
356
+ ## 8. 向后兼容性
357
+
358
+ - `dynamicAgents.enabled` 默认为 `false`,不启用功能
359
+ - 未配置时保持原有行为(所有用户使用同一 Agent)
360
+ - 管理员可继续使用 `main` Agent