@sunnoy/wecom 1.2.0 → 1.3.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 +454 -147
- package/index.js +16 -1602
- package/package.json +5 -2
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +433 -0
- package/wecom/allow-from.js +58 -0
- package/wecom/channel-plugin.js +638 -0
- package/wecom/commands.js +85 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +519 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +175 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +82 -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
package/index.js
CHANGED
|
@@ -1,1611 +1,25 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import * as crypto from "node:crypto";
|
|
3
|
-
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { WecomCrypto } from "./crypto.js";
|
|
6
|
-
import {
|
|
7
|
-
generateAgentId,
|
|
8
|
-
getDynamicAgentConfig,
|
|
9
|
-
shouldUseDynamicAgent,
|
|
10
|
-
shouldTriggerGroupResponse,
|
|
11
|
-
extractGroupMessageContent,
|
|
12
|
-
} from "./dynamic-agent.js";
|
|
13
1
|
import { logger } from "./logger.js";
|
|
14
2
|
import { streamManager } from "./stream-manager.js";
|
|
15
|
-
import {
|
|
3
|
+
import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
|
|
4
|
+
import { wecomHttpHandler } from "./wecom/http-handler.js";
|
|
5
|
+
import { responseUrls, setOpenclawConfig, setRuntime, streamMeta } from "./wecom/state.js";
|
|
16
6
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// =============================================================================
|
|
26
|
-
// Command allowlist configuration
|
|
27
|
-
// =============================================================================
|
|
28
|
-
|
|
29
|
-
// Slash commands that are allowed by default.
|
|
30
|
-
const DEFAULT_COMMAND_ALLOWLIST = ["/new", "/compact", "/help", "/status"];
|
|
31
|
-
|
|
32
|
-
// Default message shown when a command is blocked.
|
|
33
|
-
const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
|
|
34
|
-
|
|
35
|
-
支持的命令:
|
|
36
|
-
• **/new** - 新建会话
|
|
37
|
-
• **/compact** - 压缩会话(保留上下文摘要)
|
|
38
|
-
• **/help** - 查看帮助
|
|
39
|
-
• **/status** - 查看状态`;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Read command allowlist settings from config.
|
|
43
|
-
*/
|
|
44
|
-
function getCommandConfig(config) {
|
|
45
|
-
const wecom = config?.channels?.wecom || {};
|
|
46
|
-
const commands = wecom.commands || {};
|
|
47
|
-
return {
|
|
48
|
-
allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
|
|
49
|
-
blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
|
|
50
|
-
enabled: commands.enabled !== false,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Check whether a slash command is allowed.
|
|
56
|
-
* @param {string} message - User message
|
|
57
|
-
* @param {Object} config - OpenClaw config
|
|
58
|
-
* @returns {{ isCommand: boolean, allowed: boolean, command: string | null }}
|
|
59
|
-
*/
|
|
60
|
-
function checkCommandAllowlist(message, config) {
|
|
61
|
-
const trimmed = message.trim();
|
|
62
|
-
|
|
63
|
-
// Not a slash command.
|
|
64
|
-
if (!trimmed.startsWith("/")) {
|
|
65
|
-
return { isCommand: false, allowed: true, command: null };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Use the first token as the command.
|
|
69
|
-
const command = trimmed.split(/\s+/)[0].toLowerCase();
|
|
70
|
-
|
|
71
|
-
const cmdConfig = getCommandConfig(config);
|
|
72
|
-
|
|
73
|
-
// Allow all commands when command gating is disabled.
|
|
74
|
-
if (!cmdConfig.enabled) {
|
|
75
|
-
return { isCommand: true, allowed: true, command };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Require explicit allowlist match.
|
|
79
|
-
const allowed = cmdConfig.allowlist.some((cmd) => cmd.toLowerCase() === command);
|
|
80
|
-
|
|
81
|
-
return { isCommand: true, allowed, command };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Read admin user list from channels.wecom.adminUsers.
|
|
86
|
-
* Admins bypass the command allowlist and skip dynamic agent routing.
|
|
87
|
-
*/
|
|
88
|
-
function getWecomAdminUsers(config) {
|
|
89
|
-
const raw = config?.channels?.wecom?.adminUsers;
|
|
90
|
-
if (!Array.isArray(raw)) {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
return raw
|
|
94
|
-
.map((u) => String(u ?? "").trim().toLowerCase())
|
|
95
|
-
.filter(Boolean);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isWecomAdmin(userId, config) {
|
|
99
|
-
if (!userId) {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
const admins = getWecomAdminUsers(config);
|
|
103
|
-
return admins.length > 0 && admins.includes(String(userId).trim().toLowerCase());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Download and decrypt a WeCom encrypted image.
|
|
108
|
-
* @param {string} imageUrl - Encrypted image URL from WeCom
|
|
109
|
-
* @param {string} encodingAesKey - AES key
|
|
110
|
-
* @param {string} token - Token
|
|
111
|
-
* @returns {Promise<string>} Local path to decrypted image
|
|
112
|
-
*/
|
|
113
|
-
async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
|
|
114
|
-
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
115
|
-
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
logger.info("Downloading encrypted image", { url: imageUrl.substring(0, 80) });
|
|
119
|
-
const response = await fetch(imageUrl);
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
throw new Error(`Failed to download image: ${response.status}`);
|
|
122
|
-
}
|
|
123
|
-
const encryptedBuffer = Buffer.from(await response.arrayBuffer());
|
|
124
|
-
logger.debug("Downloaded encrypted image", { size: encryptedBuffer.length });
|
|
125
|
-
|
|
126
|
-
const wecomCrypto = new WecomCrypto(token, encodingAesKey);
|
|
127
|
-
const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
|
|
128
|
-
|
|
129
|
-
// Detect image type via magic bytes.
|
|
130
|
-
let ext = "jpg";
|
|
131
|
-
if (decryptedBuffer[0] === 0x89 && decryptedBuffer[1] === 0x50) {
|
|
132
|
-
ext = "png";
|
|
133
|
-
} else if (decryptedBuffer[0] === 0x47 && decryptedBuffer[1] === 0x49) {
|
|
134
|
-
ext = "gif";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const filename = `wecom_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
|
|
138
|
-
const localPath = join(MEDIA_CACHE_DIR, filename);
|
|
139
|
-
writeFileSync(localPath, decryptedBuffer);
|
|
140
|
-
|
|
141
|
-
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
142
|
-
logger.info("Image decrypted and saved", { path: localPath, size: decryptedBuffer.length, mimeType });
|
|
143
|
-
return { localPath, mimeType };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Download a file from WeCom (not encrypted, unlike images).
|
|
148
|
-
* @param {string} fileUrl - File download URL
|
|
149
|
-
* @param {string} fileName - Original file name
|
|
150
|
-
* @returns {Promise<string>} Local path to downloaded file
|
|
151
|
-
*/
|
|
152
|
-
async function downloadWecomFile(fileUrl, fileName) {
|
|
153
|
-
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
154
|
-
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
logger.info("Downloading file", { url: fileUrl.substring(0, 80), name: fileName });
|
|
158
|
-
const response = await fetch(fileUrl);
|
|
159
|
-
if (!response.ok) {
|
|
160
|
-
throw new Error(`Failed to download file: ${response.status}`);
|
|
161
|
-
}
|
|
162
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
163
|
-
|
|
164
|
-
const safeName = (fileName || `file_${Date.now()}`).replace(/[/\\:*?"<>|]/g, "_");
|
|
165
|
-
const localPath = join(MEDIA_CACHE_DIR, `${Date.now()}_${safeName}`);
|
|
166
|
-
writeFileSync(localPath, buffer);
|
|
167
|
-
|
|
168
|
-
logger.info("File downloaded and saved", { path: localPath, size: buffer.length });
|
|
169
|
-
return localPath;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Guess MIME type from file extension.
|
|
174
|
-
*/
|
|
175
|
-
function guessMimeType(fileName) {
|
|
176
|
-
const ext = (fileName || "").split(".").pop()?.toLowerCase() || "";
|
|
177
|
-
const mimeMap = {
|
|
178
|
-
pdf: "application/pdf",
|
|
179
|
-
doc: "application/msword",
|
|
180
|
-
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
181
|
-
xls: "application/vnd.ms-excel",
|
|
182
|
-
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
183
|
-
ppt: "application/vnd.ms-powerpoint",
|
|
184
|
-
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
185
|
-
txt: "text/plain",
|
|
186
|
-
csv: "text/csv",
|
|
187
|
-
zip: "application/zip",
|
|
188
|
-
png: "image/png",
|
|
189
|
-
jpg: "image/jpeg",
|
|
190
|
-
jpeg: "image/jpeg",
|
|
191
|
-
gif: "image/gif",
|
|
192
|
-
mp4: "video/mp4",
|
|
193
|
-
mp3: "audio/mpeg",
|
|
194
|
-
};
|
|
195
|
-
return mimeMap[ext] || "application/octet-stream";
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Runtime state (module-level singleton)
|
|
199
|
-
let _runtime = null;
|
|
200
|
-
let _openclawConfig = null;
|
|
201
|
-
const ensuredDynamicAgentIds = new Set();
|
|
202
|
-
let ensureDynamicAgentWriteQueue = Promise.resolve();
|
|
203
|
-
|
|
204
|
-
// Per-user dispatch serialization lock.
|
|
205
|
-
const dispatchLocks = new Map();
|
|
206
|
-
|
|
207
|
-
// Per-user message debounce buffer.
|
|
208
|
-
// Collects messages arriving within DEBOUNCE_MS into a single dispatch.
|
|
209
|
-
const DEBOUNCE_MS = 2000;
|
|
210
|
-
const messageBuffers = new Map();
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Handle stream error: replace placeholder with error message, finish stream, unregister.
|
|
214
|
-
*/
|
|
215
|
-
async function handleStreamError(streamId, streamKey, errorMessage) {
|
|
216
|
-
if (!streamId) return;
|
|
217
|
-
const stream = streamManager.getStream(streamId);
|
|
218
|
-
if (stream && !stream.finished) {
|
|
219
|
-
if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
|
|
220
|
-
streamManager.replaceIfPlaceholder(streamId, errorMessage, THINKING_PLACEHOLDER);
|
|
221
|
-
}
|
|
222
|
-
await streamManager.finishStream(streamId);
|
|
223
|
-
}
|
|
224
|
-
unregisterActiveStream(streamKey, streamId);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Set the plugin runtime (called during plugin registration)
|
|
229
|
-
*/
|
|
230
|
-
function setRuntime(runtime) {
|
|
231
|
-
_runtime = runtime;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function getRuntime() {
|
|
235
|
-
if (!_runtime) {
|
|
236
|
-
throw new Error("[wecom] Runtime not initialized");
|
|
237
|
-
}
|
|
238
|
-
return _runtime;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
242
|
-
const normalizedId = String(agentId || "")
|
|
243
|
-
.trim()
|
|
244
|
-
.toLowerCase();
|
|
245
|
-
if (!normalizedId) {
|
|
246
|
-
return false;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!cfg.agents || typeof cfg.agents !== "object") {
|
|
250
|
-
cfg.agents = {};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const currentList = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
|
|
254
|
-
const existingIds = new Set(
|
|
255
|
-
currentList
|
|
256
|
-
.map((entry) => (entry && typeof entry.id === "string" ? entry.id.trim().toLowerCase() : ""))
|
|
257
|
-
.filter(Boolean),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
let changed = false;
|
|
261
|
-
const nextList = [...currentList];
|
|
262
|
-
|
|
263
|
-
// Keep "main" as the explicit default when creating agents.list for the first time.
|
|
264
|
-
if (nextList.length === 0) {
|
|
265
|
-
nextList.push({ id: "main" });
|
|
266
|
-
existingIds.add("main");
|
|
267
|
-
changed = true;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (!existingIds.has(normalizedId)) {
|
|
271
|
-
nextList.push({ id: normalizedId });
|
|
272
|
-
changed = true;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (changed) {
|
|
276
|
-
cfg.agents.list = nextList;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return changed;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
async function ensureDynamicAgentListed(agentId) {
|
|
283
|
-
const normalizedId = String(agentId || "")
|
|
284
|
-
.trim()
|
|
285
|
-
.toLowerCase();
|
|
286
|
-
if (!normalizedId) {
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
if (ensuredDynamicAgentIds.has(normalizedId)) {
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const runtime = getRuntime();
|
|
294
|
-
const configRuntime = runtime?.config;
|
|
295
|
-
if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) {
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
|
|
300
|
-
.then(async () => {
|
|
301
|
-
if (ensuredDynamicAgentIds.has(normalizedId)) {
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const latestConfig = configRuntime.loadConfig();
|
|
306
|
-
if (!latestConfig || typeof latestConfig !== "object") {
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
|
|
311
|
-
if (changed) {
|
|
312
|
-
await configRuntime.writeConfigFile(latestConfig);
|
|
313
|
-
logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Keep runtime in-memory config aligned to avoid stale reads in this process.
|
|
317
|
-
if (_openclawConfig && typeof _openclawConfig === "object") {
|
|
318
|
-
upsertAgentIdOnlyEntry(_openclawConfig, normalizedId);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
ensuredDynamicAgentIds.add(normalizedId);
|
|
322
|
-
})
|
|
323
|
-
.catch((err) => {
|
|
324
|
-
logger.warn("WeCom: failed to sync dynamic agent into agents.list", {
|
|
325
|
-
agentId: normalizedId,
|
|
326
|
-
error: err?.message || String(err),
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
await ensureDynamicAgentWriteQueue;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Webhook targets registry (similar to Google Chat)
|
|
334
|
-
const webhookTargets = new Map();
|
|
335
|
-
|
|
336
|
-
// Track active stream for each user, so outbound messages (like reset confirmation)
|
|
337
|
-
// can be added to the correct stream instead of using response_url
|
|
338
|
-
const activeStreams = new Map();
|
|
339
|
-
const activeStreamHistory = new Map();
|
|
340
|
-
|
|
341
|
-
// AsyncLocalStorage for propagating the correct streamId through the async
|
|
342
|
-
// processing chain. Prevents outbound adapter from resolving the wrong stream
|
|
343
|
-
// when multiple messages from the same user are in flight.
|
|
344
|
-
const streamContext = new AsyncLocalStorage();
|
|
345
|
-
|
|
346
|
-
function getMessageStreamKey(message) {
|
|
347
|
-
if (!message || typeof message !== "object") {
|
|
348
|
-
return "";
|
|
349
|
-
}
|
|
350
|
-
const chatType = message.chatType || "single";
|
|
351
|
-
const chatId = message.chatId || "";
|
|
352
|
-
if (chatType === "group" && chatId) {
|
|
353
|
-
return chatId;
|
|
354
|
-
}
|
|
355
|
-
return message.fromUser || "";
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function registerActiveStream(streamKey, streamId) {
|
|
359
|
-
if (!streamKey || !streamId) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const history = activeStreamHistory.get(streamKey) ?? [];
|
|
364
|
-
const deduped = history.filter((id) => id !== streamId);
|
|
365
|
-
deduped.push(streamId);
|
|
366
|
-
activeStreamHistory.set(streamKey, deduped);
|
|
367
|
-
activeStreams.set(streamKey, streamId);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function unregisterActiveStream(streamKey, streamId) {
|
|
371
|
-
if (!streamKey || !streamId) {
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const history = activeStreamHistory.get(streamKey);
|
|
376
|
-
if (!history || history.length === 0) {
|
|
377
|
-
if (activeStreams.get(streamKey) === streamId) {
|
|
378
|
-
activeStreams.delete(streamKey);
|
|
379
|
-
}
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const remaining = history.filter((id) => id !== streamId);
|
|
384
|
-
if (remaining.length === 0) {
|
|
385
|
-
activeStreamHistory.delete(streamKey);
|
|
386
|
-
activeStreams.delete(streamKey);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
activeStreamHistory.set(streamKey, remaining);
|
|
391
|
-
activeStreams.set(streamKey, remaining[remaining.length - 1]);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function resolveActiveStream(streamKey) {
|
|
395
|
-
if (!streamKey) {
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const history = activeStreamHistory.get(streamKey);
|
|
400
|
-
if (!history || history.length === 0) {
|
|
401
|
-
activeStreams.delete(streamKey);
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const remaining = history.filter((id) => streamManager.hasStream(id));
|
|
406
|
-
if (remaining.length === 0) {
|
|
407
|
-
activeStreamHistory.delete(streamKey);
|
|
408
|
-
activeStreams.delete(streamKey);
|
|
409
|
-
return null;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
activeStreamHistory.set(streamKey, remaining);
|
|
413
|
-
const latest = remaining[remaining.length - 1];
|
|
414
|
-
activeStreams.set(streamKey, latest);
|
|
415
|
-
return latest;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function normalizeWecomAllowFromEntry(raw) {
|
|
419
|
-
const trimmed = String(raw ?? "").trim();
|
|
420
|
-
if (!trimmed) {
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
if (trimmed === "*") {
|
|
424
|
-
return "*";
|
|
425
|
-
}
|
|
426
|
-
return trimmed
|
|
427
|
-
.replace(/^(wecom|wework):/i, "")
|
|
428
|
-
.replace(/^user:/i, "")
|
|
429
|
-
.toLowerCase();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function resolveWecomAllowFrom(cfg, accountId) {
|
|
433
|
-
const wecom = cfg?.channels?.wecom;
|
|
434
|
-
if (!wecom) {
|
|
435
|
-
return [];
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID)
|
|
439
|
-
.trim()
|
|
440
|
-
.toLowerCase();
|
|
441
|
-
const accounts = wecom.accounts;
|
|
442
|
-
const account =
|
|
443
|
-
accounts && typeof accounts === "object"
|
|
444
|
-
? (accounts[accountId] ??
|
|
445
|
-
accounts[
|
|
446
|
-
Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId) ?? ""
|
|
447
|
-
])
|
|
448
|
-
: undefined;
|
|
449
|
-
|
|
450
|
-
const allowFromRaw =
|
|
451
|
-
account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
|
|
452
|
-
|
|
453
|
-
if (!Array.isArray(allowFromRaw)) {
|
|
454
|
-
return [];
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
return allowFromRaw.map(normalizeWecomAllowFromEntry).filter((entry) => Boolean(entry));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
|
|
461
|
-
const sender = String(senderId ?? "")
|
|
462
|
-
.trim()
|
|
463
|
-
.toLowerCase();
|
|
464
|
-
if (!sender) {
|
|
465
|
-
return false;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const allowFrom = resolveWecomAllowFrom(cfg, accountId);
|
|
469
|
-
if (allowFrom.includes("*") || allowFrom.length === 0) {
|
|
470
|
-
return true;
|
|
471
|
-
}
|
|
472
|
-
return allowFrom.includes(sender);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function normalizeWebhookPath(raw) {
|
|
476
|
-
const trimmed = (raw || "").trim();
|
|
477
|
-
if (!trimmed) {
|
|
478
|
-
return "/";
|
|
479
|
-
}
|
|
480
|
-
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
481
|
-
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
|
482
|
-
return withSlash.slice(0, -1);
|
|
483
|
-
}
|
|
484
|
-
return withSlash;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function registerWebhookTarget(target) {
|
|
488
|
-
const key = normalizeWebhookPath(target.path);
|
|
489
|
-
const entry = { ...target, path: key };
|
|
490
|
-
const existing = webhookTargets.get(key) ?? [];
|
|
491
|
-
webhookTargets.set(key, [...existing, entry]);
|
|
492
|
-
return () => {
|
|
493
|
-
const updated = (webhookTargets.get(key) ?? []).filter((e) => e !== entry);
|
|
494
|
-
if (updated.length > 0) {
|
|
495
|
-
webhookTargets.set(key, updated);
|
|
496
|
-
} else {
|
|
497
|
-
webhookTargets.delete(key);
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// =============================================================================
|
|
503
|
-
// Channel Plugin Definition
|
|
504
|
-
// =============================================================================
|
|
505
|
-
|
|
506
|
-
const wecomChannelPlugin = {
|
|
507
|
-
id: "wecom",
|
|
508
|
-
meta: {
|
|
509
|
-
id: "wecom",
|
|
510
|
-
label: "Enterprise WeChat",
|
|
511
|
-
selectionLabel: "Enterprise WeChat (AI Bot)",
|
|
512
|
-
docsPath: "/channels/wecom",
|
|
513
|
-
blurb: "Enterprise WeChat AI Bot channel plugin.",
|
|
514
|
-
aliases: ["wecom", "wework"],
|
|
515
|
-
},
|
|
516
|
-
capabilities: {
|
|
517
|
-
chatTypes: ["direct", "group"],
|
|
518
|
-
reactions: false,
|
|
519
|
-
threads: false,
|
|
520
|
-
media: true,
|
|
521
|
-
nativeCommands: false,
|
|
522
|
-
blockStreaming: true, // WeCom AI Bot requires stream-style responses.
|
|
523
|
-
},
|
|
524
|
-
reload: { configPrefixes: ["channels.wecom"] },
|
|
525
|
-
configSchema: {
|
|
526
|
-
schema: {
|
|
527
|
-
$schema: "http://json-schema.org/draft-07/schema#",
|
|
528
|
-
type: "object",
|
|
529
|
-
additionalProperties: false,
|
|
530
|
-
properties: {
|
|
531
|
-
enabled: {
|
|
532
|
-
type: "boolean",
|
|
533
|
-
description: "Enable WeCom channel",
|
|
534
|
-
default: true,
|
|
535
|
-
},
|
|
536
|
-
token: {
|
|
537
|
-
type: "string",
|
|
538
|
-
description: "WeCom bot token from admin console",
|
|
539
|
-
},
|
|
540
|
-
encodingAesKey: {
|
|
541
|
-
type: "string",
|
|
542
|
-
description: "WeCom message encryption key (43 characters)",
|
|
543
|
-
minLength: 43,
|
|
544
|
-
maxLength: 43,
|
|
545
|
-
},
|
|
546
|
-
commands: {
|
|
547
|
-
type: "object",
|
|
548
|
-
description: "Command whitelist configuration",
|
|
549
|
-
additionalProperties: false,
|
|
550
|
-
properties: {
|
|
551
|
-
enabled: {
|
|
552
|
-
type: "boolean",
|
|
553
|
-
description: "Enable command whitelist filtering",
|
|
554
|
-
default: true,
|
|
555
|
-
},
|
|
556
|
-
allowlist: {
|
|
557
|
-
type: "array",
|
|
558
|
-
description: "Allowed commands (e.g., /new, /status, /help)",
|
|
559
|
-
items: {
|
|
560
|
-
type: "string",
|
|
561
|
-
},
|
|
562
|
-
default: ["/new", "/status", "/help", "/compact"],
|
|
563
|
-
},
|
|
564
|
-
},
|
|
565
|
-
},
|
|
566
|
-
dynamicAgents: {
|
|
567
|
-
type: "object",
|
|
568
|
-
description: "Dynamic agent routing configuration",
|
|
569
|
-
additionalProperties: false,
|
|
570
|
-
properties: {
|
|
571
|
-
enabled: {
|
|
572
|
-
type: "boolean",
|
|
573
|
-
description: "Enable per-user/per-group agent isolation",
|
|
574
|
-
default: true,
|
|
575
|
-
},
|
|
576
|
-
},
|
|
577
|
-
},
|
|
578
|
-
dm: {
|
|
579
|
-
type: "object",
|
|
580
|
-
description: "Direct message (private chat) configuration",
|
|
581
|
-
additionalProperties: false,
|
|
582
|
-
properties: {
|
|
583
|
-
createAgentOnFirstMessage: {
|
|
584
|
-
type: "boolean",
|
|
585
|
-
description: "Create separate agent for each user",
|
|
586
|
-
default: true,
|
|
587
|
-
},
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
groupChat: {
|
|
591
|
-
type: "object",
|
|
592
|
-
description: "Group chat configuration",
|
|
593
|
-
additionalProperties: false,
|
|
594
|
-
properties: {
|
|
595
|
-
enabled: {
|
|
596
|
-
type: "boolean",
|
|
597
|
-
description: "Enable group chat support",
|
|
598
|
-
default: true,
|
|
599
|
-
},
|
|
600
|
-
requireMention: {
|
|
601
|
-
type: "boolean",
|
|
602
|
-
description: "Only respond when @mentioned in groups",
|
|
603
|
-
default: true,
|
|
604
|
-
},
|
|
605
|
-
},
|
|
606
|
-
},
|
|
607
|
-
adminUsers: {
|
|
608
|
-
type: "array",
|
|
609
|
-
description: "Admin users who bypass command allowlist and dynamic agent routing",
|
|
610
|
-
items: { type: "string" },
|
|
611
|
-
default: [],
|
|
612
|
-
},
|
|
613
|
-
},
|
|
614
|
-
},
|
|
615
|
-
uiHints: {
|
|
616
|
-
token: {
|
|
617
|
-
sensitive: true,
|
|
618
|
-
label: "Bot Token",
|
|
619
|
-
},
|
|
620
|
-
encodingAesKey: {
|
|
621
|
-
sensitive: true,
|
|
622
|
-
label: "Encoding AES Key",
|
|
623
|
-
help: "43-character encryption key from WeCom admin console",
|
|
624
|
-
},
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
config: {
|
|
628
|
-
listAccountIds: (cfg) => {
|
|
629
|
-
const wecom = cfg?.channels?.wecom;
|
|
630
|
-
if (!wecom || !wecom.enabled) {
|
|
631
|
-
return [];
|
|
632
|
-
}
|
|
633
|
-
return [DEFAULT_ACCOUNT_ID];
|
|
634
|
-
},
|
|
635
|
-
resolveAccount: (cfg, accountId) => {
|
|
636
|
-
const wecom = cfg?.channels?.wecom;
|
|
637
|
-
if (!wecom) {
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
return {
|
|
641
|
-
id: accountId || DEFAULT_ACCOUNT_ID,
|
|
642
|
-
accountId: accountId || DEFAULT_ACCOUNT_ID,
|
|
643
|
-
enabled: wecom.enabled !== false,
|
|
644
|
-
token: wecom.token || "",
|
|
645
|
-
encodingAesKey: wecom.encodingAesKey || "",
|
|
646
|
-
webhookPath: wecom.webhookPath || "/webhooks/wecom",
|
|
647
|
-
config: wecom,
|
|
648
|
-
};
|
|
649
|
-
},
|
|
650
|
-
defaultAccountId: (cfg) => {
|
|
651
|
-
const wecom = cfg?.channels?.wecom;
|
|
652
|
-
if (!wecom || !wecom.enabled) {
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
return DEFAULT_ACCOUNT_ID;
|
|
656
|
-
},
|
|
657
|
-
setAccountEnabled: ({ cfg, accountId: _accountId, enabled }) => {
|
|
658
|
-
if (!cfg.channels) {
|
|
659
|
-
cfg.channels = {};
|
|
660
|
-
}
|
|
661
|
-
if (!cfg.channels.wecom) {
|
|
662
|
-
cfg.channels.wecom = {};
|
|
663
|
-
}
|
|
664
|
-
cfg.channels.wecom.enabled = enabled;
|
|
665
|
-
return cfg;
|
|
666
|
-
},
|
|
667
|
-
deleteAccount: ({ cfg, accountId: _accountId }) => {
|
|
668
|
-
if (cfg.channels?.wecom) {
|
|
669
|
-
delete cfg.channels.wecom;
|
|
670
|
-
}
|
|
671
|
-
return cfg;
|
|
672
|
-
},
|
|
673
|
-
},
|
|
674
|
-
directory: {
|
|
675
|
-
self: async () => null,
|
|
676
|
-
listPeers: async () => [],
|
|
677
|
-
listGroups: async () => [],
|
|
678
|
-
},
|
|
679
|
-
// Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
|
|
680
|
-
outbound: {
|
|
681
|
-
sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
|
|
682
|
-
// `to` format: "wecom:userid" or "userid".
|
|
683
|
-
const userId = to.replace(/^wecom:/, "");
|
|
684
|
-
|
|
685
|
-
// Prefer stream from async context (correct for concurrent processing).
|
|
686
|
-
const ctx = streamContext.getStore();
|
|
687
|
-
const streamId = ctx?.streamId ?? resolveActiveStream(userId);
|
|
688
|
-
|
|
689
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
690
|
-
logger.debug("Appending outbound text to stream", {
|
|
691
|
-
userId,
|
|
692
|
-
streamId,
|
|
693
|
-
source: ctx ? "asyncContext" : "activeStreams",
|
|
694
|
-
text: text.substring(0, 30),
|
|
695
|
-
});
|
|
696
|
-
// Replace placeholder or append content.
|
|
697
|
-
streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
|
|
698
|
-
|
|
699
|
-
return {
|
|
700
|
-
channel: "wecom",
|
|
701
|
-
messageId: `msg_stream_${Date.now()}`,
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// No active stream means nothing can be delivered right now.
|
|
706
|
-
logger.warn("WeCom outbound: no active stream for user", { userId });
|
|
707
|
-
|
|
708
|
-
return {
|
|
709
|
-
channel: "wecom",
|
|
710
|
-
messageId: `fake_${Date.now()}`,
|
|
711
|
-
};
|
|
712
|
-
},
|
|
713
|
-
sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
|
|
714
|
-
const userId = to.replace(/^wecom:/, "");
|
|
715
|
-
|
|
716
|
-
// Prefer stream from async context (correct for concurrent processing).
|
|
717
|
-
const ctx = streamContext.getStore();
|
|
718
|
-
const streamId = ctx?.streamId ?? resolveActiveStream(userId);
|
|
719
|
-
|
|
720
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
721
|
-
// Check if mediaUrl is a local path (sandbox: prefix or absolute path)
|
|
722
|
-
const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
|
|
723
|
-
|
|
724
|
-
if (isLocalPath) {
|
|
725
|
-
// Convert sandbox: URLs to absolute paths.
|
|
726
|
-
// sandbox:///tmp/a -> /tmp/a, sandbox://tmp/a -> /tmp/a, sandbox:/tmp/a -> /tmp/a
|
|
727
|
-
let absolutePath = mediaUrl;
|
|
728
|
-
if (absolutePath.startsWith("sandbox:")) {
|
|
729
|
-
absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
|
|
730
|
-
// Ensure the result is an absolute path.
|
|
731
|
-
if (!absolutePath.startsWith("/")) {
|
|
732
|
-
absolutePath = "/" + absolutePath;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
logger.debug("Queueing local image for stream", {
|
|
737
|
-
userId,
|
|
738
|
-
streamId,
|
|
739
|
-
mediaUrl,
|
|
740
|
-
absolutePath,
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
// Queue the image for processing when stream finishes
|
|
744
|
-
const queued = streamManager.queueImage(streamId, absolutePath);
|
|
745
|
-
|
|
746
|
-
if (queued) {
|
|
747
|
-
// Append text content to stream (without markdown image)
|
|
748
|
-
if (text) {
|
|
749
|
-
streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Append placeholder indicating image will follow
|
|
753
|
-
const imagePlaceholder = "\n\n[图片]";
|
|
754
|
-
streamManager.appendStream(streamId, imagePlaceholder);
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
channel: "wecom",
|
|
758
|
-
messageId: `msg_stream_img_${Date.now()}`,
|
|
759
|
-
};
|
|
760
|
-
} else {
|
|
761
|
-
logger.warn("Failed to queue image, falling back to markdown", {
|
|
762
|
-
userId,
|
|
763
|
-
streamId,
|
|
764
|
-
mediaUrl,
|
|
765
|
-
});
|
|
766
|
-
// Fallback to old behavior
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
|
|
771
|
-
const content = text ? `${text}\n\n` : ``;
|
|
772
|
-
logger.debug("Appending outbound media to stream (markdown)", {
|
|
773
|
-
userId,
|
|
774
|
-
streamId,
|
|
775
|
-
mediaUrl,
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
// Replace placeholder or append media markdown to the current stream content.
|
|
779
|
-
streamManager.replaceIfPlaceholder(streamId, content, THINKING_PLACEHOLDER);
|
|
780
|
-
|
|
781
|
-
return {
|
|
782
|
-
channel: "wecom",
|
|
783
|
-
messageId: `msg_stream_${Date.now()}`,
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
logger.warn("WeCom outbound sendMedia: no active stream", { userId });
|
|
788
|
-
|
|
789
|
-
return {
|
|
790
|
-
channel: "wecom",
|
|
791
|
-
messageId: `fake_${Date.now()}`,
|
|
792
|
-
};
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
gateway: {
|
|
796
|
-
startAccount: async (ctx) => {
|
|
797
|
-
const account = ctx.account;
|
|
798
|
-
logger.info("WeCom gateway starting", {
|
|
799
|
-
accountId: account.accountId,
|
|
800
|
-
webhookPath: account.webhookPath,
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
const unregister = registerWebhookTarget({
|
|
804
|
-
path: account.webhookPath || "/webhooks/wecom",
|
|
805
|
-
account,
|
|
806
|
-
config: ctx.cfg,
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
return {
|
|
810
|
-
shutdown: async () => {
|
|
811
|
-
logger.info("WeCom gateway shutting down");
|
|
812
|
-
// Clear pending debounce timers to prevent post-shutdown dispatches.
|
|
813
|
-
for (const [, buf] of messageBuffers) {
|
|
814
|
-
clearTimeout(buf.timer);
|
|
815
|
-
}
|
|
816
|
-
messageBuffers.clear();
|
|
817
|
-
unregister();
|
|
818
|
-
},
|
|
819
|
-
};
|
|
820
|
-
},
|
|
821
|
-
},
|
|
822
|
-
};
|
|
823
|
-
|
|
824
|
-
// =============================================================================
|
|
825
|
-
// HTTP Webhook Handler
|
|
826
|
-
// =============================================================================
|
|
827
|
-
|
|
828
|
-
async function wecomHttpHandler(req, res) {
|
|
829
|
-
const url = new URL(req.url || "", "http://localhost");
|
|
830
|
-
const path = normalizeWebhookPath(url.pathname);
|
|
831
|
-
const targets = webhookTargets.get(path);
|
|
832
|
-
|
|
833
|
-
if (!targets || targets.length === 0) {
|
|
834
|
-
return false; // Not handled by this plugin
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const query = Object.fromEntries(url.searchParams);
|
|
838
|
-
logger.debug("WeCom HTTP request", { method: req.method, path });
|
|
839
|
-
|
|
840
|
-
// GET: URL Verification
|
|
841
|
-
if (req.method === "GET") {
|
|
842
|
-
const target = targets[0]; // Use first target for verification
|
|
843
|
-
if (!target) {
|
|
844
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
845
|
-
res.end("No webhook target configured");
|
|
846
|
-
return true;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const webhook = new WecomWebhook({
|
|
850
|
-
token: target.account.token,
|
|
851
|
-
encodingAesKey: target.account.encodingAesKey,
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
const echo = webhook.handleVerify(query);
|
|
855
|
-
if (echo) {
|
|
856
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
857
|
-
res.end(echo);
|
|
858
|
-
logger.info("WeCom URL verification successful");
|
|
859
|
-
return true;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
863
|
-
res.end("Verification failed");
|
|
864
|
-
logger.warn("WeCom URL verification failed");
|
|
865
|
-
return true;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// POST: Message handling
|
|
869
|
-
if (req.method === "POST") {
|
|
870
|
-
const target = targets[0];
|
|
871
|
-
if (!target) {
|
|
872
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
873
|
-
res.end("No webhook target configured");
|
|
874
|
-
return true;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// Read request body
|
|
878
|
-
const chunks = [];
|
|
879
|
-
for await (const chunk of req) {
|
|
880
|
-
chunks.push(chunk);
|
|
881
|
-
}
|
|
882
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
883
|
-
logger.debug("WeCom message received", { bodyLength: body.length });
|
|
884
|
-
|
|
885
|
-
const webhook = new WecomWebhook({
|
|
886
|
-
token: target.account.token,
|
|
887
|
-
encodingAesKey: target.account.encodingAesKey,
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
const result = await webhook.handleMessage(query, body);
|
|
891
|
-
if (result === WecomWebhook.DUPLICATE) {
|
|
892
|
-
// Duplicate message — ACK 200 to prevent platform retry storm.
|
|
893
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
894
|
-
res.end("success");
|
|
895
|
-
return true;
|
|
896
|
-
}
|
|
897
|
-
if (!result) {
|
|
898
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
899
|
-
res.end("Bad Request");
|
|
900
|
-
return true;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Handle text message
|
|
904
|
-
if (result.message) {
|
|
905
|
-
const msg = result.message;
|
|
906
|
-
const { timestamp, nonce } = result.query;
|
|
907
|
-
const content = (msg.content || "").trim();
|
|
908
|
-
|
|
909
|
-
// Use stream responses for every inbound message, including commands.
|
|
910
|
-
// WeCom AI Bot response_url is single-use, so streaming is mandatory.
|
|
911
|
-
const streamId = `stream_${crypto.randomUUID()}`;
|
|
912
|
-
streamManager.createStream(streamId);
|
|
913
|
-
streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
|
|
914
|
-
|
|
915
|
-
// Passive reply: return stream id immediately in the sync response.
|
|
916
|
-
// Include the placeholder so the client displays it right away.
|
|
917
|
-
const streamResponse = webhook.buildStreamResponse(streamId, THINKING_PLACEHOLDER, false, timestamp, nonce);
|
|
918
|
-
|
|
919
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
920
|
-
res.end(streamResponse);
|
|
921
|
-
|
|
922
|
-
logger.info("Stream initiated", {
|
|
923
|
-
streamId,
|
|
924
|
-
from: msg.fromUser,
|
|
925
|
-
isCommand: content.startsWith("/"),
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
const streamKey = getMessageStreamKey(msg);
|
|
929
|
-
const isCommand = content.startsWith("/");
|
|
930
|
-
|
|
931
|
-
// Commands bypass debounce — process immediately.
|
|
932
|
-
if (isCommand) {
|
|
933
|
-
processInboundMessage({
|
|
934
|
-
message: msg,
|
|
935
|
-
streamId,
|
|
936
|
-
timestamp,
|
|
937
|
-
nonce,
|
|
938
|
-
account: target.account,
|
|
939
|
-
config: target.config,
|
|
940
|
-
}).catch(async (err) => {
|
|
941
|
-
logger.error("WeCom message processing failed", { error: err.message });
|
|
942
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
943
|
-
});
|
|
944
|
-
return true;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// Debounce: buffer non-command messages per user/group.
|
|
948
|
-
// If multiple messages arrive within DEBOUNCE_MS, merge into one dispatch.
|
|
949
|
-
const existing = messageBuffers.get(streamKey);
|
|
950
|
-
if (existing) {
|
|
951
|
-
// A previous message is still buffered — merge this one in.
|
|
952
|
-
existing.messages.push(msg);
|
|
953
|
-
existing.streamIds.push(streamId);
|
|
954
|
-
clearTimeout(existing.timer);
|
|
955
|
-
existing.timer = setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS);
|
|
956
|
-
logger.info("WeCom: message buffered for merge", {
|
|
957
|
-
streamKey,
|
|
958
|
-
streamId,
|
|
959
|
-
buffered: existing.messages.length,
|
|
960
|
-
});
|
|
961
|
-
} else {
|
|
962
|
-
// First message — start a new buffer with a debounce timer.
|
|
963
|
-
const buffer = {
|
|
964
|
-
messages: [msg],
|
|
965
|
-
streamIds: [streamId],
|
|
966
|
-
target,
|
|
967
|
-
timestamp,
|
|
968
|
-
nonce,
|
|
969
|
-
timer: setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS),
|
|
970
|
-
};
|
|
971
|
-
messageBuffers.set(streamKey, buffer);
|
|
972
|
-
logger.info("WeCom: message buffered (first)", { streamKey, streamId });
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
return true;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// Handle stream refresh - return current stream state
|
|
979
|
-
if (result.stream) {
|
|
980
|
-
const { timestamp, nonce } = result.query;
|
|
981
|
-
const streamId = result.stream.id;
|
|
982
|
-
|
|
983
|
-
// Return latest stream state.
|
|
984
|
-
const stream = streamManager.getStream(streamId);
|
|
985
|
-
|
|
986
|
-
if (!stream) {
|
|
987
|
-
// Stream already expired or missing.
|
|
988
|
-
logger.warn("Stream not found for refresh", { streamId });
|
|
989
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
990
|
-
streamId,
|
|
991
|
-
"会话已过期",
|
|
992
|
-
true,
|
|
993
|
-
timestamp,
|
|
994
|
-
nonce,
|
|
995
|
-
);
|
|
996
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
997
|
-
res.end(streamResponse);
|
|
998
|
-
return true;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// Return current stream payload.
|
|
1002
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
1003
|
-
streamId,
|
|
1004
|
-
stream.content,
|
|
1005
|
-
stream.finished,
|
|
1006
|
-
timestamp,
|
|
1007
|
-
nonce,
|
|
1008
|
-
// Pass msgItem when stream is finished and has images
|
|
1009
|
-
stream.finished && stream.msgItem.length > 0 ? { msgItem: stream.msgItem } : {},
|
|
1010
|
-
);
|
|
1011
|
-
|
|
1012
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1013
|
-
res.end(streamResponse);
|
|
1014
|
-
|
|
1015
|
-
logger.debug("Stream refresh response sent", {
|
|
1016
|
-
streamId,
|
|
1017
|
-
contentLength: stream.content.length,
|
|
1018
|
-
finished: stream.finished,
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// Clean up completed streams after a short delay.
|
|
1022
|
-
if (stream.finished) {
|
|
1023
|
-
setTimeout(() => {
|
|
1024
|
-
streamManager.deleteStream(streamId);
|
|
1025
|
-
}, 30 * 1000);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
return true;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
// Handle event
|
|
1032
|
-
if (result.event) {
|
|
1033
|
-
logger.info("WeCom event received", { event: result.event });
|
|
1034
|
-
|
|
1035
|
-
// Handle enter_chat with an immediate welcome stream.
|
|
1036
|
-
if (result.event?.event_type === "enter_chat") {
|
|
1037
|
-
const { timestamp, nonce } = result.query;
|
|
1038
|
-
const fromUser = result.event?.from?.userid || "";
|
|
1039
|
-
|
|
1040
|
-
// Welcome message body.
|
|
1041
|
-
const welcomeMessage = `你好!👋 我是 AI 助手。
|
|
1042
|
-
|
|
1043
|
-
你可以使用下面的指令管理会话:
|
|
1044
|
-
• **/new** - 新建会话(清空上下文)
|
|
1045
|
-
• **/compact** - 压缩会话(保留上下文摘要)
|
|
1046
|
-
• **/help** - 查看更多命令
|
|
1047
|
-
|
|
1048
|
-
有什么我可以帮你的吗?`;
|
|
1049
|
-
|
|
1050
|
-
// Build and finish stream in a single pass.
|
|
1051
|
-
const streamId = `welcome_${crypto.randomUUID()}`;
|
|
1052
|
-
streamManager.createStream(streamId);
|
|
1053
|
-
streamManager.appendStream(streamId, welcomeMessage);
|
|
1054
|
-
await streamManager.finishStream(streamId);
|
|
1055
|
-
|
|
1056
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
1057
|
-
streamId,
|
|
1058
|
-
welcomeMessage,
|
|
1059
|
-
true,
|
|
1060
|
-
timestamp,
|
|
1061
|
-
nonce,
|
|
1062
|
-
);
|
|
1063
|
-
|
|
1064
|
-
logger.info("Sending welcome message", { fromUser, streamId });
|
|
1065
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1066
|
-
res.end(streamResponse);
|
|
1067
|
-
return true;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
1071
|
-
res.end("success");
|
|
1072
|
-
return true;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
1076
|
-
res.end("success");
|
|
1077
|
-
return true;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
1081
|
-
res.end("Method Not Allowed");
|
|
1082
|
-
return true;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// =============================================================================
|
|
1086
|
-
// Inbound Message Processing (triggers AI response)
|
|
1087
|
-
// =============================================================================
|
|
1088
|
-
|
|
1089
|
-
/**
|
|
1090
|
-
* Flush the debounce buffer for a given streamKey.
|
|
1091
|
-
* Merges buffered messages into a single dispatch call.
|
|
1092
|
-
* The first message's stream receives the LLM response.
|
|
1093
|
-
* Subsequent streams get "消息已合并到第一条回复" and finish immediately.
|
|
1094
|
-
*/
|
|
1095
|
-
function flushMessageBuffer(streamKey, target) {
|
|
1096
|
-
const buffer = messageBuffers.get(streamKey);
|
|
1097
|
-
if (!buffer) {
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
messageBuffers.delete(streamKey);
|
|
1101
|
-
|
|
1102
|
-
const { messages, streamIds } = buffer;
|
|
1103
|
-
const primaryStreamId = streamIds[0];
|
|
1104
|
-
const primaryMsg = messages[0];
|
|
1105
|
-
|
|
1106
|
-
// Merge content from all buffered messages.
|
|
1107
|
-
if (messages.length > 1) {
|
|
1108
|
-
const mergedContent = messages.map((m) => m.content || "").filter(Boolean).join("\n");
|
|
1109
|
-
primaryMsg.content = mergedContent;
|
|
1110
|
-
|
|
1111
|
-
// Merge image attachments.
|
|
1112
|
-
const allImageUrls = messages.flatMap((m) => m.imageUrls || []);
|
|
1113
|
-
if (allImageUrls.length > 0) {
|
|
1114
|
-
primaryMsg.imageUrls = allImageUrls;
|
|
1115
|
-
}
|
|
1116
|
-
const singleImages = messages.map((m) => m.imageUrl).filter(Boolean);
|
|
1117
|
-
if (singleImages.length > 0 && !primaryMsg.imageUrl) {
|
|
1118
|
-
primaryMsg.imageUrl = singleImages[0];
|
|
1119
|
-
if (singleImages.length > 1) {
|
|
1120
|
-
primaryMsg.imageUrls = [...(primaryMsg.imageUrls || []), ...singleImages.slice(1)];
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Finish extra streams with merge notice.
|
|
1125
|
-
for (let i = 1; i < streamIds.length; i++) {
|
|
1126
|
-
const extraStreamId = streamIds[i];
|
|
1127
|
-
streamManager.replaceIfPlaceholder(
|
|
1128
|
-
extraStreamId, "消息已合并到第一条回复中。", THINKING_PLACEHOLDER,
|
|
1129
|
-
);
|
|
1130
|
-
streamManager.finishStream(extraStreamId).then(() => {
|
|
1131
|
-
unregisterActiveStream(streamKey, extraStreamId);
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
logger.info("WeCom: flushing merged messages", {
|
|
1136
|
-
streamKey,
|
|
1137
|
-
count: messages.length,
|
|
1138
|
-
primaryStreamId,
|
|
1139
|
-
mergedContentPreview: mergedContent.substring(0, 60),
|
|
1140
|
-
});
|
|
1141
|
-
} else {
|
|
1142
|
-
logger.info("WeCom: flushing single message", { streamKey, primaryStreamId });
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// Dispatch the merged message.
|
|
1146
|
-
processInboundMessage({
|
|
1147
|
-
message: primaryMsg,
|
|
1148
|
-
streamId: primaryStreamId,
|
|
1149
|
-
timestamp: buffer.timestamp,
|
|
1150
|
-
nonce: buffer.nonce,
|
|
1151
|
-
account: target.account,
|
|
1152
|
-
config: target.config,
|
|
1153
|
-
}).catch(async (err) => {
|
|
1154
|
-
logger.error("WeCom message processing failed", { error: err.message });
|
|
1155
|
-
await handleStreamError(primaryStreamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
1156
|
-
});
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
async function processInboundMessage({
|
|
1160
|
-
message,
|
|
1161
|
-
streamId,
|
|
1162
|
-
timestamp: _timestamp,
|
|
1163
|
-
nonce: _nonce,
|
|
1164
|
-
account,
|
|
1165
|
-
config,
|
|
1166
|
-
}) {
|
|
1167
|
-
const runtime = getRuntime();
|
|
1168
|
-
const core = runtime.channel;
|
|
1169
|
-
|
|
1170
|
-
const senderId = message.fromUser;
|
|
1171
|
-
const msgType = message.msgType || "text";
|
|
1172
|
-
const imageUrl = message.imageUrl || "";
|
|
1173
|
-
const imageUrls = message.imageUrls || [];
|
|
1174
|
-
const fileUrl = message.fileUrl || "";
|
|
1175
|
-
const fileName = message.fileName || "";
|
|
1176
|
-
const rawContent = message.content || "";
|
|
1177
|
-
const chatType = message.chatType || "single";
|
|
1178
|
-
const chatId = message.chatId || "";
|
|
1179
|
-
const isGroupChat = chatType === "group" && chatId;
|
|
1180
|
-
|
|
1181
|
-
// Use chat id for group sessions and sender id for direct messages.
|
|
1182
|
-
const peerId = isGroupChat ? chatId : senderId;
|
|
1183
|
-
const peerKind = isGroupChat ? "group" : "dm";
|
|
1184
|
-
const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
|
|
1185
|
-
|
|
1186
|
-
// Track active stream by chat context for outbound adapter callbacks.
|
|
1187
|
-
const streamKey = isGroupChat ? chatId : senderId;
|
|
1188
|
-
if (streamId) {
|
|
1189
|
-
registerActiveStream(streamKey, streamId);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// Apply group mention gating rules.
|
|
1193
|
-
let rawBody = rawContent;
|
|
1194
|
-
if (isGroupChat) {
|
|
1195
|
-
if (!shouldTriggerGroupResponse(rawContent, config)) {
|
|
1196
|
-
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
1197
|
-
if (streamId) {
|
|
1198
|
-
streamManager.replaceIfPlaceholder(
|
|
1199
|
-
streamId, "请@提及我以获取回复。", THINKING_PLACEHOLDER,
|
|
1200
|
-
);
|
|
1201
|
-
await streamManager.finishStream(streamId);
|
|
1202
|
-
unregisterActiveStream(streamKey, streamId);
|
|
1203
|
-
}
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
// Strip mention markers from the effective prompt.
|
|
1207
|
-
rawBody = extractGroupMessageContent(rawContent, config);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
1211
|
-
cfg: config,
|
|
1212
|
-
accountId: account.accountId,
|
|
1213
|
-
senderId,
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
// Skip empty messages, but allow image/mixed/file messages.
|
|
1217
|
-
if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
|
|
1218
|
-
logger.debug("WeCom: empty message, skipping");
|
|
1219
|
-
if (streamId) {
|
|
1220
|
-
await streamManager.finishStream(streamId);
|
|
1221
|
-
unregisterActiveStream(streamKey, streamId);
|
|
1222
|
-
}
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// ========================================================================
|
|
1227
|
-
// Command allowlist enforcement
|
|
1228
|
-
// Admins bypass the allowlist entirely.
|
|
1229
|
-
// ========================================================================
|
|
1230
|
-
const senderIsAdmin = isWecomAdmin(senderId, config);
|
|
1231
|
-
const commandCheck = checkCommandAllowlist(rawBody, config);
|
|
1232
|
-
|
|
1233
|
-
if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
|
|
1234
|
-
// Return block message when command is outside the allowlist.
|
|
1235
|
-
const cmdConfig = getCommandConfig(config);
|
|
1236
|
-
logger.warn("WeCom: blocked command", {
|
|
1237
|
-
command: commandCheck.command,
|
|
1238
|
-
from: senderId,
|
|
1239
|
-
chatType: peerKind,
|
|
1240
|
-
});
|
|
1241
|
-
|
|
1242
|
-
// Send blocked-command response through the same stream.
|
|
1243
|
-
if (streamId) {
|
|
1244
|
-
streamManager.replaceIfPlaceholder(streamId, cmdConfig.blockMessage, THINKING_PLACEHOLDER);
|
|
1245
|
-
await streamManager.finishStream(streamId);
|
|
1246
|
-
unregisterActiveStream(streamKey, streamId);
|
|
1247
|
-
}
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if (commandCheck.isCommand && !commandCheck.allowed && senderIsAdmin) {
|
|
1252
|
-
logger.info("WeCom: admin bypassed command allowlist", {
|
|
1253
|
-
command: commandCheck.command,
|
|
1254
|
-
from: senderId,
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
logger.info("WeCom processing message", {
|
|
1259
|
-
from: senderId,
|
|
1260
|
-
chatType: peerKind,
|
|
1261
|
-
peerId,
|
|
1262
|
-
content: rawBody.substring(0, 50),
|
|
1263
|
-
streamId,
|
|
1264
|
-
isCommand: commandCheck.isCommand,
|
|
1265
|
-
command: commandCheck.command,
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
|
-
// ========================================================================
|
|
1269
|
-
// Dynamic agent routing
|
|
1270
|
-
// Admins route to the main agent directly.
|
|
1271
|
-
// ========================================================================
|
|
1272
|
-
const dynamicConfig = getDynamicAgentConfig(config);
|
|
1273
|
-
|
|
1274
|
-
// Compute deterministic agent target for this conversation.
|
|
1275
|
-
const targetAgentId =
|
|
1276
|
-
!senderIsAdmin && dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config })
|
|
1277
|
-
? generateAgentId(peerKind, peerId)
|
|
1278
|
-
: null;
|
|
1279
|
-
|
|
1280
|
-
if (targetAgentId) {
|
|
1281
|
-
await ensureDynamicAgentListed(targetAgentId);
|
|
1282
|
-
logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
|
|
1283
|
-
} else if (senderIsAdmin) {
|
|
1284
|
-
logger.debug("Admin user, routing to main agent", { senderId });
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// ========================================================================
|
|
1288
|
-
// Resolve route and override with dynamic agent when enabled
|
|
1289
|
-
// ========================================================================
|
|
1290
|
-
const route = core.routing.resolveAgentRoute({
|
|
1291
|
-
cfg: config,
|
|
1292
|
-
channel: "wecom",
|
|
1293
|
-
accountId: account.accountId,
|
|
1294
|
-
peer: {
|
|
1295
|
-
kind: peerKind,
|
|
1296
|
-
id: peerId,
|
|
1297
|
-
},
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
// Override default route with deterministic dynamic agent session key.
|
|
1301
|
-
if (targetAgentId) {
|
|
1302
|
-
route.agentId = targetAgentId;
|
|
1303
|
-
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// Build inbound context
|
|
1307
|
-
const storePath = core.session.resolveStorePath(config.session?.store, {
|
|
1308
|
-
agentId: route.agentId,
|
|
1309
|
-
});
|
|
1310
|
-
const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
|
|
1311
|
-
const previousTimestamp = core.session.readSessionUpdatedAt({
|
|
1312
|
-
storePath,
|
|
1313
|
-
sessionKey: route.sessionKey,
|
|
1314
|
-
});
|
|
1315
|
-
|
|
1316
|
-
// Prefix sender id in group contexts so attribution stays explicit.
|
|
1317
|
-
const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
|
|
1318
|
-
const body = core.reply.formatAgentEnvelope({
|
|
1319
|
-
channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
|
|
1320
|
-
from: senderLabel,
|
|
1321
|
-
timestamp: Date.now(),
|
|
1322
|
-
previousTimestamp,
|
|
1323
|
-
envelope: envelopeOptions,
|
|
1324
|
-
body: rawBody,
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
// Build context payload with optional image attachment.
|
|
1328
|
-
const ctxBase = {
|
|
1329
|
-
Body: body,
|
|
1330
|
-
RawBody: rawBody,
|
|
1331
|
-
CommandBody: rawBody,
|
|
1332
|
-
From: `wecom:${senderId}`,
|
|
1333
|
-
To: conversationId,
|
|
1334
|
-
SessionKey: route.sessionKey,
|
|
1335
|
-
AccountId: route.accountId,
|
|
1336
|
-
ChatType: isGroupChat ? "group" : "direct",
|
|
1337
|
-
ConversationLabel: isGroupChat ? `Group ${chatId}` : senderId,
|
|
1338
|
-
SenderName: senderId,
|
|
1339
|
-
SenderId: senderId,
|
|
1340
|
-
GroupId: isGroupChat ? chatId : undefined,
|
|
1341
|
-
Provider: "wecom",
|
|
1342
|
-
Surface: "wecom",
|
|
1343
|
-
OriginatingChannel: "wecom",
|
|
1344
|
-
OriginatingTo: conversationId,
|
|
1345
|
-
CommandAuthorized: commandAuthorized,
|
|
1346
|
-
};
|
|
1347
|
-
|
|
1348
|
-
// Download, decrypt, and attach media when present.
|
|
1349
|
-
const allImageUrls = imageUrl ? [imageUrl] : imageUrls;
|
|
1350
|
-
|
|
1351
|
-
if (allImageUrls.length > 0) {
|
|
1352
|
-
const mediaPaths = [];
|
|
1353
|
-
const mediaTypes = [];
|
|
1354
|
-
const fallbackUrls = [];
|
|
1355
|
-
|
|
1356
|
-
for (const url of allImageUrls) {
|
|
1357
|
-
try {
|
|
1358
|
-
const result = await downloadAndDecryptImage(url, account.encodingAesKey, account.token);
|
|
1359
|
-
mediaPaths.push(result.localPath);
|
|
1360
|
-
mediaTypes.push(result.mimeType);
|
|
1361
|
-
} catch (e) {
|
|
1362
|
-
logger.warn("Image decryption failed, using URL fallback", { error: e.message, url: url.substring(0, 80) });
|
|
1363
|
-
fallbackUrls.push(url);
|
|
1364
|
-
mediaTypes.push("image/jpeg");
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
if (mediaPaths.length > 0) {
|
|
1369
|
-
ctxBase.MediaPaths = mediaPaths;
|
|
1370
|
-
}
|
|
1371
|
-
if (fallbackUrls.length > 0) {
|
|
1372
|
-
ctxBase.MediaUrls = fallbackUrls;
|
|
1373
|
-
}
|
|
1374
|
-
ctxBase.MediaTypes = mediaTypes;
|
|
1375
|
-
|
|
1376
|
-
logger.info("Image attachments prepared", {
|
|
1377
|
-
decrypted: mediaPaths.length,
|
|
1378
|
-
fallback: fallbackUrls.length,
|
|
1379
|
-
});
|
|
1380
|
-
|
|
1381
|
-
// For image-only messages (no text), set a placeholder body.
|
|
1382
|
-
if (!rawBody.trim()) {
|
|
1383
|
-
const count = allImageUrls.length;
|
|
1384
|
-
ctxBase.Body = count > 1
|
|
1385
|
-
? `[用户发送了${count}张图片]`
|
|
1386
|
-
: "[用户发送了一张图片]";
|
|
1387
|
-
ctxBase.RawBody = "[图片]";
|
|
1388
|
-
ctxBase.CommandBody = "";
|
|
7
|
+
// Periodic cleanup for streamMeta and expired responseUrls to prevent memory leaks.
|
|
8
|
+
setInterval(() => {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
// Clean streamMeta entries whose stream no longer exists in streamManager.
|
|
11
|
+
for (const streamId of streamMeta.keys()) {
|
|
12
|
+
if (!streamManager.hasStream(streamId)) {
|
|
13
|
+
streamMeta.delete(streamId);
|
|
1389
14
|
}
|
|
1390
15
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
const localFilePath = await downloadWecomFile(fileUrl, fileName);
|
|
1396
|
-
ctxBase.MediaPaths = [...(ctxBase.MediaPaths || []), localFilePath];
|
|
1397
|
-
ctxBase.MediaTypes = [...(ctxBase.MediaTypes || []), guessMimeType(fileName)];
|
|
1398
|
-
logger.info("File attachment prepared", { path: localFilePath, name: fileName });
|
|
1399
|
-
} catch (e) {
|
|
1400
|
-
logger.warn("File download failed", { error: e.message });
|
|
1401
|
-
// Inform the agent about the file via text.
|
|
1402
|
-
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
1403
|
-
if (!rawBody.trim()) {
|
|
1404
|
-
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
1405
|
-
ctxBase.RawBody = label;
|
|
1406
|
-
ctxBase.CommandBody = "";
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
if (!rawBody.trim() && !ctxBase.Body) {
|
|
1410
|
-
const label = fileName ? `[文件: ${fileName}]` : "[文件]";
|
|
1411
|
-
ctxBase.Body = `[用户发送了文件] ${label}`;
|
|
1412
|
-
ctxBase.RawBody = label;
|
|
1413
|
-
ctxBase.CommandBody = "";
|
|
16
|
+
// Clean expired responseUrls (older than 1 hour).
|
|
17
|
+
for (const [key, entry] of responseUrls.entries()) {
|
|
18
|
+
if (now > entry.expiresAt) {
|
|
19
|
+
responseUrls.delete(key);
|
|
1414
20
|
}
|
|
1415
21
|
}
|
|
1416
|
-
|
|
1417
|
-
const ctxPayload = core.reply.finalizeInboundContext(ctxBase);
|
|
1418
|
-
|
|
1419
|
-
// Record session meta
|
|
1420
|
-
void core.session
|
|
1421
|
-
.recordSessionMetaFromInbound({
|
|
1422
|
-
storePath,
|
|
1423
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1424
|
-
ctx: ctxPayload,
|
|
1425
|
-
})
|
|
1426
|
-
.catch((err) => {
|
|
1427
|
-
logger.error("WeCom: failed updating session meta", { error: err.message });
|
|
1428
|
-
});
|
|
1429
|
-
|
|
1430
|
-
// Serialize dispatches per user/group. Each message gets its own full dispatch
|
|
1431
|
-
// cycle with proper deliver callbacks.
|
|
1432
|
-
const prevLock = dispatchLocks.get(streamKey) ?? Promise.resolve();
|
|
1433
|
-
const currentDispatch = prevLock.then(async () => {
|
|
1434
|
-
// Dispatch reply with AI processing.
|
|
1435
|
-
// Wrap in streamContext so outbound adapters resolve the correct stream.
|
|
1436
|
-
await streamContext.run({ streamId, streamKey }, async () => {
|
|
1437
|
-
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1438
|
-
ctx: ctxPayload,
|
|
1439
|
-
cfg: config,
|
|
1440
|
-
dispatcherOptions: {
|
|
1441
|
-
deliver: async (payload, info) => {
|
|
1442
|
-
logger.info("Dispatcher deliver called", {
|
|
1443
|
-
kind: info.kind,
|
|
1444
|
-
hasText: !!(payload.text && payload.text.trim()),
|
|
1445
|
-
textPreview: (payload.text || "").substring(0, 50),
|
|
1446
|
-
});
|
|
1447
|
-
|
|
1448
|
-
await deliverWecomReply({
|
|
1449
|
-
payload,
|
|
1450
|
-
senderId: streamKey,
|
|
1451
|
-
streamId,
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
// Mark stream complete on final payload.
|
|
1455
|
-
if (streamId && info.kind === "final") {
|
|
1456
|
-
await streamManager.finishStream(streamId);
|
|
1457
|
-
logger.info("WeCom stream finished", { streamId });
|
|
1458
|
-
}
|
|
1459
|
-
},
|
|
1460
|
-
onError: async (err, info) => {
|
|
1461
|
-
logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
|
|
1462
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
1463
|
-
},
|
|
1464
|
-
},
|
|
1465
|
-
});
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
// Safety net: ensure stream finishes after dispatch.
|
|
1469
|
-
if (streamId) {
|
|
1470
|
-
const stream = streamManager.getStream(streamId);
|
|
1471
|
-
if (!stream || stream.finished) {
|
|
1472
|
-
unregisterActiveStream(streamKey, streamId);
|
|
1473
|
-
} else {
|
|
1474
|
-
await streamManager.finishStream(streamId);
|
|
1475
|
-
unregisterActiveStream(streamKey, streamId);
|
|
1476
|
-
logger.info("WeCom stream finished (safety net)", { streamId });
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
}).catch(async (err) => {
|
|
1480
|
-
logger.error("WeCom dispatch chain error", { streamId, streamKey, error: err.message });
|
|
1481
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
1482
|
-
});
|
|
1483
|
-
|
|
1484
|
-
dispatchLocks.set(streamKey, currentDispatch);
|
|
1485
|
-
await currentDispatch;
|
|
1486
|
-
if (dispatchLocks.get(streamKey) === currentDispatch) {
|
|
1487
|
-
dispatchLocks.delete(streamKey);
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// =============================================================================
|
|
1492
|
-
// Outbound Reply Delivery (Stream-only mode)
|
|
1493
|
-
// =============================================================================
|
|
1494
|
-
|
|
1495
|
-
async function deliverWecomReply({ payload, senderId, streamId }) {
|
|
1496
|
-
const text = payload.text || "";
|
|
1497
|
-
|
|
1498
|
-
logger.debug("deliverWecomReply called", {
|
|
1499
|
-
hasText: !!text.trim(),
|
|
1500
|
-
textPreview: text.substring(0, 50),
|
|
1501
|
-
streamId,
|
|
1502
|
-
senderId,
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
// Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
|
|
1506
|
-
const mediaRegex = /^MEDIA:\s*(.+)$/gm;
|
|
1507
|
-
const mediaMatches = [];
|
|
1508
|
-
let match;
|
|
1509
|
-
while ((match = mediaRegex.exec(text)) !== null) {
|
|
1510
|
-
const mediaPath = match[1].trim();
|
|
1511
|
-
// Only intercept absolute filesystem paths.
|
|
1512
|
-
if (mediaPath.startsWith("/")) {
|
|
1513
|
-
mediaMatches.push({
|
|
1514
|
-
fullMatch: match[0],
|
|
1515
|
-
path: mediaPath,
|
|
1516
|
-
});
|
|
1517
|
-
logger.debug("Detected absolute path MEDIA line", {
|
|
1518
|
-
streamId,
|
|
1519
|
-
mediaPath,
|
|
1520
|
-
line: match[0],
|
|
1521
|
-
});
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
// Queue absolute-path images and remove corresponding MEDIA lines from text.
|
|
1526
|
-
let processedText = text;
|
|
1527
|
-
if (mediaMatches.length > 0 && streamId) {
|
|
1528
|
-
for (const media of mediaMatches) {
|
|
1529
|
-
const queued = streamManager.queueImage(streamId, media.path);
|
|
1530
|
-
if (queued) {
|
|
1531
|
-
// Remove this MEDIA line once image was queued.
|
|
1532
|
-
processedText = processedText.replace(media.fullMatch, "").trim();
|
|
1533
|
-
logger.info("Queued absolute path image for stream", {
|
|
1534
|
-
streamId,
|
|
1535
|
-
imagePath: media.path,
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
// All outbound content is sent via stream updates.
|
|
1542
|
-
if (!processedText.trim()) {
|
|
1543
|
-
logger.debug("WeCom: empty block after processing, skipping stream update");
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
// Helper: append content with duplicate suppression and placeholder awareness.
|
|
1548
|
-
const appendToStream = (targetStreamId, content) => {
|
|
1549
|
-
const stream = streamManager.getStream(targetStreamId);
|
|
1550
|
-
if (!stream) {
|
|
1551
|
-
return false;
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// If stream still has the placeholder, replace it entirely.
|
|
1555
|
-
if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
|
|
1556
|
-
streamManager.replaceIfPlaceholder(targetStreamId, content, THINKING_PLACEHOLDER);
|
|
1557
|
-
return true;
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
// Skip duplicate chunks (for example, block + final overlap).
|
|
1561
|
-
if (stream.content.includes(content.trim())) {
|
|
1562
|
-
logger.debug("WeCom: duplicate content, skipping", {
|
|
1563
|
-
streamId: targetStreamId,
|
|
1564
|
-
contentPreview: content.substring(0, 30),
|
|
1565
|
-
});
|
|
1566
|
-
return true;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
const separator = stream.content.length > 0 ? "\n\n" : "";
|
|
1570
|
-
streamManager.appendStream(targetStreamId, separator + content);
|
|
1571
|
-
return true;
|
|
1572
|
-
};
|
|
1573
|
-
|
|
1574
|
-
if (!streamId) {
|
|
1575
|
-
// Try async context first, then fallback to active stream map.
|
|
1576
|
-
const ctx = streamContext.getStore();
|
|
1577
|
-
const contextStreamId = ctx?.streamId;
|
|
1578
|
-
const activeStreamId = contextStreamId ?? resolveActiveStream(senderId);
|
|
1579
|
-
|
|
1580
|
-
if (activeStreamId && streamManager.hasStream(activeStreamId)) {
|
|
1581
|
-
appendToStream(activeStreamId, processedText);
|
|
1582
|
-
logger.debug("WeCom stream appended (via context/activeStreams)", {
|
|
1583
|
-
streamId: activeStreamId,
|
|
1584
|
-
source: contextStreamId ? "asyncContext" : "activeStreams",
|
|
1585
|
-
contentLength: processedText.length,
|
|
1586
|
-
});
|
|
1587
|
-
return;
|
|
1588
|
-
}
|
|
1589
|
-
logger.warn("WeCom: no active stream for this message", { senderId });
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
if (!streamManager.hasStream(streamId)) {
|
|
1594
|
-
logger.warn("WeCom: stream not found, cannot update", { streamId });
|
|
1595
|
-
return;
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
appendToStream(streamId, processedText);
|
|
1599
|
-
logger.debug("WeCom stream appended", {
|
|
1600
|
-
streamId,
|
|
1601
|
-
contentLength: processedText.length,
|
|
1602
|
-
to: senderId,
|
|
1603
|
-
});
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
// =============================================================================
|
|
1607
|
-
// Plugin Registration
|
|
1608
|
-
// =============================================================================
|
|
22
|
+
}, 60 * 1000).unref();
|
|
1609
23
|
|
|
1610
24
|
const plugin = {
|
|
1611
25
|
// Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
|
|
@@ -1618,7 +32,7 @@ const plugin = {
|
|
|
1618
32
|
|
|
1619
33
|
// Save runtime for message processing
|
|
1620
34
|
setRuntime(api.runtime);
|
|
1621
|
-
|
|
35
|
+
setOpenclawConfig(api.config);
|
|
1622
36
|
|
|
1623
37
|
// Register channel
|
|
1624
38
|
api.registerChannel({ plugin: wecomChannelPlugin });
|