@yanhaidao/wecom 2.3.260 → 2.4.120

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.
Files changed (58) hide show
  1. package/MENU_EVENT_CONF.md +500 -0
  2. package/MENU_EVENT_PLAN.md +440 -0
  3. package/README.md +90 -8
  4. package/UPSTREAM_CONFIG.md +170 -0
  5. package/UPSTREAM_PLAN.md +175 -0
  6. package/changelog/v2.3.27.md +33 -0
  7. package/changelog/v2.4.12.md +37 -0
  8. package/index.test.ts +5 -1
  9. package/package.json +17 -17
  10. package/scripts/wecom/README.md +123 -0
  11. package/scripts/wecom/menu-click-help.js +59 -0
  12. package/scripts/wecom/menu-click-help.py +55 -0
  13. package/src/agent/event-router.test.ts +421 -0
  14. package/src/agent/event-router.ts +272 -0
  15. package/src/agent/handler.event-filter.test.ts +65 -1
  16. package/src/agent/handler.ts +375 -21
  17. package/src/agent/script-runner.ts +186 -0
  18. package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
  19. package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
  20. package/src/agent/test-fixtures/reply-event-script.py +17 -0
  21. package/src/app/account-runtime.ts +1 -1
  22. package/src/app/index.ts +6 -3
  23. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  24. package/src/capability/bot/sandbox-media.test.ts +221 -0
  25. package/src/capability/bot/sandbox-media.ts +176 -0
  26. package/src/capability/bot/stream-orchestrator.ts +19 -0
  27. package/src/capability/mcp/tool.ts +7 -3
  28. package/src/channel.config.test.ts +33 -0
  29. package/src/channel.meta.test.ts +14 -0
  30. package/src/channel.ts +33 -60
  31. package/src/config/accounts.ts +16 -0
  32. package/src/config/schema.ts +58 -0
  33. package/src/context-store.ts +41 -8
  34. package/src/onboarding.test.ts +42 -24
  35. package/src/onboarding.ts +598 -553
  36. package/src/outbound.test.ts +211 -2
  37. package/src/outbound.ts +340 -81
  38. package/src/runtime/session-manager.test.ts +39 -0
  39. package/src/runtime/session-manager.ts +17 -0
  40. package/src/runtime/source-registry.ts +5 -0
  41. package/src/shared/media-asset.ts +78 -0
  42. package/src/shared/media-service.test.ts +111 -0
  43. package/src/shared/media-service.ts +42 -14
  44. package/src/target.ts +40 -0
  45. package/src/transport/agent-api/client.ts +233 -0
  46. package/src/transport/agent-api/core.ts +101 -5
  47. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  48. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  49. package/src/transport/agent-api/upstream-reply.ts +43 -0
  50. package/src/transport/bot-ws/media.test.ts +8 -8
  51. package/src/transport/bot-ws/media.ts +51 -2
  52. package/src/transport/bot-ws/sdk-adapter.ts +6 -6
  53. package/src/types/account.ts +2 -0
  54. package/src/types/config.ts +74 -0
  55. package/src/types/message.ts +2 -0
  56. package/src/upstream/index.ts +150 -0
  57. package/src/upstream.test.ts +84 -0
  58. package/vitest.config.ts +15 -4
@@ -0,0 +1,272 @@
1
+ import { extractAgentId, extractMsgId } from "../shared/xml-parser.js";
2
+ import type {
3
+ ResolvedAgentAccount,
4
+ WecomAgentEventRouteConfig,
5
+ WecomAgentInboundMessage,
6
+ } from "../types/index.js";
7
+ import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
8
+ import { runAgentEventScript, type AgentEventScriptEnvelope } from "./script-runner.js";
9
+
10
+ export type AgentInboundEventRouteResult = {
11
+ handled: boolean;
12
+ chainToAgent: boolean;
13
+ replyText?: string;
14
+ matchedRouteId?: string;
15
+ reason: string;
16
+ error?: string;
17
+ };
18
+
19
+ // 统一比较口径:事件类型按小写处理
20
+ function normalizeLower(value: unknown): string {
21
+ return String(value ?? "").trim().toLowerCase();
22
+ }
23
+
24
+ // 事件键值按原大小写保留,仅做首尾清理
25
+ function normalizeText(value: unknown): string {
26
+ return String(value ?? "").trim();
27
+ }
28
+
29
+ function testPatternSafe(params: {
30
+ pattern: string;
31
+ value: string;
32
+ onInvalidPattern?: (errorMessage: string) => void;
33
+ }): boolean {
34
+ try {
35
+ return new RegExp(params.pattern).test(params.value);
36
+ } catch (err) {
37
+ const message = err instanceof Error ? err.message : String(err);
38
+ params.onInvalidPattern?.(message);
39
+ // 配置了非法正则时按“不匹配”处理,避免中断 webhook 主流程
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function matchesRoute(params: {
45
+ route: WecomAgentEventRouteConfig;
46
+ eventType: string;
47
+ changeType: string;
48
+ eventKey: string;
49
+ onInvalidPattern?: (errorMessage: string) => void;
50
+ }): boolean {
51
+ // 三层匹配:eventType -> changeType/eventKey(含 prefix/regex)
52
+ const when = params.route.when ?? {};
53
+
54
+ if (when.eventType && normalizeLower(when.eventType) !== params.eventType) return false;
55
+ if (when.changeType && normalizeLower(when.changeType) !== params.changeType) return false;
56
+ if (when.eventKey && normalizeText(when.eventKey) !== params.eventKey) return false;
57
+ if (when.eventKeyPrefix && !params.eventKey.startsWith(normalizeText(when.eventKeyPrefix))) return false;
58
+ if (when.eventKeyPattern && !testPatternSafe({
59
+ pattern: when.eventKeyPattern,
60
+ value: params.eventKey,
61
+ onInvalidPattern: params.onInvalidPattern,
62
+ })) return false;
63
+
64
+ return true;
65
+ }
66
+
67
+ function buildScriptEnvelope(params: {
68
+ accountId: string;
69
+ msgType: string;
70
+ eventType: string;
71
+ eventKey: string;
72
+ changeType: string;
73
+ fromUser: string;
74
+ toUser?: string;
75
+ chatId?: string;
76
+ msg: WecomAgentInboundMessage;
77
+ matchedRuleId: string;
78
+ handlerType: "node_script" | "python_script";
79
+ }): AgentEventScriptEnvelope {
80
+ // 透传给外部脚本:标准化字段 + 原始 XML 解析对象 raw
81
+ const rawAgentId = extractAgentId(params.msg);
82
+ const numericAgentId = typeof rawAgentId === "number"
83
+ ? rawAgentId
84
+ : Number.isFinite(Number(rawAgentId)) ? Number(rawAgentId) : null;
85
+
86
+ return {
87
+ version: "1.0",
88
+ channel: "wecom",
89
+ accountId: params.accountId,
90
+ receivedAt: Date.now(),
91
+ message: {
92
+ msgType: params.msgType,
93
+ eventType: params.eventType,
94
+ eventKey: params.eventKey || null,
95
+ changeType: params.changeType || null,
96
+ fromUser: params.fromUser,
97
+ toUser: params.toUser ?? null,
98
+ chatId: params.chatId ?? null,
99
+ agentId: numericAgentId,
100
+ createTime: typeof params.msg.CreateTime === "number" ? params.msg.CreateTime : Number.isFinite(Number(params.msg.CreateTime)) ? Number(params.msg.CreateTime) : null,
101
+ msgId: extractMsgId(params.msg) ?? null,
102
+ raw: { ...(params.msg as Record<string, unknown>) },
103
+ },
104
+ route: {
105
+ matchedRuleId: params.matchedRuleId,
106
+ handlerType: params.handlerType,
107
+ },
108
+ };
109
+ }
110
+
111
+ export async function routeAgentInboundEvent(params: {
112
+ agent: ResolvedAgentAccount;
113
+ msgType: string;
114
+ eventType: string;
115
+ fromUser: string;
116
+ chatId?: string;
117
+ msg: WecomAgentInboundMessage;
118
+ log?: (msg: string) => void;
119
+ auditSink?: (event: WecomRuntimeAuditEvent) => void;
120
+ }): Promise<AgentInboundEventRouteResult> {
121
+ if (normalizeLower(params.msgType) !== "event") {
122
+ return {
123
+ handled: false,
124
+ chainToAgent: false,
125
+ reason: "not_event",
126
+ };
127
+ }
128
+
129
+ const routing = params.agent.config.eventRouting;
130
+ const routes = routing?.routes ?? [];
131
+ const changeType = normalizeLower(params.msg.ChangeType);
132
+ const eventKey = normalizeText(params.msg.EventKey);
133
+ // 路由采用“首个命中即执行”策略
134
+ const matchedRoute = routes.find((route) => matchesRoute({
135
+ route,
136
+ eventType: params.eventType,
137
+ changeType,
138
+ eventKey,
139
+ onInvalidPattern: (errorMessage) => {
140
+ const routeId = route.id?.trim() || "anonymous";
141
+ params.log?.(
142
+ `[wecom-agent] invalid eventKeyPattern in route routeId=${routeId} pattern=${route.when?.eventKeyPattern ?? ""} error=${errorMessage}`,
143
+ );
144
+ params.auditSink?.({
145
+ transport: "agent-callback",
146
+ category: "runtime-error",
147
+ messageId: extractMsgId(params.msg) ?? undefined,
148
+ summary: `invalid route eventKeyPattern routeId=${routeId}`,
149
+ raw: {
150
+ transport: "agent-callback",
151
+ envelopeType: "xml",
152
+ body: params.msg,
153
+ },
154
+ error: `routeId=${routeId} pattern=${route.when?.eventKeyPattern ?? ""} error=${errorMessage}`,
155
+ });
156
+ },
157
+ }));
158
+
159
+ if (!matchedRoute) {
160
+ // 未命中时由 unmatchedAction 决定:忽略 or 继续默认 AI 流程
161
+ if (routing?.unmatchedAction === "forwardToAgent") {
162
+ return {
163
+ handled: false,
164
+ chainToAgent: true,
165
+ reason: "unmatched_event_forwardToAgent",
166
+ };
167
+ }
168
+ return {
169
+ handled: true,
170
+ chainToAgent: false,
171
+ reason: "unmatched_event_ignored",
172
+ };
173
+ }
174
+
175
+ const matchedRouteId = matchedRoute.id?.trim() || `${params.eventType || "event"}:${eventKey || "default"}`;
176
+ params.log?.(`[wecom-agent] event route matched routeId=${matchedRouteId} event=${params.eventType} eventKey=${eventKey || "N/A"}`);
177
+
178
+ if (matchedRoute.handler.type === "builtin") {
179
+ // 内置 handler:当前仅实现 echo,用于联调和最小可用场景
180
+ if ((matchedRoute.handler.name ?? "echo") === "echo") {
181
+ const replyText = [`event=${params.eventType}`,
182
+ eventKey ? `eventKey=${eventKey}` : "",
183
+ changeType ? `changeType=${changeType}` : "",
184
+ ].filter(Boolean).join(" ");
185
+ return {
186
+ handled: true,
187
+ chainToAgent: matchedRoute.handler.chainToAgent === true,
188
+ replyText,
189
+ matchedRouteId,
190
+ reason: "builtin_echo",
191
+ };
192
+ }
193
+ }
194
+
195
+ if (matchedRoute.handler.type !== "node_script" && matchedRoute.handler.type !== "python_script") {
196
+ return {
197
+ handled: true,
198
+ chainToAgent: false,
199
+ matchedRouteId,
200
+ reason: "unsupported_handler",
201
+ };
202
+ }
203
+
204
+ try {
205
+ // 外部脚本 handler:通过 stdin 输入 envelope,stdout 返回 JSON 协议
206
+ const { response: scriptResponse, meta } = await runAgentEventScript({
207
+ runtime: params.agent.config.scriptRuntime,
208
+ handler: matchedRoute.handler,
209
+ envelope: buildScriptEnvelope({
210
+ accountId: params.agent.accountId,
211
+ msgType: params.msgType,
212
+ eventType: params.eventType,
213
+ eventKey,
214
+ changeType,
215
+ fromUser: params.fromUser,
216
+ toUser: typeof params.msg.ToUserName === "string" ? params.msg.ToUserName : undefined,
217
+ chatId: params.chatId,
218
+ msg: params.msg,
219
+ matchedRuleId: matchedRouteId,
220
+ handlerType: matchedRoute.handler.type,
221
+ }),
222
+ });
223
+
224
+ params.auditSink?.({
225
+ transport: "agent-callback",
226
+ category: "inbound",
227
+ messageId: extractMsgId(params.msg) ?? undefined,
228
+ summary:
229
+ `event route script ok routeId=${matchedRouteId} handler=${matchedRoute.handler.type} ` +
230
+ `event=${params.eventType} durationMs=${meta.durationMs} exitCode=${meta.exitCode ?? "null"}`,
231
+ raw: {
232
+ transport: "agent-callback",
233
+ envelopeType: "xml",
234
+ body: params.msg,
235
+ },
236
+ });
237
+
238
+ return {
239
+ handled: true,
240
+ chainToAgent:
241
+ scriptResponse.chainToAgent === true || matchedRoute.handler.chainToAgent === true,
242
+ replyText: scriptResponse.action === "reply_text" ? scriptResponse.reply?.text : undefined,
243
+ matchedRouteId,
244
+ reason: `script_${matchedRoute.handler.type}`,
245
+ };
246
+ } catch (err) {
247
+ // 脚本失败时不抛出到上层,转为“已处理 + 审计错误”,避免中断 webhook 主流程
248
+ const message = err instanceof Error ? err.message : String(err);
249
+ params.log?.(
250
+ `[wecom-agent] event route script failed routeId=${matchedRouteId} handler=${matchedRoute.handler.type} error=${message}`,
251
+ );
252
+ params.auditSink?.({
253
+ transport: "agent-callback",
254
+ category: "runtime-error",
255
+ messageId: extractMsgId(params.msg) ?? undefined,
256
+ summary: `event route script failed routeId=${matchedRouteId} handler=${matchedRoute.handler.type} event=${params.eventType}`,
257
+ raw: {
258
+ transport: "agent-callback",
259
+ envelopeType: "xml",
260
+ body: params.msg,
261
+ },
262
+ error: message,
263
+ });
264
+ return {
265
+ handled: true,
266
+ chainToAgent: false,
267
+ matchedRouteId,
268
+ reason: `script_${matchedRoute.handler.type}_error`,
269
+ error: message,
270
+ };
271
+ }
272
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { shouldProcessAgentInboundMessage } from "./handler.js";
3
+ import { shouldProcessAgentInboundMessage, shouldSuppressAgentReplyText } from "./handler.js";
4
4
 
5
5
  describe("shouldProcessAgentInboundMessage", () => {
6
6
  it("allows enter_agent/subscribe through the filter (handled earlier by static welcome)", () => {
@@ -31,6 +31,41 @@ describe("shouldProcessAgentInboundMessage", () => {
31
31
  expect(unknown.reason).toBe("event:some_random_event");
32
32
  });
33
33
 
34
+ it("blocks event processing when eventEnabled is false", () => {
35
+ const disabled = shouldProcessAgentInboundMessage({
36
+ msgType: "event",
37
+ eventType: "click",
38
+ fromUser: "zhangsan",
39
+ eventEnabled: false,
40
+ });
41
+ expect(disabled.shouldProcess).toBe(false);
42
+ expect(disabled.reason).toBe("event_disabled");
43
+ });
44
+
45
+ it("allows configured custom event types", () => {
46
+ const custom = shouldProcessAgentInboundMessage({
47
+ msgType: "event",
48
+ eventType: "click",
49
+ fromUser: "zhangsan",
50
+ eventEnabled: true,
51
+ allowedEventTypes: ["click"],
52
+ });
53
+ expect(custom.shouldProcess).toBe(true);
54
+ expect(custom.reason).toBe("allowed_event:click");
55
+ });
56
+
57
+ it("normalizes configured event type values before matching", () => {
58
+ const custom = shouldProcessAgentInboundMessage({
59
+ msgType: "event",
60
+ eventType: "view_miniprogram",
61
+ fromUser: "zhangsan",
62
+ eventEnabled: true,
63
+ allowedEventTypes: [" VIEW_MINIPROGRAM "],
64
+ });
65
+ expect(custom.shouldProcess).toBe(true);
66
+ expect(custom.reason).toBe("allowed_event:view_miniprogram");
67
+ });
68
+
34
69
  it("skips system sender callbacks", () => {
35
70
  const systemSender = shouldProcessAgentInboundMessage({
36
71
  msgType: "text",
@@ -69,3 +104,32 @@ describe("shouldProcessAgentInboundMessage", () => {
69
104
  expect(normalMessage.reason).toBe("user_message");
70
105
  });
71
106
  });
107
+
108
+ describe("shouldSuppressAgentReplyText", () => {
109
+ it("keeps plain text replies when no media reply has been seen", () => {
110
+ expect(
111
+ shouldSuppressAgentReplyText({
112
+ text: "这里是正常文本",
113
+ mediaReplySeen: false,
114
+ }),
115
+ ).toBe(false);
116
+ });
117
+
118
+ it("suppresses companion text once the reply flow includes media", () => {
119
+ expect(
120
+ shouldSuppressAgentReplyText({
121
+ text: "文件已发送,请查收",
122
+ mediaReplySeen: true,
123
+ }),
124
+ ).toBe(true);
125
+ });
126
+
127
+ it("does not suppress empty text even after media replies", () => {
128
+ expect(
129
+ shouldSuppressAgentReplyText({
130
+ text: " ",
131
+ mediaReplySeen: true,
132
+ }),
133
+ ).toBe(false);
134
+ });
135
+ });