@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
|
@@ -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
|
+
});
|
package/src/agent/handler.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
shouldUseDynamicAgent,
|
|
16
16
|
ensureDynamicAgentListed,
|
|
17
17
|
} from "../dynamic-agent.js";
|
|
18
|
+
import { setPeerContext } from "../context-store.js";
|
|
18
19
|
import { getWecomRuntime } from "../runtime.js";
|
|
19
20
|
import { registerWecomSourceSnapshot } from "../runtime/source-registry.js";
|
|
20
21
|
import {
|
|
@@ -30,16 +31,28 @@ import {
|
|
|
30
31
|
extractMsgId,
|
|
31
32
|
extractFileName,
|
|
32
33
|
extractAgentId,
|
|
34
|
+
extractToUser,
|
|
33
35
|
} from "../shared/xml-parser.js";
|
|
34
|
-
import {
|
|
36
|
+
import { routeAgentInboundEvent } from "./event-router.js";
|
|
37
|
+
import { resolveOutboundMediaAsset } from "../shared/media-asset.js";
|
|
38
|
+
import {
|
|
39
|
+
downloadAgentApiMedia,
|
|
40
|
+
downloadUpstreamAgentApiMedia,
|
|
41
|
+
sendAgentApiText,
|
|
42
|
+
sendUpstreamAgentApiText,
|
|
43
|
+
} from "../transport/agent-api/client.js";
|
|
44
|
+
import { deliverAgentApiMedia } from "../transport/agent-api/delivery.js";
|
|
45
|
+
import { deliverUpstreamAgentApiMedia } from "../transport/agent-api/upstream-delivery.js";
|
|
35
46
|
import type {
|
|
36
47
|
ResolvedAgentAccount,
|
|
48
|
+
ReplyPayload,
|
|
37
49
|
UnifiedInboundEvent,
|
|
38
50
|
WecomInboundKind,
|
|
39
51
|
} from "../types/index.js";
|
|
40
52
|
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
41
53
|
import type { TransportSessionPatch } from "../types/index.js";
|
|
42
54
|
import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
|
|
55
|
+
import { detectUpstreamUser, createUpstreamAgentConfig, resolveUpstreamCorpConfig } from "../upstream/index.js";
|
|
43
56
|
|
|
44
57
|
/** 错误提示信息 */
|
|
45
58
|
const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
|
|
@@ -123,6 +136,11 @@ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefi
|
|
|
123
136
|
return truncated;
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
function readContextSessionId(ctx: { SessionId?: string } | Record<string, unknown>): string | undefined {
|
|
140
|
+
const sessionId = "SessionId" in ctx ? ctx.SessionId : undefined;
|
|
141
|
+
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* **AgentWebhookParams (Webhook 处理器参数)**
|
|
128
146
|
*
|
|
@@ -175,6 +193,8 @@ export function shouldProcessAgentInboundMessage(params: {
|
|
|
175
193
|
fromUser: string;
|
|
176
194
|
chatId?: string;
|
|
177
195
|
eventType?: string;
|
|
196
|
+
eventEnabled?: boolean;
|
|
197
|
+
allowedEventTypes?: string[];
|
|
178
198
|
}): AgentInboundProcessDecision {
|
|
179
199
|
const msgType = String(params.msgType ?? "")
|
|
180
200
|
.trim()
|
|
@@ -187,7 +207,8 @@ export function shouldProcessAgentInboundMessage(params: {
|
|
|
187
207
|
.toLowerCase();
|
|
188
208
|
|
|
189
209
|
if (msgType === "event") {
|
|
190
|
-
|
|
210
|
+
// 兼容旧行为:未配置 event 策略时,继续沿用历史白名单
|
|
211
|
+
const compatibilityAllowedEvents = [
|
|
191
212
|
"subscribe",
|
|
192
213
|
"enter_agent",
|
|
193
214
|
"batch_job_result",
|
|
@@ -203,6 +224,26 @@ export function shouldProcessAgentInboundMessage(params: {
|
|
|
203
224
|
"smartsheet_field_change",
|
|
204
225
|
"smartsheet_view_change",
|
|
205
226
|
];
|
|
227
|
+
const configuredAllowedEvents = Array.isArray(params.allowedEventTypes)
|
|
228
|
+
? params.allowedEventTypes
|
|
229
|
+
.map((entry) => String(entry ?? "").trim().toLowerCase())
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
: [];
|
|
232
|
+
const hasEventConfig = params.eventEnabled !== undefined || configuredAllowedEvents.length > 0;
|
|
233
|
+
|
|
234
|
+
// 显式关闭 event 时直接拒绝,优先级最高
|
|
235
|
+
if (params.eventEnabled === false) {
|
|
236
|
+
return {
|
|
237
|
+
shouldProcess: false,
|
|
238
|
+
reason: "event_disabled",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 配置存在时:历史白名单 + 配置白名单并集,保证平滑迁移
|
|
243
|
+
const allowedEvents = hasEventConfig
|
|
244
|
+
? Array.from(new Set([...compatibilityAllowedEvents, ...configuredAllowedEvents]))
|
|
245
|
+
: compatibilityAllowedEvents;
|
|
246
|
+
|
|
206
247
|
if (
|
|
207
248
|
allowedEvents.includes(eventType) ||
|
|
208
249
|
eventType.startsWith("doc_") ||
|
|
@@ -246,6 +287,13 @@ export function shouldProcessAgentInboundMessage(params: {
|
|
|
246
287
|
};
|
|
247
288
|
}
|
|
248
289
|
|
|
290
|
+
export function shouldSuppressAgentReplyText(params: {
|
|
291
|
+
text: string;
|
|
292
|
+
mediaReplySeen: boolean;
|
|
293
|
+
}): boolean {
|
|
294
|
+
return params.mediaReplySeen && Boolean(params.text.trim());
|
|
295
|
+
}
|
|
296
|
+
|
|
249
297
|
function normalizeAgentId(value: unknown): number | undefined {
|
|
250
298
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
251
299
|
const raw = String(value ?? "").trim();
|
|
@@ -254,6 +302,72 @@ function normalizeAgentId(value: unknown): number | undefined {
|
|
|
254
302
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
255
303
|
}
|
|
256
304
|
|
|
305
|
+
function resolveAgentReplyTransportContext(params: {
|
|
306
|
+
agent: ResolvedAgentAccount;
|
|
307
|
+
msg: WecomAgentInboundMessage;
|
|
308
|
+
fromUser: string;
|
|
309
|
+
chatId?: string;
|
|
310
|
+
log?: (msg: string) => void;
|
|
311
|
+
error?: (msg: string) => void;
|
|
312
|
+
}): {
|
|
313
|
+
upstreamAgent?: ResolvedAgentAccount;
|
|
314
|
+
primaryAgentForUpstream?: ResolvedAgentAccount;
|
|
315
|
+
upstreamReplyTarget?: { toUser: string | undefined; chatId: string | undefined };
|
|
316
|
+
effectiveReplyTarget: { toUser: string | undefined; chatId: string | undefined };
|
|
317
|
+
} {
|
|
318
|
+
// 事件路由也可能需要即时回包,这里复用普通消息的上下游目标判定逻辑
|
|
319
|
+
const { agent, msg, fromUser, chatId, log, error } = params;
|
|
320
|
+
const isGroup = Boolean(chatId);
|
|
321
|
+
const peerId = isGroup ? chatId! : fromUser;
|
|
322
|
+
const replyTarget = isGroup
|
|
323
|
+
? ({ toUser: undefined, chatId: peerId } as const)
|
|
324
|
+
: ({ toUser: fromUser, chatId: undefined } as const);
|
|
325
|
+
const toUserName = extractToUser(msg);
|
|
326
|
+
const isUpstreamUser = detectUpstreamUser({
|
|
327
|
+
messageToUserName: toUserName,
|
|
328
|
+
primaryCorpId: agent.corpId,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!isUpstreamUser) {
|
|
332
|
+
return {
|
|
333
|
+
upstreamAgent: undefined,
|
|
334
|
+
primaryAgentForUpstream: undefined,
|
|
335
|
+
upstreamReplyTarget: undefined,
|
|
336
|
+
effectiveReplyTarget: replyTarget,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log?.(`[wecom-agent] detected upstream user during event routing: from=${fromUser} toCorpId=${toUserName}`);
|
|
341
|
+
const upstreamConfig = resolveUpstreamCorpConfig({
|
|
342
|
+
upstreamCorpId: toUserName,
|
|
343
|
+
upstreamCorps: agent.config.upstreamCorps,
|
|
344
|
+
});
|
|
345
|
+
if (!upstreamConfig) {
|
|
346
|
+
error?.(
|
|
347
|
+
`[wecom-agent] upstream event detected but no upstream config for corpId=${toUserName}; fallback to primary agent target`,
|
|
348
|
+
);
|
|
349
|
+
return {
|
|
350
|
+
upstreamAgent: undefined,
|
|
351
|
+
primaryAgentForUpstream: undefined,
|
|
352
|
+
upstreamReplyTarget: undefined,
|
|
353
|
+
effectiveReplyTarget: replyTarget,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const upstreamAgent = createUpstreamAgentConfig({
|
|
358
|
+
baseAgent: agent,
|
|
359
|
+
upstreamCorpId: toUserName,
|
|
360
|
+
upstreamAgentId: upstreamConfig.agentId,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
upstreamAgent,
|
|
365
|
+
primaryAgentForUpstream: agent,
|
|
366
|
+
upstreamReplyTarget: replyTarget,
|
|
367
|
+
effectiveReplyTarget: replyTarget,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
257
371
|
/**
|
|
258
372
|
* **resolveQueryParams (解析查询参数)**
|
|
259
373
|
*
|
|
@@ -367,6 +481,8 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
367
481
|
fromUser,
|
|
368
482
|
chatId,
|
|
369
483
|
eventType,
|
|
484
|
+
eventEnabled: agent.eventEnabled,
|
|
485
|
+
allowedEventTypes: agent.allowedEventTypes,
|
|
370
486
|
});
|
|
371
487
|
if (!decision.shouldProcess) {
|
|
372
488
|
log?.(
|
|
@@ -375,6 +491,57 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
375
491
|
return true;
|
|
376
492
|
}
|
|
377
493
|
|
|
494
|
+
const routedEvent = await routeAgentInboundEvent({
|
|
495
|
+
agent,
|
|
496
|
+
msgType,
|
|
497
|
+
eventType,
|
|
498
|
+
fromUser,
|
|
499
|
+
chatId,
|
|
500
|
+
msg,
|
|
501
|
+
log,
|
|
502
|
+
auditSink,
|
|
503
|
+
});
|
|
504
|
+
// 路由器返回文本时,先即时回包给用户/群,再决定是否进入默认 AI 流程
|
|
505
|
+
if (routedEvent.handled && routedEvent.replyText?.trim()) {
|
|
506
|
+
const replyContext = resolveAgentReplyTransportContext({
|
|
507
|
+
agent,
|
|
508
|
+
msg,
|
|
509
|
+
fromUser,
|
|
510
|
+
chatId,
|
|
511
|
+
log,
|
|
512
|
+
error,
|
|
513
|
+
});
|
|
514
|
+
try {
|
|
515
|
+
if (replyContext.upstreamAgent && replyContext.primaryAgentForUpstream) {
|
|
516
|
+
await sendUpstreamAgentApiText({
|
|
517
|
+
upstreamAgent: replyContext.upstreamAgent,
|
|
518
|
+
primaryAgent: replyContext.primaryAgentForUpstream,
|
|
519
|
+
...(replyContext.upstreamReplyTarget ?? replyContext.effectiveReplyTarget),
|
|
520
|
+
text: routedEvent.replyText,
|
|
521
|
+
});
|
|
522
|
+
} else {
|
|
523
|
+
await sendAgentApiText({
|
|
524
|
+
agent,
|
|
525
|
+
...replyContext.effectiveReplyTarget,
|
|
526
|
+
text: routedEvent.replyText,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
params.touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
530
|
+
log?.(
|
|
531
|
+
`[wecom-agent] event route reply delivered routeId=${routedEvent.matchedRouteId ?? "N/A"} to=${chatId ? `chat:${chatId}` : fromUser}`,
|
|
532
|
+
);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
error?.(`[wecom-agent] event route reply failed: ${String(err)}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// routedEvent 已完全消费该事件时,终止后续默认处理链
|
|
538
|
+
if (routedEvent.handled && !routedEvent.chainToAgent) {
|
|
539
|
+
log?.(
|
|
540
|
+
`[wecom-agent] event route handled routeId=${routedEvent.matchedRouteId ?? "N/A"} reason=${routedEvent.reason}`,
|
|
541
|
+
);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
378
545
|
// 异步处理消息
|
|
379
546
|
processAgentMessage({
|
|
380
547
|
agent,
|
|
@@ -396,9 +563,11 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
396
563
|
return true;
|
|
397
564
|
} catch (err) {
|
|
398
565
|
error?.(`[wecom-agent] callback failed: ${String(err)}`);
|
|
399
|
-
res.
|
|
400
|
-
|
|
401
|
-
|
|
566
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
567
|
+
res.statusCode = 400;
|
|
568
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
569
|
+
res.end(`error - 回调处理失败${ERROR_HELP}`);
|
|
570
|
+
}
|
|
402
571
|
return true;
|
|
403
572
|
}
|
|
404
573
|
}
|
|
@@ -448,10 +617,48 @@ async function processAgentMessage(params: {
|
|
|
448
617
|
const replyTarget = isGroup
|
|
449
618
|
? ({ toUser: undefined, chatId: peerId } as const)
|
|
450
619
|
: ({ toUser: fromUser, chatId: undefined } as const);
|
|
620
|
+
let upstreamAgent: typeof agent | undefined;
|
|
621
|
+
let upstreamReplyTarget: typeof replyTarget | undefined;
|
|
622
|
+
let primaryAgentForUpstream: typeof agent | undefined;
|
|
451
623
|
const eventType = String(msg.Event ?? "")
|
|
452
624
|
.trim()
|
|
453
625
|
.toLowerCase();
|
|
454
626
|
|
|
627
|
+
// 检测是否是上下游用户
|
|
628
|
+
const toUserName = extractToUser(msg);
|
|
629
|
+
const isUpstreamUser = detectUpstreamUser({
|
|
630
|
+
messageToUserName: toUserName,
|
|
631
|
+
primaryCorpId: agent.corpId,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
if (isUpstreamUser) {
|
|
635
|
+
log?.(
|
|
636
|
+
`[wecom-agent] detected upstream user: from=${fromUser} toCorpId=${toUserName}`,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// 查找上下游配置,构建上游 Agent 配置
|
|
640
|
+
const upstreamConfig = resolveUpstreamCorpConfig({
|
|
641
|
+
upstreamCorpId: toUserName,
|
|
642
|
+
upstreamCorps: agent.config.upstreamCorps,
|
|
643
|
+
});
|
|
644
|
+
if (upstreamConfig) {
|
|
645
|
+
upstreamAgent = createUpstreamAgentConfig({
|
|
646
|
+
baseAgent: agent,
|
|
647
|
+
upstreamCorpId: toUserName,
|
|
648
|
+
upstreamAgentId: upstreamConfig.agentId,
|
|
649
|
+
});
|
|
650
|
+
primaryAgentForUpstream = agent;
|
|
651
|
+
// 上下游的 replyTarget 与普通 DM 一致(toUser = fromUser)
|
|
652
|
+
upstreamReplyTarget = isGroup
|
|
653
|
+
? ({ toUser: undefined, chatId: peerId } as const)
|
|
654
|
+
: ({ toUser: fromUser, chatId: undefined } as const);
|
|
655
|
+
} else {
|
|
656
|
+
error?.(
|
|
657
|
+
`[wecom-agent] upstream user detected but no upstream config for corpId=${toUserName}; fallback to primary agent target`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
455
662
|
const resolveInboundKind = (): WecomInboundKind => {
|
|
456
663
|
if (msgType === "event") {
|
|
457
664
|
if (eventType === "subscribe" || eventType === "enter_agent") return "welcome";
|
|
@@ -497,7 +704,15 @@ async function processAgentMessage(params: {
|
|
|
497
704
|
buffer,
|
|
498
705
|
contentType,
|
|
499
706
|
filename: headerFileName,
|
|
500
|
-
} =
|
|
707
|
+
} =
|
|
708
|
+
upstreamAgent && primaryAgentForUpstream
|
|
709
|
+
? await downloadUpstreamAgentApiMedia({
|
|
710
|
+
upstreamAgent,
|
|
711
|
+
primaryAgent: primaryAgentForUpstream,
|
|
712
|
+
mediaId,
|
|
713
|
+
maxBytes: mediaMaxBytes,
|
|
714
|
+
})
|
|
715
|
+
: await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
|
|
501
716
|
const xmlFileName = extractFileName(msg);
|
|
502
717
|
const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
|
|
503
718
|
const heuristic = analyzeTextHeuristic(buffer);
|
|
@@ -633,7 +848,16 @@ async function processAgentMessage(params: {
|
|
|
633
848
|
`[wecom-agent] routing guard: blocked default fallback accountId=${agent.accountId} matchedBy=${route.matchedBy} from=${fromUser}`,
|
|
634
849
|
);
|
|
635
850
|
try {
|
|
636
|
-
|
|
851
|
+
if (upstreamAgent) {
|
|
852
|
+
await sendUpstreamAgentApiText({
|
|
853
|
+
upstreamAgent,
|
|
854
|
+
primaryAgent: primaryAgentForUpstream!,
|
|
855
|
+
...(upstreamReplyTarget ?? replyTarget),
|
|
856
|
+
text: prompt,
|
|
857
|
+
});
|
|
858
|
+
} else {
|
|
859
|
+
await sendAgentApiText({ agent, ...replyTarget, text: prompt });
|
|
860
|
+
}
|
|
637
861
|
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
638
862
|
log?.(`[wecom-agent] routing guard prompt delivered to ${fromUser}`);
|
|
639
863
|
} catch (err: unknown) {
|
|
@@ -710,7 +934,16 @@ async function processAgentMessage(params: {
|
|
|
710
934
|
scope: "agent",
|
|
711
935
|
});
|
|
712
936
|
try {
|
|
713
|
-
|
|
937
|
+
if (upstreamAgent) {
|
|
938
|
+
await sendUpstreamAgentApiText({
|
|
939
|
+
upstreamAgent,
|
|
940
|
+
primaryAgent: primaryAgentForUpstream!,
|
|
941
|
+
...(upstreamReplyTarget ?? replyTarget),
|
|
942
|
+
text: prompt,
|
|
943
|
+
});
|
|
944
|
+
} else {
|
|
945
|
+
await sendAgentApiText({ agent, ...replyTarget, text: prompt });
|
|
946
|
+
}
|
|
714
947
|
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
715
948
|
log?.(
|
|
716
949
|
`[wecom-agent] unauthorized command: replied to ${isGroup ? `chat:${peerId}` : fromUser}`,
|
|
@@ -756,6 +989,27 @@ async function processAgentMessage(params: {
|
|
|
756
989
|
MediaType: mediaType,
|
|
757
990
|
MediaUrl: mediaPath,
|
|
758
991
|
});
|
|
992
|
+
const sessionId = readContextSessionId(ctxPayload);
|
|
993
|
+
|
|
994
|
+
log?.(
|
|
995
|
+
`[wecom-agent] session bound: sessionKey=${ctxPayload.SessionKey ?? route.sessionKey} sessionId=${sessionId ?? "N/A"} peer=${peerId} upstream=${String(Boolean(upstreamAgent))}`,
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
registerWecomSourceSnapshot({
|
|
999
|
+
accountId: agent.accountId,
|
|
1000
|
+
source: "agent-callback",
|
|
1001
|
+
messageId: extractMsgId(msg) ?? undefined,
|
|
1002
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1003
|
+
sessionId,
|
|
1004
|
+
peerKind: isGroup ? "group" : "direct",
|
|
1005
|
+
peerId,
|
|
1006
|
+
upstreamCorpId: upstreamAgent?.corpId,
|
|
1007
|
+
});
|
|
1008
|
+
setPeerContext(agent.accountId, peerId, {
|
|
1009
|
+
peerKind: isGroup ? "group" : "direct",
|
|
1010
|
+
lastSeen: Date.now(),
|
|
1011
|
+
upstreamCorpId: upstreamAgent?.corpId,
|
|
1012
|
+
});
|
|
759
1013
|
|
|
760
1014
|
// 记录会话
|
|
761
1015
|
await core.channel.session.recordInboundSession({
|
|
@@ -769,14 +1023,25 @@ async function processAgentMessage(params: {
|
|
|
769
1023
|
|
|
770
1024
|
// 5秒无响应自动回复进度提示
|
|
771
1025
|
let hasResponseSent = false;
|
|
1026
|
+
const effectiveAgent = upstreamAgent ?? agent;
|
|
1027
|
+
const effectiveReplyTarget = upstreamReplyTarget ?? replyTarget;
|
|
772
1028
|
const processingTimer = setTimeout(async () => {
|
|
773
1029
|
if (hasResponseSent) return;
|
|
774
1030
|
try {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1031
|
+
if (upstreamAgent && primaryAgentForUpstream) {
|
|
1032
|
+
await sendUpstreamAgentApiText({
|
|
1033
|
+
upstreamAgent,
|
|
1034
|
+
primaryAgent: primaryAgentForUpstream,
|
|
1035
|
+
...effectiveReplyTarget,
|
|
1036
|
+
text: "正在处理中,请稍候...",
|
|
1037
|
+
});
|
|
1038
|
+
} else {
|
|
1039
|
+
await sendAgentApiText({
|
|
1040
|
+
agent: effectiveAgent,
|
|
1041
|
+
...effectiveReplyTarget,
|
|
1042
|
+
text: "正在处理中,请稍候...",
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
780
1045
|
log?.(
|
|
781
1046
|
`[wecom-agent] sent processing notification to ${isGroup ? `chat:${peerId}` : fromUser}`,
|
|
782
1047
|
);
|
|
@@ -787,6 +1052,25 @@ async function processAgentMessage(params: {
|
|
|
787
1052
|
|
|
788
1053
|
// 发送队列锁:确保所有 deliver 调用(以及内部的分片发送)严格串行执行
|
|
789
1054
|
let messageSendQueue = Promise.resolve();
|
|
1055
|
+
let deferredMediaUrls: string[] = [];
|
|
1056
|
+
|
|
1057
|
+
const mergeDeferredMediaUrls = (mediaUrls: string[]): string[] => {
|
|
1058
|
+
if (mediaUrls.length === 0) {
|
|
1059
|
+
return deferredMediaUrls;
|
|
1060
|
+
}
|
|
1061
|
+
const merged = [...deferredMediaUrls];
|
|
1062
|
+
for (const mediaUrl of mediaUrls) {
|
|
1063
|
+
if (!merged.includes(mediaUrl)) {
|
|
1064
|
+
merged.push(mediaUrl);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
deferredMediaUrls = merged;
|
|
1068
|
+
return deferredMediaUrls;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const replyWecomTarget = effectiveReplyTarget.chatId
|
|
1072
|
+
? ({ chatid: effectiveReplyTarget.chatId } as const)
|
|
1073
|
+
: ({ touser: effectiveReplyTarget.toUser } as const);
|
|
790
1074
|
|
|
791
1075
|
try {
|
|
792
1076
|
// 调度回复
|
|
@@ -797,10 +1081,20 @@ async function processAgentMessage(params: {
|
|
|
797
1081
|
disableBlockStreaming: false,
|
|
798
1082
|
},
|
|
799
1083
|
dispatcherOptions: {
|
|
800
|
-
deliver: async (payload:
|
|
1084
|
+
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
|
801
1085
|
const text = payload.text ?? "";
|
|
802
|
-
|
|
803
|
-
if (
|
|
1086
|
+
const incomingMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
1087
|
+
if (info.kind !== "final" && incomingMediaUrls.length > 0) {
|
|
1088
|
+
mergeDeferredMediaUrls(incomingMediaUrls);
|
|
1089
|
+
}
|
|
1090
|
+
const mediaUrls =
|
|
1091
|
+
info.kind === "final"
|
|
1092
|
+
? mergeDeferredMediaUrls(incomingMediaUrls)
|
|
1093
|
+
: incomingMediaUrls;
|
|
1094
|
+
|
|
1095
|
+
const outboundText = text;
|
|
1096
|
+
|
|
1097
|
+
if ((!outboundText || !outboundText.trim()) && mediaUrls.length === 0) {
|
|
804
1098
|
return;
|
|
805
1099
|
}
|
|
806
1100
|
|
|
@@ -813,18 +1107,27 @@ async function processAgentMessage(params: {
|
|
|
813
1107
|
const currentTask = async () => {
|
|
814
1108
|
const MAX_CHUNK_SIZE = 600;
|
|
815
1109
|
// 确保分片顺序发送
|
|
816
|
-
for (let i = 0; i <
|
|
817
|
-
const chunk =
|
|
1110
|
+
for (let i = 0; i < outboundText.length; i += MAX_CHUNK_SIZE) {
|
|
1111
|
+
const chunk = outboundText.slice(i, i + MAX_CHUNK_SIZE);
|
|
818
1112
|
|
|
819
1113
|
try {
|
|
820
|
-
|
|
1114
|
+
if (upstreamAgent) {
|
|
1115
|
+
await sendUpstreamAgentApiText({
|
|
1116
|
+
upstreamAgent,
|
|
1117
|
+
primaryAgent: primaryAgentForUpstream!,
|
|
1118
|
+
...effectiveReplyTarget,
|
|
1119
|
+
text: chunk,
|
|
1120
|
+
});
|
|
1121
|
+
} else {
|
|
1122
|
+
await sendAgentApiText({ agent: effectiveAgent, ...effectiveReplyTarget, text: chunk });
|
|
1123
|
+
}
|
|
821
1124
|
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
822
1125
|
log?.(
|
|
823
|
-
`[wecom-agent] reply chunk delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, len=${chunk.length}`,
|
|
1126
|
+
`[wecom-agent] reply chunk delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, len=${chunk.length}, sessionKey=${ctxPayload.SessionKey ?? route.sessionKey}, sessionId=${sessionId ?? "N/A"}`,
|
|
824
1127
|
);
|
|
825
1128
|
|
|
826
1129
|
// 强制延时:确保企业微信有足够时间处理顺序(优化:200ms → 50ms)
|
|
827
|
-
if (i + MAX_CHUNK_SIZE <
|
|
1130
|
+
if (i + MAX_CHUNK_SIZE < outboundText.length) {
|
|
828
1131
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
829
1132
|
}
|
|
830
1133
|
} catch (err: unknown) {
|
|
@@ -847,6 +1150,57 @@ async function processAgentMessage(params: {
|
|
|
847
1150
|
}
|
|
848
1151
|
}
|
|
849
1152
|
|
|
1153
|
+
if (info.kind === "final") {
|
|
1154
|
+
for (const mediaUrl of mediaUrls) {
|
|
1155
|
+
try {
|
|
1156
|
+
const media = await resolveOutboundMediaAsset({
|
|
1157
|
+
mediaUrl,
|
|
1158
|
+
network: effectiveAgent.network,
|
|
1159
|
+
});
|
|
1160
|
+
if (upstreamAgent) {
|
|
1161
|
+
await deliverUpstreamAgentApiMedia({
|
|
1162
|
+
upstreamAgent,
|
|
1163
|
+
primaryAgent: primaryAgentForUpstream!,
|
|
1164
|
+
target: replyWecomTarget,
|
|
1165
|
+
buffer: media.buffer,
|
|
1166
|
+
filename: media.filename,
|
|
1167
|
+
contentType: media.contentType,
|
|
1168
|
+
});
|
|
1169
|
+
} else {
|
|
1170
|
+
await deliverAgentApiMedia({
|
|
1171
|
+
agent: effectiveAgent,
|
|
1172
|
+
target: replyWecomTarget,
|
|
1173
|
+
buffer: media.buffer,
|
|
1174
|
+
filename: media.filename,
|
|
1175
|
+
contentType: media.contentType,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
1179
|
+
log?.(
|
|
1180
|
+
`[wecom-agent] reply media delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, media=${media.filename}, sessionKey=${ctxPayload.SessionKey ?? route.sessionKey}, sessionId=${sessionId ?? "N/A"}`,
|
|
1181
|
+
);
|
|
1182
|
+
} catch (err: unknown) {
|
|
1183
|
+
const message =
|
|
1184
|
+
err instanceof Error
|
|
1185
|
+
? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}`
|
|
1186
|
+
: String(err);
|
|
1187
|
+
error?.(`[wecom-agent] media reply failed: ${message}`);
|
|
1188
|
+
auditSink?.({
|
|
1189
|
+
transport: "agent-callback",
|
|
1190
|
+
category: "fallback-delivery-failed",
|
|
1191
|
+
summary: `agent callback media reply failed user=${fromUser} kind=${info.kind}`,
|
|
1192
|
+
raw: {
|
|
1193
|
+
transport: "agent-callback",
|
|
1194
|
+
envelopeType: "xml",
|
|
1195
|
+
body: msg,
|
|
1196
|
+
},
|
|
1197
|
+
error: message,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
deferredMediaUrls = [];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
850
1204
|
// 不同 Block 之间也增加一点间隔(优化:200ms → 50ms)
|
|
851
1205
|
if (info.kind !== "final") {
|
|
852
1206
|
await new Promise((resolve) => setTimeout(resolve, 50));
|