@sunnoy/wecom 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
|
@@ -1,562 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
extractGroupMessageContent,
|
|
3
|
-
generateAgentId,
|
|
4
|
-
getDynamicAgentConfig,
|
|
5
|
-
shouldTriggerGroupResponse,
|
|
6
|
-
shouldUseDynamicAgent,
|
|
7
|
-
} from "../dynamic-agent.js";
|
|
8
|
-
import { logger } from "../logger.js";
|
|
9
|
-
import { streamManager } from "../stream-manager.js";
|
|
10
|
-
import { resolveWecomCommandAuthorized } from "./allow-from.js";
|
|
11
|
-
import {
|
|
12
|
-
checkCommandAllowlist,
|
|
13
|
-
getCommandConfig,
|
|
14
|
-
isHighPriorityCommand,
|
|
15
|
-
isWecomAdmin,
|
|
16
|
-
} from "./commands.js";
|
|
17
|
-
import { THINKING_PLACEHOLDER } from "./constants.js";
|
|
18
|
-
import { downloadAndDecryptImage, downloadWecomFile, guessMimeType } from "./media.js";
|
|
19
|
-
import { deliverWecomReply } from "./outbound-delivery.js";
|
|
20
|
-
import {
|
|
21
|
-
dispatchLocks,
|
|
22
|
-
getRuntime,
|
|
23
|
-
messageBuffers,
|
|
24
|
-
responseUrls,
|
|
25
|
-
streamContext,
|
|
26
|
-
streamMeta,
|
|
27
|
-
} from "./state.js";
|
|
28
|
-
import { handleStreamError, registerActiveStream, unregisterActiveStream } from "./stream-utils.js";
|
|
29
|
-
import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Flush the debounce buffer for a given streamKey.
|
|
33
|
-
* Merges buffered messages into a single dispatch call.
|
|
34
|
-
* The first message's stream receives the LLM response.
|
|
35
|
-
* Subsequent streams get "消息已合并到第一条回复" and finish immediately.
|
|
36
|
-
*/
|
|
37
|
-
export function flushMessageBuffer(streamKey, target) {
|
|
38
|
-
const buffer = messageBuffers.get(streamKey);
|
|
39
|
-
if (!buffer) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
messageBuffers.delete(streamKey);
|
|
43
|
-
|
|
44
|
-
const { messages, streamIds } = buffer;
|
|
45
|
-
const primaryStreamId = streamIds[0];
|
|
46
|
-
const primaryMsg = messages[0];
|
|
47
|
-
|
|
48
|
-
// Merge content from all buffered messages.
|
|
49
|
-
if (messages.length > 1) {
|
|
50
|
-
const mergedContent = messages.map((m) => m.content || "").filter(Boolean).join("\n");
|
|
51
|
-
primaryMsg.content = mergedContent;
|
|
52
|
-
|
|
53
|
-
// Merge image attachments from all buffered messages.
|
|
54
|
-
// Collect single imageUrl fields first, then multi imageUrls arrays.
|
|
55
|
-
const allSingleImageUrls = messages.map((m) => m.imageUrl).filter(Boolean);
|
|
56
|
-
const allMultiImageUrls = messages.flatMap((m) => m.imageUrls || []);
|
|
57
|
-
const mergedImageUrls = [...allSingleImageUrls, ...allMultiImageUrls];
|
|
58
|
-
if (mergedImageUrls.length > 0) {
|
|
59
|
-
primaryMsg.imageUrl = mergedImageUrls[0];
|
|
60
|
-
if (mergedImageUrls.length > 1) {
|
|
61
|
-
primaryMsg.imageUrls = mergedImageUrls.slice(1);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Finish extra streams with merge notice.
|
|
66
|
-
for (let i = 1; i < streamIds.length; i++) {
|
|
67
|
-
const extraStreamId = streamIds[i];
|
|
68
|
-
streamManager.replaceIfPlaceholder(
|
|
69
|
-
extraStreamId,
|
|
70
|
-
"消息已合并到第一条回复中。",
|
|
71
|
-
THINKING_PLACEHOLDER,
|
|
72
|
-
);
|
|
73
|
-
streamManager.finishStream(extraStreamId).then(() => {
|
|
74
|
-
unregisterActiveStream(streamKey, extraStreamId);
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
logger.info("WeCom: flushing merged messages", {
|
|
79
|
-
streamKey,
|
|
80
|
-
count: messages.length,
|
|
81
|
-
primaryStreamId,
|
|
82
|
-
mergedContentPreview: mergedContent.substring(0, 60),
|
|
83
|
-
});
|
|
84
|
-
} else {
|
|
85
|
-
logger.info("WeCom: flushing single message", { streamKey, primaryStreamId });
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Dispatch the merged message.
|
|
89
|
-
processInboundMessage({
|
|
90
|
-
message: primaryMsg,
|
|
91
|
-
streamId: primaryStreamId,
|
|
92
|
-
timestamp: buffer.timestamp,
|
|
93
|
-
nonce: buffer.nonce,
|
|
94
|
-
account: target.account,
|
|
95
|
-
config: target.config,
|
|
96
|
-
}).catch(async (err) => {
|
|
97
|
-
logger.error("WeCom message processing failed", { error: err.message });
|
|
98
|
-
await handleStreamError(primaryStreamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export async function processInboundMessage({
|
|
103
|
-
message,
|
|
104
|
-
streamId,
|
|
105
|
-
timestamp: _timestamp,
|
|
106
|
-
nonce: _nonce,
|
|
107
|
-
account,
|
|
108
|
-
config,
|
|
109
|
-
}) {
|
|
110
|
-
const runtime = getRuntime();
|
|
111
|
-
const core = runtime.channel;
|
|
112
|
-
|
|
113
|
-
const senderId = message.fromUser;
|
|
114
|
-
const msgType = message.msgType || "text";
|
|
115
|
-
const imageUrl = message.imageUrl || "";
|
|
116
|
-
const imageUrls = message.imageUrls || [];
|
|
117
|
-
const fileUrl = message.fileUrl || "";
|
|
118
|
-
const fileName = message.fileName || "";
|
|
119
|
-
const rawContent = message.content || "";
|
|
120
|
-
const chatType = message.chatType || "single";
|
|
121
|
-
const chatId = message.chatId || "";
|
|
122
|
-
const isGroupChat = chatType === "group" && chatId;
|
|
123
|
-
|
|
124
|
-
// Use chat id for group sessions and sender id for direct messages.
|
|
125
|
-
const peerId = isGroupChat ? chatId : senderId;
|
|
126
|
-
const peerKind = isGroupChat ? "group" : "dm";
|
|
127
|
-
const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
|
|
128
|
-
|
|
129
|
-
// Track active stream by chat context for outbound adapter callbacks.
|
|
130
|
-
const streamKey = isGroupChat ? chatId : senderId;
|
|
131
|
-
if (streamId) {
|
|
132
|
-
registerActiveStream(streamKey, streamId);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Save response_url for fallback delivery after stream closes.
|
|
136
|
-
// response_url is valid for 1 hour and can be used only once.
|
|
137
|
-
if (message.responseUrl && message.responseUrl.trim()) {
|
|
138
|
-
responseUrls.set(streamKey, {
|
|
139
|
-
url: message.responseUrl,
|
|
140
|
-
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour
|
|
141
|
-
used: false,
|
|
142
|
-
});
|
|
143
|
-
logger.debug("WeCom: saved response_url for fallback", { streamKey });
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Apply group mention gating rules.
|
|
147
|
-
let rawBody = rawContent;
|
|
148
|
-
if (isGroupChat) {
|
|
149
|
-
if (!shouldTriggerGroupResponse(rawContent, account.config)) {
|
|
150
|
-
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
151
|
-
if (streamId) {
|
|
152
|
-
streamManager.replaceIfPlaceholder(streamId, "请@提及我以获取回复。", THINKING_PLACEHOLDER);
|
|
153
|
-
await streamManager.finishStream(streamId);
|
|
154
|
-
unregisterActiveStream(streamKey, streamId);
|
|
155
|
-
}
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
// Strip mention markers from the effective prompt.
|
|
159
|
-
rawBody = extractGroupMessageContent(rawContent, account.config);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
163
|
-
cfg: config,
|
|
164
|
-
accountId: account.accountId,
|
|
165
|
-
senderId,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// ── Quoted message context ────────────────────────────────────────
|
|
169
|
-
// When the user replies to (quotes) a previous message, prepend the
|
|
170
|
-
// quoted content so the LLM sees the full conversational context.
|
|
171
|
-
const quote = message.quote;
|
|
172
|
-
if (quote && quote.content) {
|
|
173
|
-
const quoteLabel = quote.msgType === "image" ? "[引用图片]" : `> ${quote.content}`;
|
|
174
|
-
rawBody = `${quoteLabel}\n\n${rawBody}`;
|
|
175
|
-
logger.debug("WeCom: prepended quoted message context", {
|
|
176
|
-
quoteType: quote.msgType,
|
|
177
|
-
quotePreview: quote.content.substring(0, 60),
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Skip empty messages, but allow image/mixed/file messages.
|
|
182
|
-
if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
|
|
183
|
-
logger.debug("WeCom: empty message, skipping", { msgType });
|
|
184
|
-
if (streamId) {
|
|
185
|
-
await streamManager.finishStream(streamId);
|
|
186
|
-
unregisterActiveStream(streamKey, streamId);
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ========================================================================
|
|
192
|
-
// Command allowlist enforcement
|
|
193
|
-
// Admins bypass the allowlist entirely.
|
|
194
|
-
// ========================================================================
|
|
195
|
-
const senderIsAdmin = isWecomAdmin(senderId, account.config);
|
|
196
|
-
const commandCheck = checkCommandAllowlist(rawBody, account.config);
|
|
197
|
-
|
|
198
|
-
if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
|
|
199
|
-
// Return block message when command is outside the allowlist.
|
|
200
|
-
const cmdConfig = getCommandConfig(account.config);
|
|
201
|
-
logger.warn("WeCom: blocked command", {
|
|
202
|
-
command: commandCheck.command,
|
|
203
|
-
from: senderId,
|
|
204
|
-
chatType: peerKind,
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// Send blocked-command response through the same stream.
|
|
208
|
-
if (streamId) {
|
|
209
|
-
streamManager.replaceIfPlaceholder(streamId, cmdConfig.blockMessage, THINKING_PLACEHOLDER);
|
|
210
|
-
await streamManager.finishStream(streamId);
|
|
211
|
-
unregisterActiveStream(streamKey, streamId);
|
|
212
|
-
}
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (commandCheck.isCommand && !commandCheck.allowed && senderIsAdmin) {
|
|
217
|
-
logger.info("WeCom: admin bypassed command allowlist", {
|
|
218
|
-
command: commandCheck.command,
|
|
219
|
-
from: senderId,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
logger.info("WeCom processing message", {
|
|
224
|
-
from: senderId,
|
|
225
|
-
chatType: peerKind,
|
|
226
|
-
peerId,
|
|
227
|
-
content: rawBody.substring(0, 50),
|
|
228
|
-
streamId,
|
|
229
|
-
isCommand: commandCheck.isCommand,
|
|
230
|
-
command: commandCheck.command,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const highPriorityCommand = commandCheck.isCommand && isHighPriorityCommand(commandCheck.command);
|
|
234
|
-
|
|
235
|
-
// ========================================================================
|
|
236
|
-
// Dynamic agent routing
|
|
237
|
-
// Admins also use dynamic agents; admin flag only affects command allowlist.
|
|
238
|
-
// ========================================================================
|
|
239
|
-
const dynamicConfig = getDynamicAgentConfig(account.config);
|
|
240
|
-
|
|
241
|
-
// Compute deterministic agent target for this conversation.
|
|
242
|
-
const targetAgentId =
|
|
243
|
-
dynamicConfig.enabled
|
|
244
|
-
&& shouldUseDynamicAgent({
|
|
245
|
-
chatType: peerKind,
|
|
246
|
-
config: account.config,
|
|
247
|
-
senderIsAdmin,
|
|
248
|
-
})
|
|
249
|
-
? generateAgentId(peerKind, peerId, account.accountId)
|
|
250
|
-
: null;
|
|
251
|
-
|
|
252
|
-
// Resolve template directory: per-account or global.
|
|
253
|
-
const templateDir = account.config?.workspaceTemplate || config?.channels?.wecom?.workspaceTemplate;
|
|
254
|
-
if (targetAgentId) {
|
|
255
|
-
await ensureDynamicAgentListed(targetAgentId, templateDir);
|
|
256
|
-
logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
|
|
257
|
-
} else if (senderIsAdmin) {
|
|
258
|
-
logger.debug("Admin user, dynamic agent disabled for this chat type; falling back to default route", {
|
|
259
|
-
senderId,
|
|
260
|
-
chatType: peerKind,
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ========================================================================
|
|
265
|
-
// Resolve route and override with dynamic agent when enabled
|
|
266
|
-
// ========================================================================
|
|
267
|
-
const route = core.routing.resolveAgentRoute({
|
|
268
|
-
cfg: config,
|
|
269
|
-
channel: "wecom",
|
|
270
|
-
accountId: account.accountId,
|
|
271
|
-
peer: {
|
|
272
|
-
kind: peerKind,
|
|
273
|
-
id: peerId,
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// Override default route with deterministic dynamic agent session key,
|
|
278
|
-
// but respect explicit bindings configured for this channel + account.
|
|
279
|
-
const hasExplicitBinding = Array.isArray(config?.bindings) &&
|
|
280
|
-
config.bindings.some((b) =>
|
|
281
|
-
b.match?.channel === "wecom" && b.match?.accountId === account.accountId,
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
if (targetAgentId && !hasExplicitBinding) {
|
|
285
|
-
route.agentId = targetAgentId;
|
|
286
|
-
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
287
|
-
} else if (hasExplicitBinding) {
|
|
288
|
-
logger.debug("WeCom: explicit binding found, skipping dynamic agent override", {
|
|
289
|
-
accountId: account.accountId,
|
|
290
|
-
resolvedAgentId: route.agentId,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Build inbound context
|
|
295
|
-
const storePath = core.session.resolveStorePath(config.session?.store, {
|
|
296
|
-
agentId: route.agentId,
|
|
297
|
-
});
|
|
298
|
-
const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
|
|
299
|
-
const previousTimestamp = core.session.readSessionUpdatedAt({
|
|
300
|
-
storePath,
|
|
301
|
-
sessionKey: route.sessionKey,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// Prefix sender id in group contexts so attribution stays explicit.
|
|
305
|
-
const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
|
|
306
|
-
const body = core.reply.formatAgentEnvelope({
|
|
307
|
-
channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
|
|
308
|
-
from: senderLabel,
|
|
309
|
-
timestamp: Date.now(),
|
|
310
|
-
previousTimestamp,
|
|
311
|
-
envelope: envelopeOptions,
|
|
312
|
-
body: rawBody,
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// Build context payload with optional image attachment.
|
|
316
|
-
const ctxBase = {
|
|
317
|
-
Body: body,
|
|
318
|
-
RawBody: rawBody,
|
|
319
|
-
CommandBody: rawBody,
|
|
320
|
-
From: `wecom:${senderId}`,
|
|
321
|
-
To: conversationId,
|
|
322
|
-
SessionKey: route.sessionKey,
|
|
323
|
-
AccountId: route.accountId,
|
|
324
|
-
ChatType: isGroupChat ? "group" : "direct",
|
|
325
|
-
ConversationLabel: isGroupChat ? `Group ${chatId}` : senderId,
|
|
326
|
-
SenderName: senderId,
|
|
327
|
-
SenderId: senderId,
|
|
328
|
-
GroupId: isGroupChat ? chatId : undefined,
|
|
329
|
-
Provider: "wecom",
|
|
330
|
-
Surface: "wecom",
|
|
331
|
-
OriginatingChannel: "wecom",
|
|
332
|
-
OriginatingTo: conversationId,
|
|
333
|
-
CommandAuthorized: commandAuthorized,
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
// Download, decrypt, and attach media when present.
|
|
337
|
-
// Combine imageUrl (single) and imageUrls (array) so both are processed
|
|
338
|
-
// when a merged message carries values in both fields.
|
|
339
|
-
const allImageUrls = [imageUrl, ...imageUrls].filter(Boolean);
|
|
340
|
-
|
|
341
|
-
if (allImageUrls.length > 0) {
|
|
342
|
-
const mediaPaths = [];
|
|
343
|
-
const mediaTypes = [];
|
|
344
|
-
const fallbackUrls = [];
|
|
345
|
-
|
|
346
|
-
for (const url of allImageUrls) {
|
|
347
|
-
try {
|
|
348
|
-
const result = await downloadAndDecryptImage(url, account.encodingAesKey, account.token);
|
|
349
|
-
mediaPaths.push(result.localPath);
|
|
350
|
-
mediaTypes.push(result.mimeType);
|
|
351
|
-
} catch (e) {
|
|
352
|
-
logger.warn("Image decryption failed, using URL fallback", {
|
|
353
|
-
error: e.message,
|
|
354
|
-
url: url.substring(0, 80),
|
|
355
|
-
});
|
|
356
|
-
fallbackUrls.push(url);
|
|
357
|
-
mediaTypes.push("image/jpeg");
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (mediaPaths.length > 0) {
|
|
362
|
-
ctxBase.MediaPaths = mediaPaths;
|
|
363
|
-
}
|
|
364
|
-
if (fallbackUrls.length > 0) {
|
|
365
|
-
ctxBase.MediaUrls = fallbackUrls;
|
|
366
|
-
}
|
|
367
|
-
ctxBase.MediaTypes = mediaTypes;
|
|
368
|
-
|
|
369
|
-
logger.info("Image attachments prepared", {
|
|
370
|
-
decrypted: mediaPaths.length,
|
|
371
|
-
fallback: fallbackUrls.length,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// For image-only messages (no text), set a placeholder body.
|
|
375
|
-
if (!rawBody.trim()) {
|
|
376
|
-
const count = allImageUrls.length;
|
|
377
|
-
ctxBase.Body = count > 1
|
|
378
|
-
? `[用户发送了${count}张图片]`
|
|
379
|
-
: "[用户发送了一张图片]";
|
|
380
|
-
ctxBase.RawBody = "[图片]";
|
|
381
|
-
ctxBase.CommandBody = "";
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Handle file attachment.
|
|
386
|
-
if (fileUrl) {
|
|
387
|
-
try {
|
|
388
|
-
const { localPath: localFilePath, effectiveFileName } = await downloadWecomFile(
|
|
389
|
-
fileUrl,
|
|
390
|
-
fileName,
|
|
391
|
-
account.encodingAesKey,
|
|
392
|
-
account.token,
|
|
393
|
-
);
|
|
394
|
-
ctxBase.MediaPaths = [...(ctxBase.MediaPaths || []), localFilePath];
|
|
395
|
-
ctxBase.MediaTypes = [...(ctxBase.MediaTypes || []), guessMimeType(effectiveFileName)];
|
|
396
|
-
logger.info("File attachment prepared", { path: localFilePath, name: effectiveFileName });
|
|
397
|
-
} catch (e) {
|
|
398
|
-
logger.warn("File download failed", { error: e.message });
|
|
399
|
-
// Inform the agent about the file via text.
|
|
400
|
-
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
401
|
-
if (!rawBody.trim()) {
|
|
402
|
-
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
403
|
-
ctxBase.RawBody = label;
|
|
404
|
-
ctxBase.CommandBody = "";
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (!rawBody.trim() && !ctxBase.Body) {
|
|
408
|
-
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
409
|
-
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
410
|
-
ctxBase.RawBody = label;
|
|
411
|
-
ctxBase.CommandBody = "";
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const ctxPayload = core.reply.finalizeInboundContext(ctxBase);
|
|
416
|
-
|
|
417
|
-
// Record session meta
|
|
418
|
-
void core.session
|
|
419
|
-
.recordSessionMetaFromInbound({
|
|
420
|
-
storePath,
|
|
421
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
422
|
-
ctx: ctxPayload,
|
|
423
|
-
})
|
|
424
|
-
.catch((err) => {
|
|
425
|
-
logger.error("WeCom: failed updating session meta", { error: err.message });
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
const runDispatch = async () => {
|
|
429
|
-
// --- Stream close coordination ---
|
|
430
|
-
// dispatchReplyWithBufferedBlockDispatcher may return before the LLM
|
|
431
|
-
// actually processes the message (e.g. when the session lane is busy and
|
|
432
|
-
// the message is queued). We therefore track two signals:
|
|
433
|
-
// 1. dispatchDone – the await on the dispatcher has resolved.
|
|
434
|
-
// 2. hadDelivery – at least one deliver callback has fired.
|
|
435
|
-
// We only schedule a stream-close timer when BOTH are true, and we
|
|
436
|
-
// reset the timer on every new delivery so the stream stays open while
|
|
437
|
-
// content keeps arriving.
|
|
438
|
-
let dispatchDone = false;
|
|
439
|
-
let hadDelivery = false;
|
|
440
|
-
let closeTimer = null;
|
|
441
|
-
|
|
442
|
-
const scheduleStreamClose = () => {
|
|
443
|
-
if (closeTimer) clearTimeout(closeTimer);
|
|
444
|
-
closeTimer = setTimeout(async () => {
|
|
445
|
-
const s = streamManager.getStream(streamId);
|
|
446
|
-
if (s && !s.finished) {
|
|
447
|
-
logger.info("WeCom: finishing stream after dispatch complete", { streamId });
|
|
448
|
-
try {
|
|
449
|
-
await streamManager.finishStream(streamId);
|
|
450
|
-
} catch (err) {
|
|
451
|
-
logger.error("WeCom: failed to finish stream post-dispatch", {
|
|
452
|
-
streamId,
|
|
453
|
-
error: err.message,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
unregisterActiveStream(streamKey, streamId);
|
|
457
|
-
}
|
|
458
|
-
}, 200); // short grace for I/O flush; dispatcher is already done
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
// Dispatch reply with AI processing.
|
|
462
|
-
// Wrap in streamContext so outbound adapters resolve the correct stream.
|
|
463
|
-
await streamContext.run({ streamId, streamKey, agentId: route.agentId, accountId: account.accountId }, async () => {
|
|
464
|
-
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
465
|
-
ctx: ctxPayload,
|
|
466
|
-
cfg: config,
|
|
467
|
-
// Force block streaming for WeCom so incremental content can be emitted
|
|
468
|
-
// during long LLM runs instead of waiting for final completion.
|
|
469
|
-
replyOptions: {
|
|
470
|
-
disableBlockStreaming: false,
|
|
471
|
-
},
|
|
472
|
-
dispatcherOptions: {
|
|
473
|
-
deliver: async (payload, info) => {
|
|
474
|
-
hadDelivery = true;
|
|
475
|
-
|
|
476
|
-
logger.info("Dispatcher deliver called", {
|
|
477
|
-
kind: info.kind,
|
|
478
|
-
hasText: !!(payload.text && payload.text.trim()),
|
|
479
|
-
hasMediaUrl: !!(payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length)),
|
|
480
|
-
textPreview: (payload.text || "").substring(0, 50),
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
try {
|
|
484
|
-
await deliverWecomReply({
|
|
485
|
-
payload,
|
|
486
|
-
senderId: streamKey,
|
|
487
|
-
streamId,
|
|
488
|
-
agentId: route.agentId,
|
|
489
|
-
});
|
|
490
|
-
} catch (deliverErr) {
|
|
491
|
-
logger.error("WeCom: deliverWecomReply threw, continuing to finalize stream", {
|
|
492
|
-
streamId,
|
|
493
|
-
error: deliverErr.message,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Schedule / reset stream close timer if dispatch already returned.
|
|
498
|
-
if (streamId && dispatchDone) {
|
|
499
|
-
scheduleStreamClose();
|
|
500
|
-
}
|
|
501
|
-
},
|
|
502
|
-
onError: async (err, info) => {
|
|
503
|
-
logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
|
|
504
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
505
|
-
},
|
|
506
|
-
},
|
|
507
|
-
});
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// Dispatch returned — the entire LLM turn (all blocks, tool calls,
|
|
511
|
-
// and final payloads) is complete. Mark mainResponseDone now so the
|
|
512
|
-
// idle-close timer in http-handler only starts after there is truly
|
|
513
|
-
// no more content to deliver.
|
|
514
|
-
dispatchDone = true;
|
|
515
|
-
|
|
516
|
-
if (streamId) {
|
|
517
|
-
streamMeta.set(streamId, {
|
|
518
|
-
mainResponseDone: true,
|
|
519
|
-
doneAt: Date.now(),
|
|
520
|
-
});
|
|
521
|
-
const stream = streamManager.getStream(streamId);
|
|
522
|
-
if (!stream || stream.finished) {
|
|
523
|
-
unregisterActiveStream(streamKey, streamId);
|
|
524
|
-
} else if (hadDelivery) {
|
|
525
|
-
// Normal case: content was already delivered, close after grace period.
|
|
526
|
-
scheduleStreamClose();
|
|
527
|
-
}
|
|
528
|
-
// If !hadDelivery, the message was queued and is not yet processed.
|
|
529
|
-
// The deliver callback will fire later and schedule the close (since
|
|
530
|
-
// dispatchDone is now true). The existing stream GC handles the edge
|
|
531
|
-
// case where no delivery ever arrives.
|
|
532
|
-
}
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
if (highPriorityCommand) {
|
|
536
|
-
logger.info("WeCom: high-priority command bypassing dispatch queue", {
|
|
537
|
-
streamKey,
|
|
538
|
-
streamId,
|
|
539
|
-
command: commandCheck.command,
|
|
540
|
-
});
|
|
541
|
-
try {
|
|
542
|
-
await runDispatch();
|
|
543
|
-
} catch (err) {
|
|
544
|
-
logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
|
|
545
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
546
|
-
}
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Serialize non-priority dispatches per user/group.
|
|
551
|
-
const prevLock = dispatchLocks.get(streamKey) ?? Promise.resolve();
|
|
552
|
-
const currentDispatch = prevLock.then(runDispatch).catch(async (err) => {
|
|
553
|
-
logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
|
|
554
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
dispatchLocks.set(streamKey, currentDispatch);
|
|
558
|
-
await currentDispatch;
|
|
559
|
-
if (dispatchLocks.get(streamKey) === currentDispatch) {
|
|
560
|
-
dispatchLocks.delete(streamKey);
|
|
561
|
-
}
|
|
562
|
-
}
|