@sunnoy/wecom 1.2.0 → 1.4.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 +831 -147
- package/dynamic-agent.js +18 -4
- package/index.js +16 -1602
- package/package.json +8 -2
- package/wecom/accounts.js +258 -0
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +441 -0
- package/wecom/allow-from.js +45 -0
- package/wecom/channel-plugin.js +732 -0
- package/wecom/commands.js +90 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +531 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +484 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +84 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -303
|
@@ -0,0 +1,531 @@
|
|
|
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.
|
|
54
|
+
const allImageUrls = messages.flatMap((m) => m.imageUrls || []);
|
|
55
|
+
if (allImageUrls.length > 0) {
|
|
56
|
+
primaryMsg.imageUrls = allImageUrls;
|
|
57
|
+
}
|
|
58
|
+
const singleImages = messages.map((m) => m.imageUrl).filter(Boolean);
|
|
59
|
+
if (singleImages.length > 0 && !primaryMsg.imageUrl) {
|
|
60
|
+
primaryMsg.imageUrl = singleImages[0];
|
|
61
|
+
if (singleImages.length > 1) {
|
|
62
|
+
primaryMsg.imageUrls = [...(primaryMsg.imageUrls || []), ...singleImages.slice(1)];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Finish extra streams with merge notice.
|
|
67
|
+
for (let i = 1; i < streamIds.length; i++) {
|
|
68
|
+
const extraStreamId = streamIds[i];
|
|
69
|
+
streamManager.replaceIfPlaceholder(
|
|
70
|
+
extraStreamId,
|
|
71
|
+
"消息已合并到第一条回复中。",
|
|
72
|
+
THINKING_PLACEHOLDER,
|
|
73
|
+
);
|
|
74
|
+
streamManager.finishStream(extraStreamId).then(() => {
|
|
75
|
+
unregisterActiveStream(streamKey, extraStreamId);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger.info("WeCom: flushing merged messages", {
|
|
80
|
+
streamKey,
|
|
81
|
+
count: messages.length,
|
|
82
|
+
primaryStreamId,
|
|
83
|
+
mergedContentPreview: mergedContent.substring(0, 60),
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
logger.info("WeCom: flushing single message", { streamKey, primaryStreamId });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Dispatch the merged message.
|
|
90
|
+
processInboundMessage({
|
|
91
|
+
message: primaryMsg,
|
|
92
|
+
streamId: primaryStreamId,
|
|
93
|
+
timestamp: buffer.timestamp,
|
|
94
|
+
nonce: buffer.nonce,
|
|
95
|
+
account: target.account,
|
|
96
|
+
config: target.config,
|
|
97
|
+
}).catch(async (err) => {
|
|
98
|
+
logger.error("WeCom message processing failed", { error: err.message });
|
|
99
|
+
await handleStreamError(primaryStreamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function processInboundMessage({
|
|
104
|
+
message,
|
|
105
|
+
streamId,
|
|
106
|
+
timestamp: _timestamp,
|
|
107
|
+
nonce: _nonce,
|
|
108
|
+
account,
|
|
109
|
+
config,
|
|
110
|
+
}) {
|
|
111
|
+
const runtime = getRuntime();
|
|
112
|
+
const core = runtime.channel;
|
|
113
|
+
|
|
114
|
+
const senderId = message.fromUser;
|
|
115
|
+
const msgType = message.msgType || "text";
|
|
116
|
+
const imageUrl = message.imageUrl || "";
|
|
117
|
+
const imageUrls = message.imageUrls || [];
|
|
118
|
+
const fileUrl = message.fileUrl || "";
|
|
119
|
+
const fileName = message.fileName || "";
|
|
120
|
+
const rawContent = message.content || "";
|
|
121
|
+
const chatType = message.chatType || "single";
|
|
122
|
+
const chatId = message.chatId || "";
|
|
123
|
+
const isGroupChat = chatType === "group" && chatId;
|
|
124
|
+
|
|
125
|
+
// Use chat id for group sessions and sender id for direct messages.
|
|
126
|
+
const peerId = isGroupChat ? chatId : senderId;
|
|
127
|
+
const peerKind = isGroupChat ? "group" : "dm";
|
|
128
|
+
const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
|
|
129
|
+
|
|
130
|
+
// Track active stream by chat context for outbound adapter callbacks.
|
|
131
|
+
const streamKey = isGroupChat ? chatId : senderId;
|
|
132
|
+
if (streamId) {
|
|
133
|
+
registerActiveStream(streamKey, streamId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Save response_url for fallback delivery after stream closes.
|
|
137
|
+
// response_url is valid for 1 hour and can be used only once.
|
|
138
|
+
if (message.responseUrl && message.responseUrl.trim()) {
|
|
139
|
+
responseUrls.set(streamKey, {
|
|
140
|
+
url: message.responseUrl,
|
|
141
|
+
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour
|
|
142
|
+
used: false,
|
|
143
|
+
});
|
|
144
|
+
logger.debug("WeCom: saved response_url for fallback", { streamKey });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Apply group mention gating rules.
|
|
148
|
+
let rawBody = rawContent;
|
|
149
|
+
if (isGroupChat) {
|
|
150
|
+
if (!shouldTriggerGroupResponse(rawContent, account.config)) {
|
|
151
|
+
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
152
|
+
if (streamId) {
|
|
153
|
+
streamManager.replaceIfPlaceholder(streamId, "请@提及我以获取回复。", THINKING_PLACEHOLDER);
|
|
154
|
+
await streamManager.finishStream(streamId);
|
|
155
|
+
unregisterActiveStream(streamKey, streamId);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Strip mention markers from the effective prompt.
|
|
160
|
+
rawBody = extractGroupMessageContent(rawContent, account.config);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
164
|
+
cfg: config,
|
|
165
|
+
accountId: account.accountId,
|
|
166
|
+
senderId,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Skip empty messages, but allow image/mixed/file messages.
|
|
170
|
+
if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
|
|
171
|
+
logger.debug("WeCom: empty message, skipping", { msgType });
|
|
172
|
+
if (streamId) {
|
|
173
|
+
await streamManager.finishStream(streamId);
|
|
174
|
+
unregisterActiveStream(streamKey, streamId);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ========================================================================
|
|
180
|
+
// Command allowlist enforcement
|
|
181
|
+
// Admins bypass the allowlist entirely.
|
|
182
|
+
// ========================================================================
|
|
183
|
+
const senderIsAdmin = isWecomAdmin(senderId, account.config);
|
|
184
|
+
const commandCheck = checkCommandAllowlist(rawBody, account.config);
|
|
185
|
+
|
|
186
|
+
if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
|
|
187
|
+
// Return block message when command is outside the allowlist.
|
|
188
|
+
const cmdConfig = getCommandConfig(account.config);
|
|
189
|
+
logger.warn("WeCom: blocked command", {
|
|
190
|
+
command: commandCheck.command,
|
|
191
|
+
from: senderId,
|
|
192
|
+
chatType: peerKind,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Send blocked-command response through the same stream.
|
|
196
|
+
if (streamId) {
|
|
197
|
+
streamManager.replaceIfPlaceholder(streamId, cmdConfig.blockMessage, THINKING_PLACEHOLDER);
|
|
198
|
+
await streamManager.finishStream(streamId);
|
|
199
|
+
unregisterActiveStream(streamKey, streamId);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (commandCheck.isCommand && !commandCheck.allowed && senderIsAdmin) {
|
|
205
|
+
logger.info("WeCom: admin bypassed command allowlist", {
|
|
206
|
+
command: commandCheck.command,
|
|
207
|
+
from: senderId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
logger.info("WeCom processing message", {
|
|
212
|
+
from: senderId,
|
|
213
|
+
chatType: peerKind,
|
|
214
|
+
peerId,
|
|
215
|
+
content: rawBody.substring(0, 50),
|
|
216
|
+
streamId,
|
|
217
|
+
isCommand: commandCheck.isCommand,
|
|
218
|
+
command: commandCheck.command,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const highPriorityCommand = commandCheck.isCommand && isHighPriorityCommand(commandCheck.command);
|
|
222
|
+
|
|
223
|
+
// ========================================================================
|
|
224
|
+
// Dynamic agent routing
|
|
225
|
+
// Admins also use dynamic agents; admin flag only affects command allowlist.
|
|
226
|
+
// ========================================================================
|
|
227
|
+
const dynamicConfig = getDynamicAgentConfig(account.config);
|
|
228
|
+
|
|
229
|
+
// Compute deterministic agent target for this conversation.
|
|
230
|
+
const targetAgentId =
|
|
231
|
+
dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config: account.config })
|
|
232
|
+
? generateAgentId(peerKind, peerId, account.accountId)
|
|
233
|
+
: null;
|
|
234
|
+
|
|
235
|
+
if (targetAgentId) {
|
|
236
|
+
await ensureDynamicAgentListed(targetAgentId);
|
|
237
|
+
logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
|
|
238
|
+
} else if (senderIsAdmin) {
|
|
239
|
+
logger.debug("Admin user, dynamic agent disabled for this chat type; falling back to default route", {
|
|
240
|
+
senderId,
|
|
241
|
+
chatType: peerKind,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ========================================================================
|
|
246
|
+
// Resolve route and override with dynamic agent when enabled
|
|
247
|
+
// ========================================================================
|
|
248
|
+
const route = core.routing.resolveAgentRoute({
|
|
249
|
+
cfg: config,
|
|
250
|
+
channel: "wecom",
|
|
251
|
+
accountId: account.accountId,
|
|
252
|
+
peer: {
|
|
253
|
+
kind: peerKind,
|
|
254
|
+
id: peerId,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Override default route with deterministic dynamic agent session key.
|
|
259
|
+
if (targetAgentId) {
|
|
260
|
+
route.agentId = targetAgentId;
|
|
261
|
+
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Build inbound context
|
|
265
|
+
const storePath = core.session.resolveStorePath(config.session?.store, {
|
|
266
|
+
agentId: route.agentId,
|
|
267
|
+
});
|
|
268
|
+
const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
|
|
269
|
+
const previousTimestamp = core.session.readSessionUpdatedAt({
|
|
270
|
+
storePath,
|
|
271
|
+
sessionKey: route.sessionKey,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Prefix sender id in group contexts so attribution stays explicit.
|
|
275
|
+
const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
|
|
276
|
+
const body = core.reply.formatAgentEnvelope({
|
|
277
|
+
channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
|
|
278
|
+
from: senderLabel,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
previousTimestamp,
|
|
281
|
+
envelope: envelopeOptions,
|
|
282
|
+
body: rawBody,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Build context payload with optional image attachment.
|
|
286
|
+
const ctxBase = {
|
|
287
|
+
Body: body,
|
|
288
|
+
RawBody: rawBody,
|
|
289
|
+
CommandBody: rawBody,
|
|
290
|
+
From: `wecom:${senderId}`,
|
|
291
|
+
To: conversationId,
|
|
292
|
+
SessionKey: route.sessionKey,
|
|
293
|
+
AccountId: route.accountId,
|
|
294
|
+
ChatType: isGroupChat ? "group" : "direct",
|
|
295
|
+
ConversationLabel: isGroupChat ? `Group ${chatId}` : senderId,
|
|
296
|
+
SenderName: senderId,
|
|
297
|
+
SenderId: senderId,
|
|
298
|
+
GroupId: isGroupChat ? chatId : undefined,
|
|
299
|
+
Provider: "wecom",
|
|
300
|
+
Surface: "wecom",
|
|
301
|
+
OriginatingChannel: "wecom",
|
|
302
|
+
OriginatingTo: conversationId,
|
|
303
|
+
CommandAuthorized: commandAuthorized,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Download, decrypt, and attach media when present.
|
|
307
|
+
const allImageUrls = imageUrl ? [imageUrl] : imageUrls;
|
|
308
|
+
|
|
309
|
+
if (allImageUrls.length > 0) {
|
|
310
|
+
const mediaPaths = [];
|
|
311
|
+
const mediaTypes = [];
|
|
312
|
+
const fallbackUrls = [];
|
|
313
|
+
|
|
314
|
+
for (const url of allImageUrls) {
|
|
315
|
+
try {
|
|
316
|
+
const result = await downloadAndDecryptImage(url, account.encodingAesKey, account.token);
|
|
317
|
+
mediaPaths.push(result.localPath);
|
|
318
|
+
mediaTypes.push(result.mimeType);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
logger.warn("Image decryption failed, using URL fallback", {
|
|
321
|
+
error: e.message,
|
|
322
|
+
url: url.substring(0, 80),
|
|
323
|
+
});
|
|
324
|
+
fallbackUrls.push(url);
|
|
325
|
+
mediaTypes.push("image/jpeg");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (mediaPaths.length > 0) {
|
|
330
|
+
ctxBase.MediaPaths = mediaPaths;
|
|
331
|
+
}
|
|
332
|
+
if (fallbackUrls.length > 0) {
|
|
333
|
+
ctxBase.MediaUrls = fallbackUrls;
|
|
334
|
+
}
|
|
335
|
+
ctxBase.MediaTypes = mediaTypes;
|
|
336
|
+
|
|
337
|
+
logger.info("Image attachments prepared", {
|
|
338
|
+
decrypted: mediaPaths.length,
|
|
339
|
+
fallback: fallbackUrls.length,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// For image-only messages (no text), set a placeholder body.
|
|
343
|
+
if (!rawBody.trim()) {
|
|
344
|
+
const count = allImageUrls.length;
|
|
345
|
+
ctxBase.Body = count > 1
|
|
346
|
+
? `[用户发送了${count}张图片]`
|
|
347
|
+
: "[用户发送了一张图片]";
|
|
348
|
+
ctxBase.RawBody = "[图片]";
|
|
349
|
+
ctxBase.CommandBody = "";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Handle file attachment.
|
|
354
|
+
if (fileUrl) {
|
|
355
|
+
try {
|
|
356
|
+
const { localPath: localFilePath, effectiveFileName } = await downloadWecomFile(
|
|
357
|
+
fileUrl,
|
|
358
|
+
fileName,
|
|
359
|
+
account.encodingAesKey,
|
|
360
|
+
account.token,
|
|
361
|
+
);
|
|
362
|
+
ctxBase.MediaPaths = [...(ctxBase.MediaPaths || []), localFilePath];
|
|
363
|
+
ctxBase.MediaTypes = [...(ctxBase.MediaTypes || []), guessMimeType(effectiveFileName)];
|
|
364
|
+
logger.info("File attachment prepared", { path: localFilePath, name: effectiveFileName });
|
|
365
|
+
} catch (e) {
|
|
366
|
+
logger.warn("File download failed", { error: e.message });
|
|
367
|
+
// Inform the agent about the file via text.
|
|
368
|
+
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
369
|
+
if (!rawBody.trim()) {
|
|
370
|
+
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
371
|
+
ctxBase.RawBody = label;
|
|
372
|
+
ctxBase.CommandBody = "";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!rawBody.trim() && !ctxBase.Body) {
|
|
376
|
+
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
377
|
+
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
378
|
+
ctxBase.RawBody = label;
|
|
379
|
+
ctxBase.CommandBody = "";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const ctxPayload = core.reply.finalizeInboundContext(ctxBase);
|
|
384
|
+
|
|
385
|
+
// Record session meta
|
|
386
|
+
void core.session
|
|
387
|
+
.recordSessionMetaFromInbound({
|
|
388
|
+
storePath,
|
|
389
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
390
|
+
ctx: ctxPayload,
|
|
391
|
+
})
|
|
392
|
+
.catch((err) => {
|
|
393
|
+
logger.error("WeCom: failed updating session meta", { error: err.message });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const runDispatch = async () => {
|
|
397
|
+
// --- Stream close coordination ---
|
|
398
|
+
// dispatchReplyWithBufferedBlockDispatcher may return before the LLM
|
|
399
|
+
// actually processes the message (e.g. when the session lane is busy and
|
|
400
|
+
// the message is queued). We therefore track two signals:
|
|
401
|
+
// 1. dispatchDone – the await on the dispatcher has resolved.
|
|
402
|
+
// 2. hadDelivery – at least one deliver callback has fired.
|
|
403
|
+
// We only schedule a stream-close timer when BOTH are true, and we
|
|
404
|
+
// reset the timer on every new delivery so the stream stays open while
|
|
405
|
+
// content keeps arriving.
|
|
406
|
+
let dispatchDone = false;
|
|
407
|
+
let hadDelivery = false;
|
|
408
|
+
let closeTimer = null;
|
|
409
|
+
|
|
410
|
+
const scheduleStreamClose = () => {
|
|
411
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
412
|
+
closeTimer = setTimeout(async () => {
|
|
413
|
+
const s = streamManager.getStream(streamId);
|
|
414
|
+
if (s && !s.finished) {
|
|
415
|
+
logger.info("WeCom: finishing stream after dispatch complete", { streamId });
|
|
416
|
+
try {
|
|
417
|
+
await streamManager.finishStream(streamId);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
logger.error("WeCom: failed to finish stream post-dispatch", {
|
|
420
|
+
streamId,
|
|
421
|
+
error: err.message,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
unregisterActiveStream(streamKey, streamId);
|
|
425
|
+
}
|
|
426
|
+
}, 3000); // 3s grace after last delivery
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Dispatch reply with AI processing.
|
|
430
|
+
// Wrap in streamContext so outbound adapters resolve the correct stream.
|
|
431
|
+
await streamContext.run({ streamId, streamKey, agentId: route.agentId, accountId: account.accountId }, async () => {
|
|
432
|
+
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
433
|
+
ctx: ctxPayload,
|
|
434
|
+
cfg: config,
|
|
435
|
+
// Force block streaming for WeCom so incremental content can be emitted
|
|
436
|
+
// during long LLM runs instead of waiting for final completion.
|
|
437
|
+
replyOptions: {
|
|
438
|
+
disableBlockStreaming: false,
|
|
439
|
+
},
|
|
440
|
+
dispatcherOptions: {
|
|
441
|
+
deliver: async (payload, info) => {
|
|
442
|
+
hadDelivery = true;
|
|
443
|
+
|
|
444
|
+
logger.info("Dispatcher deliver called", {
|
|
445
|
+
kind: info.kind,
|
|
446
|
+
hasText: !!(payload.text && payload.text.trim()),
|
|
447
|
+
hasMediaUrl: !!(payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length)),
|
|
448
|
+
textPreview: (payload.text || "").substring(0, 50),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
await deliverWecomReply({
|
|
453
|
+
payload,
|
|
454
|
+
senderId: streamKey,
|
|
455
|
+
streamId,
|
|
456
|
+
agentId: route.agentId,
|
|
457
|
+
});
|
|
458
|
+
} catch (deliverErr) {
|
|
459
|
+
logger.error("WeCom: deliverWecomReply threw, continuing to finalize stream", {
|
|
460
|
+
streamId,
|
|
461
|
+
error: deliverErr.message,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Mark stream meta when main response is done.
|
|
466
|
+
if (streamId && (info.kind === "final" || info.kind === "block")) {
|
|
467
|
+
streamMeta.set(streamId, {
|
|
468
|
+
mainResponseDone: true,
|
|
469
|
+
doneAt: Date.now(),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Schedule / reset stream close timer if dispatch already returned.
|
|
474
|
+
if (streamId && dispatchDone) {
|
|
475
|
+
scheduleStreamClose();
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
onError: async (err, info) => {
|
|
479
|
+
logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
|
|
480
|
+
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Dispatch returned.
|
|
487
|
+
dispatchDone = true;
|
|
488
|
+
|
|
489
|
+
if (streamId) {
|
|
490
|
+
const stream = streamManager.getStream(streamId);
|
|
491
|
+
if (!stream || stream.finished) {
|
|
492
|
+
unregisterActiveStream(streamKey, streamId);
|
|
493
|
+
} else if (hadDelivery) {
|
|
494
|
+
// Normal case: content was already delivered, close after grace period.
|
|
495
|
+
scheduleStreamClose();
|
|
496
|
+
}
|
|
497
|
+
// If !hadDelivery, the message was queued and is not yet processed.
|
|
498
|
+
// The deliver callback will fire later and schedule the close (since
|
|
499
|
+
// dispatchDone is now true). The existing stream GC handles the edge
|
|
500
|
+
// case where no delivery ever arrives.
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
if (highPriorityCommand) {
|
|
505
|
+
logger.info("WeCom: high-priority command bypassing dispatch queue", {
|
|
506
|
+
streamKey,
|
|
507
|
+
streamId,
|
|
508
|
+
command: commandCheck.command,
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
await runDispatch();
|
|
512
|
+
} catch (err) {
|
|
513
|
+
logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
|
|
514
|
+
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
515
|
+
}
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Serialize non-priority dispatches per user/group.
|
|
520
|
+
const prevLock = dispatchLocks.get(streamKey) ?? Promise.resolve();
|
|
521
|
+
const currentDispatch = prevLock.then(runDispatch).catch(async (err) => {
|
|
522
|
+
logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
|
|
523
|
+
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
dispatchLocks.set(streamKey, currentDispatch);
|
|
527
|
+
await currentDispatch;
|
|
528
|
+
if (dispatchLocks.get(streamKey) === currentDispatch) {
|
|
529
|
+
dispatchLocks.delete(streamKey);
|
|
530
|
+
}
|
|
531
|
+
}
|
package/wecom/media.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { WecomCrypto } from "../crypto.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { MEDIA_CACHE_DIR } from "./constants.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Download and decrypt a WeCom encrypted image.
|
|
9
|
+
* @param {string} imageUrl - Encrypted image URL from WeCom
|
|
10
|
+
* @param {string} encodingAesKey - AES key
|
|
11
|
+
* @param {string} token - Token
|
|
12
|
+
* @returns {Promise<{ localPath: string, mimeType: string }>} Local path and mime type
|
|
13
|
+
*/
|
|
14
|
+
export async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
|
|
15
|
+
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
16
|
+
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
logger.info("Downloading encrypted image", { url: imageUrl.substring(0, 80) });
|
|
20
|
+
const response = await fetch(imageUrl);
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new Error(`Failed to download image: ${response.status}`);
|
|
23
|
+
}
|
|
24
|
+
const encryptedBuffer = Buffer.from(await response.arrayBuffer());
|
|
25
|
+
logger.debug("Downloaded encrypted image", { size: encryptedBuffer.length });
|
|
26
|
+
|
|
27
|
+
const wecomCrypto = new WecomCrypto(token, encodingAesKey);
|
|
28
|
+
const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
|
|
29
|
+
|
|
30
|
+
// Detect image type via magic bytes.
|
|
31
|
+
let ext = "jpg";
|
|
32
|
+
if (decryptedBuffer[0] === 0x89 && decryptedBuffer[1] === 0x50) {
|
|
33
|
+
ext = "png";
|
|
34
|
+
} else if (decryptedBuffer[0] === 0x47 && decryptedBuffer[1] === 0x49) {
|
|
35
|
+
ext = "gif";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const filename = `wecom_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
|
|
39
|
+
const localPath = join(MEDIA_CACHE_DIR, filename);
|
|
40
|
+
writeFileSync(localPath, decryptedBuffer);
|
|
41
|
+
|
|
42
|
+
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
43
|
+
logger.info("Image decrypted and saved", { path: localPath, size: decryptedBuffer.length, mimeType });
|
|
44
|
+
return { localPath, mimeType };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Download and decrypt a file from WeCom.
|
|
49
|
+
* Note: WeCom encrypts ALL media files (not just images) with AES-256-CBC.
|
|
50
|
+
* @param {string} fileUrl - File download URL
|
|
51
|
+
* @param {string} fileName - Original file name
|
|
52
|
+
* @param {string} encodingAesKey - AES key for decryption
|
|
53
|
+
* @param {string} token - Token for decryption
|
|
54
|
+
* @returns {Promise<{ localPath: string, effectiveFileName: string }>} Local path and resolved filename
|
|
55
|
+
*/
|
|
56
|
+
export async function downloadWecomFile(fileUrl, fileName, encodingAesKey, token) {
|
|
57
|
+
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
58
|
+
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.info("Downloading encrypted file", { url: fileUrl.substring(0, 80), name: fileName });
|
|
62
|
+
const response = await fetch(fileUrl);
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Failed to download file: ${response.status}`);
|
|
65
|
+
}
|
|
66
|
+
const encryptedBuffer = Buffer.from(await response.arrayBuffer());
|
|
67
|
+
|
|
68
|
+
// Try to extract filename from Content-Disposition header if not provided
|
|
69
|
+
let effectiveFileName = fileName;
|
|
70
|
+
if (!effectiveFileName) {
|
|
71
|
+
const contentDisposition = response.headers.get("content-disposition");
|
|
72
|
+
if (contentDisposition) {
|
|
73
|
+
// Match: filename="xxx.pdf" or filename*=UTF-8''xxx.pdf
|
|
74
|
+
const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)["']?/i);
|
|
75
|
+
if (filenameMatch && filenameMatch[1]) {
|
|
76
|
+
effectiveFileName = decodeURIComponent(filenameMatch[1]);
|
|
77
|
+
logger.info("Extracted filename from Content-Disposition", { name: effectiveFileName });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Decrypt the file (WeCom encrypts all media the same way as images)
|
|
83
|
+
const wecomCrypto = new WecomCrypto(token, encodingAesKey);
|
|
84
|
+
const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
|
|
85
|
+
|
|
86
|
+
const safeName = (effectiveFileName || `file_${Date.now()}`).replace(/[/\\:*?"<>|]/g, "_");
|
|
87
|
+
const localPath = join(MEDIA_CACHE_DIR, `${Date.now()}_${safeName}`);
|
|
88
|
+
writeFileSync(localPath, decryptedBuffer);
|
|
89
|
+
|
|
90
|
+
logger.info("File decrypted and saved", { path: localPath, size: decryptedBuffer.length });
|
|
91
|
+
return { localPath, effectiveFileName: effectiveFileName || fileName };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Guess MIME type from file extension.
|
|
96
|
+
*/
|
|
97
|
+
export function guessMimeType(fileName) {
|
|
98
|
+
const ext = (fileName || "").split(".").pop()?.toLowerCase() || "";
|
|
99
|
+
const mimeMap = {
|
|
100
|
+
pdf: "application/pdf",
|
|
101
|
+
doc: "application/msword",
|
|
102
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
103
|
+
xls: "application/vnd.ms-excel",
|
|
104
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
105
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
106
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
107
|
+
txt: "text/plain",
|
|
108
|
+
csv: "text/csv",
|
|
109
|
+
zip: "application/zip",
|
|
110
|
+
png: "image/png",
|
|
111
|
+
jpg: "image/jpeg",
|
|
112
|
+
jpeg: "image/jpeg",
|
|
113
|
+
gif: "image/gif",
|
|
114
|
+
mp4: "video/mp4",
|
|
115
|
+
mp3: "audio/mpeg",
|
|
116
|
+
};
|
|
117
|
+
return mimeMap[ext] || "application/octet-stream";
|
|
118
|
+
}
|