@yanhaidao/wecom 2.3.270 → 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.
- package/MENU_EVENT_CONF.md +500 -0
- package/MENU_EVENT_PLAN.md +440 -0
- package/README.md +80 -3
- package/UPSTREAM_CONFIG.md +170 -0
- package/UPSTREAM_PLAN.md +175 -0
- package/changelog/v2.4.12.md +37 -0
- package/package.json +1 -1
- package/scripts/wecom/README.md +123 -0
- package/scripts/wecom/menu-click-help.js +59 -0
- package/scripts/wecom/menu-click-help.py +55 -0
- package/src/agent/event-router.test.ts +421 -0
- package/src/agent/event-router.ts +272 -0
- package/src/agent/handler.event-filter.test.ts +65 -1
- package/src/agent/handler.ts +375 -21
- package/src/agent/script-runner.ts +186 -0
- package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
- package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
- package/src/agent/test-fixtures/reply-event-script.py +17 -0
- package/src/app/account-runtime.ts +1 -1
- package/src/capability/agent/upstream-delivery-service.ts +96 -0
- package/src/capability/bot/sandbox-media.test.ts +221 -0
- package/src/capability/bot/sandbox-media.ts +176 -0
- package/src/capability/bot/stream-orchestrator.ts +19 -0
- package/src/channel.config.test.ts +33 -0
- package/src/channel.meta.test.ts +10 -0
- package/src/channel.ts +4 -1
- package/src/config/accounts.ts +16 -0
- package/src/config/schema.ts +58 -0
- package/src/context-store.ts +41 -8
- package/src/outbound.test.ts +211 -2
- package/src/outbound.ts +323 -70
- package/src/runtime/session-manager.test.ts +39 -0
- package/src/runtime/session-manager.ts +17 -0
- package/src/runtime/source-registry.ts +5 -0
- package/src/shared/media-asset.ts +78 -0
- package/src/shared/media-service.test.ts +111 -0
- package/src/shared/media-service.ts +42 -14
- package/src/target.ts +40 -0
- package/src/transport/agent-api/client.ts +233 -0
- package/src/transport/agent-api/core.ts +101 -5
- package/src/transport/agent-api/upstream-delivery.ts +45 -0
- package/src/transport/agent-api/upstream-media-upload.ts +70 -0
- package/src/transport/agent-api/upstream-reply.ts +43 -0
- package/src/types/account.ts +2 -0
- package/src/types/config.ts +74 -0
- package/src/types/message.ts +2 -0
- package/src/upstream/index.ts +150 -0
- package/src/upstream.test.ts +84 -0
- package/vitest.config.ts +15 -4
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { routeAgentInboundEvent } from "./event-router.js";
|
|
5
|
+
import type { ResolvedAgentAccount, WecomAgentInboundMessage } from "../types/index.js";
|
|
6
|
+
import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
|
|
7
|
+
|
|
8
|
+
function createAgent(overrides?: Partial<ResolvedAgentAccount>): ResolvedAgentAccount {
|
|
9
|
+
return {
|
|
10
|
+
accountId: "default",
|
|
11
|
+
configured: true,
|
|
12
|
+
callbackConfigured: true,
|
|
13
|
+
apiConfigured: true,
|
|
14
|
+
corpId: "corp-1",
|
|
15
|
+
corpSecret: "secret",
|
|
16
|
+
agentId: 1001,
|
|
17
|
+
token: "token",
|
|
18
|
+
encodingAESKey: "aes",
|
|
19
|
+
eventEnabled: true,
|
|
20
|
+
allowedEventTypes: ["click", "change_contact"],
|
|
21
|
+
config: {
|
|
22
|
+
corpId: "corp-1",
|
|
23
|
+
corpSecret: "secret",
|
|
24
|
+
agentId: 1001,
|
|
25
|
+
token: "token",
|
|
26
|
+
encodingAESKey: "aes",
|
|
27
|
+
},
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("routeAgentInboundEvent", () => {
|
|
33
|
+
it("ignores unmatched events when unmatchedAction is ignore", async () => {
|
|
34
|
+
const agent = createAgent({
|
|
35
|
+
config: {
|
|
36
|
+
corpId: "corp-1",
|
|
37
|
+
corpSecret: "secret",
|
|
38
|
+
agentId: 1001,
|
|
39
|
+
token: "token",
|
|
40
|
+
encodingAESKey: "aes",
|
|
41
|
+
eventRouting: {
|
|
42
|
+
unmatchedAction: "ignore",
|
|
43
|
+
routes: [],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await routeAgentInboundEvent({
|
|
49
|
+
agent,
|
|
50
|
+
msgType: "event",
|
|
51
|
+
eventType: "click",
|
|
52
|
+
fromUser: "zhangsan",
|
|
53
|
+
msg: { MsgType: "event", Event: "click", EventKey: "MENU_X" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.handled).toBe(true);
|
|
57
|
+
expect(result.chainToAgent).toBe(false);
|
|
58
|
+
expect(result.reason).toBe("unmatched_event_ignored");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("proxies unmatched events to agent when unmatchedAction is forwardToAgent", async () => {
|
|
62
|
+
const agent = createAgent({
|
|
63
|
+
config: {
|
|
64
|
+
corpId: "corp-1",
|
|
65
|
+
corpSecret: "secret",
|
|
66
|
+
agentId: 1001,
|
|
67
|
+
token: "token",
|
|
68
|
+
encodingAESKey: "aes",
|
|
69
|
+
eventRouting: {
|
|
70
|
+
unmatchedAction: "forwardToAgent",
|
|
71
|
+
routes: [],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await routeAgentInboundEvent({
|
|
77
|
+
agent,
|
|
78
|
+
msgType: "event",
|
|
79
|
+
eventType: "click",
|
|
80
|
+
fromUser: "zhangsan",
|
|
81
|
+
msg: { MsgType: "event", Event: "click", EventKey: "MENU_X" },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.handled).toBe(false);
|
|
85
|
+
expect(result.chainToAgent).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("matches builtin echo routes using eventKey", async () => {
|
|
89
|
+
const agent = createAgent({
|
|
90
|
+
config: {
|
|
91
|
+
corpId: "corp-1",
|
|
92
|
+
corpSecret: "secret",
|
|
93
|
+
agentId: 1001,
|
|
94
|
+
token: "token",
|
|
95
|
+
encodingAESKey: "aes",
|
|
96
|
+
eventRouting: {
|
|
97
|
+
routes: [
|
|
98
|
+
{
|
|
99
|
+
id: "menu-help",
|
|
100
|
+
when: { eventType: "click", eventKey: "MENU_HELP" },
|
|
101
|
+
handler: { type: "builtin", name: "echo" },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await routeAgentInboundEvent({
|
|
109
|
+
agent,
|
|
110
|
+
msgType: "event",
|
|
111
|
+
eventType: "click",
|
|
112
|
+
fromUser: "zhangsan",
|
|
113
|
+
msg: { MsgType: "event", Event: "click", EventKey: "MENU_HELP" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.handled).toBe(true);
|
|
117
|
+
expect(result.replyText).toContain("event=click");
|
|
118
|
+
expect(result.replyText).toContain("eventKey=MENU_HELP");
|
|
119
|
+
expect(result.matchedRouteId).toBe("menu-help");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("matches change_contact routes using changeType", async () => {
|
|
123
|
+
const agent = createAgent({
|
|
124
|
+
config: {
|
|
125
|
+
corpId: "corp-1",
|
|
126
|
+
corpSecret: "secret",
|
|
127
|
+
agentId: 1001,
|
|
128
|
+
token: "token",
|
|
129
|
+
encodingAESKey: "aes",
|
|
130
|
+
eventRouting: {
|
|
131
|
+
routes: [
|
|
132
|
+
{
|
|
133
|
+
id: "contact-create-user",
|
|
134
|
+
when: { eventType: "change_contact", changeType: "create_user" },
|
|
135
|
+
handler: { type: "builtin", name: "echo" },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const msg: WecomAgentInboundMessage = {
|
|
143
|
+
MsgType: "event",
|
|
144
|
+
Event: "change_contact",
|
|
145
|
+
ChangeType: "create_user",
|
|
146
|
+
};
|
|
147
|
+
const result = await routeAgentInboundEvent({
|
|
148
|
+
agent,
|
|
149
|
+
msgType: "event",
|
|
150
|
+
eventType: "change_contact",
|
|
151
|
+
fromUser: "sys",
|
|
152
|
+
msg,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result.handled).toBe(true);
|
|
156
|
+
expect(result.replyText).toContain("changeType=create_user");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("passes full event params to node scripts", async () => {
|
|
160
|
+
const fixturePath = path.resolve("src/agent/test-fixtures/reply-event-script.mjs");
|
|
161
|
+
const agent = createAgent({
|
|
162
|
+
config: {
|
|
163
|
+
corpId: "corp-1",
|
|
164
|
+
corpSecret: "secret",
|
|
165
|
+
agentId: 1001,
|
|
166
|
+
token: "token",
|
|
167
|
+
encodingAESKey: "aes",
|
|
168
|
+
eventRouting: {
|
|
169
|
+
routes: [
|
|
170
|
+
{
|
|
171
|
+
id: "script-click",
|
|
172
|
+
when: { eventType: "click", eventKeyPrefix: "MENU_" },
|
|
173
|
+
handler: { type: "node_script", entry: fixturePath },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
scriptRuntime: {
|
|
178
|
+
enabled: true,
|
|
179
|
+
allowPaths: [path.resolve("src/agent/test-fixtures")],
|
|
180
|
+
nodeCommand: process.execPath,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = await routeAgentInboundEvent({
|
|
186
|
+
agent,
|
|
187
|
+
msgType: "event",
|
|
188
|
+
eventType: "click",
|
|
189
|
+
fromUser: "zhangsan",
|
|
190
|
+
msg: {
|
|
191
|
+
ToUserName: "corp-1",
|
|
192
|
+
FromUserName: "zhangsan",
|
|
193
|
+
MsgType: "event",
|
|
194
|
+
Event: "click",
|
|
195
|
+
EventKey: "MENU_HELP",
|
|
196
|
+
AgentID: 1001,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.handled).toBe(true);
|
|
201
|
+
expect(result.replyText).toBe("script:click:MENU_HELP:");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("supports scripts that explicitly continue the default pipeline", async () => {
|
|
205
|
+
const fixturePath = path.resolve("src/agent/test-fixtures/reply-event-script.mjs");
|
|
206
|
+
const agent = createAgent({
|
|
207
|
+
config: {
|
|
208
|
+
corpId: "corp-1",
|
|
209
|
+
corpSecret: "secret",
|
|
210
|
+
agentId: 1001,
|
|
211
|
+
token: "token",
|
|
212
|
+
encodingAESKey: "aes",
|
|
213
|
+
eventRouting: {
|
|
214
|
+
routes: [
|
|
215
|
+
{
|
|
216
|
+
id: "script-click-pass",
|
|
217
|
+
when: { eventType: "click", eventKey: "PASS_TO_DEFAULT" },
|
|
218
|
+
handler: { type: "node_script", entry: fixturePath },
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
scriptRuntime: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
allowPaths: [path.resolve("src/agent/test-fixtures")],
|
|
225
|
+
nodeCommand: process.execPath,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const result = await routeAgentInboundEvent({
|
|
231
|
+
agent,
|
|
232
|
+
msgType: "event",
|
|
233
|
+
eventType: "click",
|
|
234
|
+
fromUser: "zhangsan",
|
|
235
|
+
msg: {
|
|
236
|
+
MsgType: "event",
|
|
237
|
+
Event: "click",
|
|
238
|
+
EventKey: "PASS_TO_DEFAULT",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(result.handled).toBe(true);
|
|
243
|
+
expect(result.chainToAgent).toBe(true);
|
|
244
|
+
expect(result.replyText).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("passes full event params to python scripts", async () => {
|
|
248
|
+
const fixturePath = path.resolve("src/agent/test-fixtures/reply-event-script.py");
|
|
249
|
+
const agent = createAgent({
|
|
250
|
+
config: {
|
|
251
|
+
corpId: "corp-1",
|
|
252
|
+
corpSecret: "secret",
|
|
253
|
+
agentId: 1001,
|
|
254
|
+
token: "token",
|
|
255
|
+
encodingAESKey: "aes",
|
|
256
|
+
eventRouting: {
|
|
257
|
+
routes: [
|
|
258
|
+
{
|
|
259
|
+
id: "script-python-click",
|
|
260
|
+
when: { eventType: "click", eventKey: "MENU_PY" },
|
|
261
|
+
handler: { type: "python_script", entry: fixturePath },
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
scriptRuntime: {
|
|
266
|
+
enabled: true,
|
|
267
|
+
allowPaths: [path.resolve("src/agent/test-fixtures")],
|
|
268
|
+
pythonCommand: "python3",
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const result = await routeAgentInboundEvent({
|
|
274
|
+
agent,
|
|
275
|
+
msgType: "event",
|
|
276
|
+
eventType: "click",
|
|
277
|
+
fromUser: "zhangsan",
|
|
278
|
+
msg: {
|
|
279
|
+
MsgType: "event",
|
|
280
|
+
Event: "click",
|
|
281
|
+
EventKey: "MENU_PY",
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(result.handled).toBe(true);
|
|
286
|
+
expect(result.replyText).toBe("python:click:MENU_PY:");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("records audit events for successful script execution", async () => {
|
|
290
|
+
const fixturePath = path.resolve("src/agent/test-fixtures/reply-event-script.mjs");
|
|
291
|
+
const auditEvents: WecomRuntimeAuditEvent[] = [];
|
|
292
|
+
const agent = createAgent({
|
|
293
|
+
config: {
|
|
294
|
+
corpId: "corp-1",
|
|
295
|
+
corpSecret: "secret",
|
|
296
|
+
agentId: 1001,
|
|
297
|
+
token: "token",
|
|
298
|
+
encodingAESKey: "aes",
|
|
299
|
+
eventRouting: {
|
|
300
|
+
routes: [
|
|
301
|
+
{
|
|
302
|
+
id: "script-audit-success",
|
|
303
|
+
when: { eventType: "click", eventKey: "MENU_AUDIT" },
|
|
304
|
+
handler: { type: "node_script", entry: fixturePath },
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
scriptRuntime: {
|
|
309
|
+
enabled: true,
|
|
310
|
+
allowPaths: [path.resolve("src/agent/test-fixtures")],
|
|
311
|
+
nodeCommand: process.execPath,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await routeAgentInboundEvent({
|
|
317
|
+
agent,
|
|
318
|
+
msgType: "event",
|
|
319
|
+
eventType: "click",
|
|
320
|
+
fromUser: "zhangsan",
|
|
321
|
+
msg: {
|
|
322
|
+
MsgType: "event",
|
|
323
|
+
Event: "click",
|
|
324
|
+
EventKey: "MENU_AUDIT",
|
|
325
|
+
},
|
|
326
|
+
auditSink: (event) => auditEvents.push(event),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(auditEvents).toHaveLength(1);
|
|
330
|
+
expect(auditEvents[0]?.category).toBe("inbound");
|
|
331
|
+
expect(auditEvents[0]?.summary).toContain("event route script ok");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("captures invalid script output as a routed error and audits it", async () => {
|
|
335
|
+
const fixturePath = path.resolve("src/agent/test-fixtures/invalid-json-script.mjs");
|
|
336
|
+
const auditEvents: WecomRuntimeAuditEvent[] = [];
|
|
337
|
+
const agent = createAgent({
|
|
338
|
+
config: {
|
|
339
|
+
corpId: "corp-1",
|
|
340
|
+
corpSecret: "secret",
|
|
341
|
+
agentId: 1001,
|
|
342
|
+
token: "token",
|
|
343
|
+
encodingAESKey: "aes",
|
|
344
|
+
eventRouting: {
|
|
345
|
+
routes: [
|
|
346
|
+
{
|
|
347
|
+
id: "script-invalid-json",
|
|
348
|
+
when: { eventType: "click", eventKey: "MENU_BAD_JSON" },
|
|
349
|
+
handler: { type: "node_script", entry: fixturePath },
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
scriptRuntime: {
|
|
354
|
+
enabled: true,
|
|
355
|
+
allowPaths: [path.resolve("src/agent/test-fixtures")],
|
|
356
|
+
nodeCommand: process.execPath,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const result = await routeAgentInboundEvent({
|
|
362
|
+
agent,
|
|
363
|
+
msgType: "event",
|
|
364
|
+
eventType: "click",
|
|
365
|
+
fromUser: "zhangsan",
|
|
366
|
+
msg: {
|
|
367
|
+
MsgType: "event",
|
|
368
|
+
Event: "click",
|
|
369
|
+
EventKey: "MENU_BAD_JSON",
|
|
370
|
+
},
|
|
371
|
+
auditSink: (event) => auditEvents.push(event),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(result.handled).toBe(true);
|
|
375
|
+
expect(result.chainToAgent).toBe(false);
|
|
376
|
+
expect(result.reason).toBe("script_node_script_error");
|
|
377
|
+
expect(result.error).toContain("not valid JSON");
|
|
378
|
+
expect(auditEvents).toHaveLength(1);
|
|
379
|
+
expect(auditEvents[0]?.category).toBe("runtime-error");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("treats invalid eventKeyPattern as non-match instead of throwing", async () => {
|
|
383
|
+
const auditEvents: WecomRuntimeAuditEvent[] = [];
|
|
384
|
+
const agent = createAgent({
|
|
385
|
+
config: {
|
|
386
|
+
corpId: "corp-1",
|
|
387
|
+
corpSecret: "secret",
|
|
388
|
+
agentId: 1001,
|
|
389
|
+
token: "token",
|
|
390
|
+
encodingAESKey: "aes",
|
|
391
|
+
eventRouting: {
|
|
392
|
+
unmatchedAction: "ignore",
|
|
393
|
+
routes: [
|
|
394
|
+
{
|
|
395
|
+
id: "invalid-pattern",
|
|
396
|
+
when: { eventType: "click", eventKeyPattern: "(*" },
|
|
397
|
+
handler: { type: "builtin", name: "echo" },
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const result = await routeAgentInboundEvent({
|
|
405
|
+
agent,
|
|
406
|
+
msgType: "event",
|
|
407
|
+
eventType: "click",
|
|
408
|
+
fromUser: "zhangsan",
|
|
409
|
+
msg: { MsgType: "event", Event: "click", EventKey: "MENU_X" },
|
|
410
|
+
auditSink: (event) => auditEvents.push(event),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(result.handled).toBe(true);
|
|
414
|
+
expect(result.chainToAgent).toBe(false);
|
|
415
|
+
expect(result.reason).toBe("unmatched_event_ignored");
|
|
416
|
+
expect(auditEvents).toHaveLength(1);
|
|
417
|
+
expect(auditEvents[0]?.category).toBe("runtime-error");
|
|
418
|
+
expect(auditEvents[0]?.summary).toContain("invalid route eventKeyPattern");
|
|
419
|
+
expect(auditEvents[0]?.error).toContain("routeId=invalid-pattern");
|
|
420
|
+
});
|
|
421
|
+
});
|
|
@@ -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
|
+
}
|