@dingxiang-me/openclaw-wechat 0.4.9
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/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.en.md +287 -0
- package/README.md +508 -0
- package/docs/channels/wecom.md +58 -0
- package/openclaw.plugin.json +412 -0
- package/package.json +49 -0
- package/src/core.js +732 -0
- package/src/index.js +3800 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,3800 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { XMLParser } from "fast-xml-parser";
|
|
3
|
+
import { normalizePluginHttpPath } from "openclaw/plugin-sdk";
|
|
4
|
+
import { writeFile, unlink, mkdir, readFile, stat, open } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join } from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { ProxyAgent } from "undici";
|
|
9
|
+
import {
|
|
10
|
+
WECOM_TEXT_BYTE_LIMIT,
|
|
11
|
+
buildWecomSessionId,
|
|
12
|
+
buildInboundDedupeKey,
|
|
13
|
+
markInboundMessageSeen,
|
|
14
|
+
resetInboundMessageDedupeForTests,
|
|
15
|
+
computeMsgSignature,
|
|
16
|
+
getByteLength,
|
|
17
|
+
isLocalVoiceInputTypeDirectlySupported,
|
|
18
|
+
normalizeAudioContentType,
|
|
19
|
+
pickAudioFileExtension,
|
|
20
|
+
extractLeadingSlashCommand,
|
|
21
|
+
isWecomSenderAllowed,
|
|
22
|
+
resolveWecomAllowFromPolicyConfig,
|
|
23
|
+
resolveWecomBotModeConfig,
|
|
24
|
+
resolveWecomCommandPolicyConfig,
|
|
25
|
+
resolveWecomDebounceConfig,
|
|
26
|
+
resolveWecomGroupChatConfig,
|
|
27
|
+
resolveWecomStreamingConfig,
|
|
28
|
+
resolveVoiceTranscriptionConfig,
|
|
29
|
+
resolveWecomProxyConfig,
|
|
30
|
+
shouldTriggerWecomGroupResponse,
|
|
31
|
+
stripWecomGroupMentions,
|
|
32
|
+
splitWecomText,
|
|
33
|
+
pickAccountBySignature,
|
|
34
|
+
} from "./core.js";
|
|
35
|
+
const xmlParser = new XMLParser({
|
|
36
|
+
ignoreAttributes: false,
|
|
37
|
+
trimValues: true,
|
|
38
|
+
processEntities: false, // 禁用实体处理,防止 XXE 攻击
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 请求体大小限制 (1MB)
|
|
42
|
+
const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
|
|
43
|
+
const PLUGIN_VERSION = "0.4.9";
|
|
44
|
+
const WECOM_TEMP_DIR_NAME = "openclaw-wechat";
|
|
45
|
+
const WECOM_TEMP_FILE_RETENTION_MS = 30 * 60 * 1000;
|
|
46
|
+
const FFMPEG_PATH_CHECK_CACHE = {
|
|
47
|
+
checked: false,
|
|
48
|
+
available: false,
|
|
49
|
+
};
|
|
50
|
+
const COMMAND_PATH_CHECK_CACHE = new Map();
|
|
51
|
+
const WECOM_PROXY_DISPATCHER_CACHE = new Map();
|
|
52
|
+
const INVALID_PROXY_CACHE = new Set();
|
|
53
|
+
const TEXT_MESSAGE_DEBOUNCE_BUFFERS = new Map();
|
|
54
|
+
const ACTIVE_LATE_REPLY_WATCHERS = new Map();
|
|
55
|
+
const DELIVERED_TRANSCRIPT_REPLY_CACHE = new Map();
|
|
56
|
+
const TRANSCRIPT_REPLY_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
57
|
+
const BOT_STREAMS = new Map();
|
|
58
|
+
let BOT_STREAM_CLEANUP_TIMER = null;
|
|
59
|
+
|
|
60
|
+
function readRequestBody(req, maxSize = MAX_REQUEST_BODY_SIZE) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let totalSize = 0;
|
|
64
|
+
|
|
65
|
+
req.on("data", (c) => {
|
|
66
|
+
const chunk = Buffer.isBuffer(c) ? c : Buffer.from(c);
|
|
67
|
+
totalSize += chunk.length;
|
|
68
|
+
if (totalSize > maxSize) {
|
|
69
|
+
reject(new Error(`Request body too large (limit: ${maxSize} bytes)`));
|
|
70
|
+
req.destroy();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
76
|
+
req.on("error", reject);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decodeAesKey(aesKey) {
|
|
81
|
+
const base64 = aesKey.endsWith("=") ? aesKey : `${aesKey}=`;
|
|
82
|
+
const key = Buffer.from(base64, "base64");
|
|
83
|
+
if (key.length !== 32) {
|
|
84
|
+
throw new Error(`Invalid callbackAesKey: expected 32-byte key, got ${key.length}`);
|
|
85
|
+
}
|
|
86
|
+
return key;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pkcs7Unpad(buf) {
|
|
90
|
+
const pad = buf[buf.length - 1];
|
|
91
|
+
if (pad < 1 || pad > 32) return buf;
|
|
92
|
+
return buf.subarray(0, buf.length - pad);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decryptWecom({ aesKey, cipherTextBase64 }) {
|
|
96
|
+
const key = decodeAesKey(aesKey);
|
|
97
|
+
const iv = key.subarray(0, 16);
|
|
98
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
99
|
+
decipher.setAutoPadding(false);
|
|
100
|
+
const plain = Buffer.concat([
|
|
101
|
+
decipher.update(Buffer.from(cipherTextBase64, "base64")),
|
|
102
|
+
decipher.final(),
|
|
103
|
+
]);
|
|
104
|
+
const unpadded = pkcs7Unpad(plain);
|
|
105
|
+
|
|
106
|
+
const msgLen = unpadded.readUInt32BE(16);
|
|
107
|
+
const msgStart = 20;
|
|
108
|
+
const msgEnd = msgStart + msgLen;
|
|
109
|
+
const msg = unpadded.subarray(msgStart, msgEnd).toString("utf8");
|
|
110
|
+
const corpId = unpadded.subarray(msgEnd).toString("utf8");
|
|
111
|
+
return { msg, corpId };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function decryptWecomMediaBuffer({ aesKey, encryptedBuffer }) {
|
|
115
|
+
if (!Buffer.isBuffer(encryptedBuffer) || encryptedBuffer.length === 0) {
|
|
116
|
+
throw new Error("empty media buffer");
|
|
117
|
+
}
|
|
118
|
+
const key = decodeAesKey(aesKey);
|
|
119
|
+
const iv = key.subarray(0, 16);
|
|
120
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
121
|
+
decipher.setAutoPadding(false);
|
|
122
|
+
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
|
|
123
|
+
const padLen = decrypted[decrypted.length - 1];
|
|
124
|
+
if (!Number.isFinite(padLen) || padLen < 1 || padLen > 32) {
|
|
125
|
+
return decrypted;
|
|
126
|
+
}
|
|
127
|
+
for (let i = decrypted.length - padLen; i < decrypted.length; i += 1) {
|
|
128
|
+
if (decrypted[i] !== padLen) return decrypted;
|
|
129
|
+
}
|
|
130
|
+
return decrypted.subarray(0, decrypted.length - padLen);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pkcs7Pad(buf, blockSize = 32) {
|
|
134
|
+
const amountToPad = blockSize - (buf.length % blockSize || blockSize);
|
|
135
|
+
const pad = Buffer.alloc(amountToPad === 0 ? blockSize : amountToPad, amountToPad === 0 ? blockSize : amountToPad);
|
|
136
|
+
return Buffer.concat([buf, pad]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function encryptWecom({ aesKey, plainText, corpId = "" }) {
|
|
140
|
+
const key = decodeAesKey(aesKey);
|
|
141
|
+
const iv = key.subarray(0, 16);
|
|
142
|
+
const random16 = crypto.randomBytes(16);
|
|
143
|
+
const msgBuffer = Buffer.from(String(plainText ?? ""), "utf8");
|
|
144
|
+
const lenBuffer = Buffer.alloc(4);
|
|
145
|
+
lenBuffer.writeUInt32BE(msgBuffer.length, 0);
|
|
146
|
+
const corpBuffer = Buffer.from(String(corpId ?? ""), "utf8");
|
|
147
|
+
const raw = Buffer.concat([random16, lenBuffer, msgBuffer, corpBuffer]);
|
|
148
|
+
const padded = pkcs7Pad(raw, 32);
|
|
149
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
150
|
+
cipher.setAutoPadding(false);
|
|
151
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
152
|
+
return encrypted.toString("base64");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseIncomingXml(xml) {
|
|
156
|
+
const obj = xmlParser.parse(xml);
|
|
157
|
+
const root = obj?.xml ?? obj;
|
|
158
|
+
return root;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseIncomingJson(jsonText) {
|
|
162
|
+
if (!jsonText) return null;
|
|
163
|
+
const parsed = JSON.parse(jsonText);
|
|
164
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildWecomBotEncryptedResponse({ token, aesKey, timestamp, nonce, plainPayload }) {
|
|
168
|
+
const plainText = JSON.stringify(plainPayload ?? {});
|
|
169
|
+
const encrypt = encryptWecom({
|
|
170
|
+
aesKey,
|
|
171
|
+
plainText,
|
|
172
|
+
corpId: "",
|
|
173
|
+
});
|
|
174
|
+
const msgsignature = computeMsgSignature({
|
|
175
|
+
token,
|
|
176
|
+
timestamp,
|
|
177
|
+
nonce,
|
|
178
|
+
encrypt,
|
|
179
|
+
});
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
encrypt,
|
|
182
|
+
msgsignature,
|
|
183
|
+
timestamp: String(timestamp ?? ""),
|
|
184
|
+
nonce: String(nonce ?? ""),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createBotStream(streamId, initialContent = "") {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
BOT_STREAMS.set(streamId, {
|
|
191
|
+
id: streamId,
|
|
192
|
+
content: String(initialContent ?? ""),
|
|
193
|
+
finished: false,
|
|
194
|
+
createdAt: now,
|
|
195
|
+
updatedAt: now,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function updateBotStream(streamId, content, { append = false, finished = false } = {}) {
|
|
200
|
+
const existing = BOT_STREAMS.get(streamId);
|
|
201
|
+
if (!existing) return null;
|
|
202
|
+
const incoming = String(content ?? "");
|
|
203
|
+
if (append) {
|
|
204
|
+
existing.content = `${existing.content}${incoming}`;
|
|
205
|
+
} else {
|
|
206
|
+
existing.content = incoming;
|
|
207
|
+
}
|
|
208
|
+
if (finished) existing.finished = true;
|
|
209
|
+
existing.updatedAt = Date.now();
|
|
210
|
+
BOT_STREAMS.set(streamId, existing);
|
|
211
|
+
return existing;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function finishBotStream(streamId, content) {
|
|
215
|
+
if (!BOT_STREAMS.has(streamId)) return null;
|
|
216
|
+
if (content != null) {
|
|
217
|
+
return updateBotStream(streamId, content, { append: false, finished: true });
|
|
218
|
+
}
|
|
219
|
+
const existing = BOT_STREAMS.get(streamId);
|
|
220
|
+
existing.finished = true;
|
|
221
|
+
existing.updatedAt = Date.now();
|
|
222
|
+
BOT_STREAMS.set(streamId, existing);
|
|
223
|
+
return existing;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getBotStream(streamId) {
|
|
227
|
+
return BOT_STREAMS.get(streamId) ?? null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cleanupExpiredBotStreams(expireMs = 10 * 60 * 1000) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
for (const [streamId, stream] of BOT_STREAMS.entries()) {
|
|
233
|
+
const age = now - Number(stream?.updatedAt ?? now);
|
|
234
|
+
if (age > expireMs) {
|
|
235
|
+
BOT_STREAMS.delete(streamId);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ensureBotStreamCleanupTimer(expireMs, logger) {
|
|
241
|
+
if (BOT_STREAM_CLEANUP_TIMER) return;
|
|
242
|
+
BOT_STREAM_CLEANUP_TIMER = setInterval(() => {
|
|
243
|
+
cleanupExpiredBotStreams(expireMs);
|
|
244
|
+
}, 60 * 1000);
|
|
245
|
+
BOT_STREAM_CLEANUP_TIMER.unref?.();
|
|
246
|
+
logger?.info?.(`wecom(bot): stream cleanup timer started (expireMs=${expireMs})`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function collectWecomBotImageUrls(imageLike) {
|
|
250
|
+
const candidates = [
|
|
251
|
+
imageLike?.url,
|
|
252
|
+
imageLike?.pic_url,
|
|
253
|
+
imageLike?.picUrl,
|
|
254
|
+
imageLike?.image_url,
|
|
255
|
+
imageLike?.imageUrl,
|
|
256
|
+
];
|
|
257
|
+
const dedupe = new Set();
|
|
258
|
+
const urls = [];
|
|
259
|
+
for (const candidate of candidates) {
|
|
260
|
+
const url = String(candidate ?? "").trim();
|
|
261
|
+
if (!url || dedupe.has(url)) continue;
|
|
262
|
+
dedupe.add(url);
|
|
263
|
+
urls.push(url);
|
|
264
|
+
}
|
|
265
|
+
return urls;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseWecomBotInboundMessage(payload) {
|
|
269
|
+
if (!payload || typeof payload !== "object") return null;
|
|
270
|
+
const msgType = String(payload.msgtype ?? "").trim().toLowerCase();
|
|
271
|
+
if (!msgType) return null;
|
|
272
|
+
if (msgType === "stream") {
|
|
273
|
+
return {
|
|
274
|
+
kind: "stream-refresh",
|
|
275
|
+
streamId: String(payload?.stream?.id ?? "").trim(),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const msgId = String(payload.msgid ?? "").trim() || `wecom-bot-${Date.now()}`;
|
|
280
|
+
const fromUser = String(payload?.from?.userid ?? "").trim();
|
|
281
|
+
const chatType = String(payload.chattype ?? "single").trim().toLowerCase() || "single";
|
|
282
|
+
const chatId = String(payload.chatid ?? "").trim();
|
|
283
|
+
const responseUrl = String(payload.response_url ?? "").trim();
|
|
284
|
+
let content = "";
|
|
285
|
+
const imageUrls = [];
|
|
286
|
+
|
|
287
|
+
if (msgType === "text") {
|
|
288
|
+
content = String(payload?.text?.content ?? "").trim();
|
|
289
|
+
} else if (msgType === "voice") {
|
|
290
|
+
content = String(payload?.voice?.content ?? "").trim();
|
|
291
|
+
} else if (msgType === "link") {
|
|
292
|
+
const title = String(payload?.link?.title ?? "").trim();
|
|
293
|
+
const description = String(payload?.link?.description ?? "").trim();
|
|
294
|
+
const url = String(payload?.link?.url ?? "").trim();
|
|
295
|
+
content = [title ? `[链接] ${title}` : "", description, url].filter(Boolean).join("\n").trim();
|
|
296
|
+
} else if (msgType === "location") {
|
|
297
|
+
const latitude = String(payload?.location?.latitude ?? "").trim();
|
|
298
|
+
const longitude = String(payload?.location?.longitude ?? "").trim();
|
|
299
|
+
const name = String(payload?.location?.name ?? payload?.location?.label ?? "").trim();
|
|
300
|
+
content = name ? `[位置] ${name} (${latitude}, ${longitude})` : `[位置] ${latitude}, ${longitude}`;
|
|
301
|
+
} else if (msgType === "image") {
|
|
302
|
+
imageUrls.push(...collectWecomBotImageUrls(payload?.image));
|
|
303
|
+
content = "[图片]";
|
|
304
|
+
} else if (msgType === "mixed") {
|
|
305
|
+
const items = Array.isArray(payload?.mixed?.msg_item) ? payload.mixed.msg_item : [];
|
|
306
|
+
const parts = [];
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
const itemType = String(item?.msgtype ?? "").trim().toLowerCase();
|
|
309
|
+
if (itemType === "text") {
|
|
310
|
+
const text = String(item?.text?.content ?? "").trim();
|
|
311
|
+
if (text) parts.push(text);
|
|
312
|
+
} else if (itemType === "image") {
|
|
313
|
+
const itemImageUrls = collectWecomBotImageUrls(item?.image);
|
|
314
|
+
if (itemImageUrls.length > 0) {
|
|
315
|
+
imageUrls.push(...itemImageUrls);
|
|
316
|
+
parts.push("[图片]");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
content = parts.join("\n").trim();
|
|
321
|
+
} else if (msgType === "event") {
|
|
322
|
+
return {
|
|
323
|
+
kind: "event",
|
|
324
|
+
eventType: String(payload?.event?.event_type ?? payload?.event ?? "").trim(),
|
|
325
|
+
fromUser,
|
|
326
|
+
};
|
|
327
|
+
} else {
|
|
328
|
+
return {
|
|
329
|
+
kind: "unsupported",
|
|
330
|
+
msgType,
|
|
331
|
+
fromUser,
|
|
332
|
+
msgId,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!fromUser) {
|
|
337
|
+
return {
|
|
338
|
+
kind: "invalid",
|
|
339
|
+
reason: "missing-from-user",
|
|
340
|
+
msgType,
|
|
341
|
+
msgId,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
kind: "message",
|
|
347
|
+
msgType,
|
|
348
|
+
msgId,
|
|
349
|
+
fromUser,
|
|
350
|
+
chatType,
|
|
351
|
+
chatId,
|
|
352
|
+
responseUrl,
|
|
353
|
+
content,
|
|
354
|
+
imageUrls,
|
|
355
|
+
isGroupChat: chatType === "group" || Boolean(chatId),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function describeWecomBotParsedMessage(parsed) {
|
|
360
|
+
if (!parsed || typeof parsed !== "object") return "unknown";
|
|
361
|
+
if (parsed.kind === "message") {
|
|
362
|
+
const imageCount = Array.isArray(parsed.imageUrls) ? parsed.imageUrls.length : 0;
|
|
363
|
+
const imageSuffix = imageCount > 0 ? ` images=${imageCount}` : "";
|
|
364
|
+
return `message msgType=${parsed.msgType || "unknown"} from=${parsed.fromUser || "unknown"} msgId=${parsed.msgId || "n/a"}${imageSuffix}`;
|
|
365
|
+
}
|
|
366
|
+
if (parsed.kind === "stream-refresh") {
|
|
367
|
+
return `stream-refresh streamId=${parsed.streamId || "unknown"}`;
|
|
368
|
+
}
|
|
369
|
+
if (parsed.kind === "unsupported") {
|
|
370
|
+
return `unsupported msgType=${parsed.msgType || "unknown"} from=${parsed.fromUser || "unknown"} msgId=${parsed.msgId || "n/a"}`;
|
|
371
|
+
}
|
|
372
|
+
if (parsed.kind === "invalid") {
|
|
373
|
+
return `invalid reason=${parsed.reason || "unknown"} msgType=${parsed.msgType || "unknown"} msgId=${parsed.msgId || "n/a"}`;
|
|
374
|
+
}
|
|
375
|
+
if (parsed.kind === "event") {
|
|
376
|
+
return `event eventType=${parsed.eventType || "unknown"} from=${parsed.fromUser || "unknown"}`;
|
|
377
|
+
}
|
|
378
|
+
return parsed.kind || "unknown";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function requireEnv(name, fallback) {
|
|
382
|
+
const v = process.env[name];
|
|
383
|
+
if (v == null || v === "") return fallback;
|
|
384
|
+
return v;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function asNumber(v, fallback = null) {
|
|
388
|
+
if (v == null) return fallback;
|
|
389
|
+
const n = Number(v);
|
|
390
|
+
return Number.isFinite(n) ? n : fallback;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function scheduleTempFileCleanup(filePath, logger, delayMs = WECOM_TEMP_FILE_RETENTION_MS) {
|
|
394
|
+
if (!filePath) return;
|
|
395
|
+
const timer = setTimeout(() => {
|
|
396
|
+
unlink(filePath).catch((err) => {
|
|
397
|
+
logger?.warn?.(`wecom: failed to cleanup temp file ${filePath}: ${String(err?.message || err)}`);
|
|
398
|
+
});
|
|
399
|
+
}, delayMs);
|
|
400
|
+
timer.unref?.();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 企业微信 access_token 缓存(支持多账户)
|
|
404
|
+
const accessTokenCaches = new Map(); // key: corpId, value: { token, expiresAt, refreshPromise }
|
|
405
|
+
|
|
406
|
+
async function getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger }) {
|
|
407
|
+
const cacheKey = corpId;
|
|
408
|
+
let cache = accessTokenCaches.get(cacheKey);
|
|
409
|
+
|
|
410
|
+
if (!cache) {
|
|
411
|
+
cache = { token: null, expiresAt: 0, refreshPromise: null };
|
|
412
|
+
accessTokenCaches.set(cacheKey, cache);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
if (cache.token && cache.expiresAt > now + 60000) {
|
|
417
|
+
return cache.token;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 如果已有刷新在进行中,等待它完成
|
|
421
|
+
if (cache.refreshPromise) {
|
|
422
|
+
return cache.refreshPromise;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
cache.refreshPromise = (async () => {
|
|
426
|
+
try {
|
|
427
|
+
const tokenUrl = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(corpSecret)}`;
|
|
428
|
+
const tokenRes = await fetchWithRetry(tokenUrl, {}, 3, 1000, { proxyUrl, logger });
|
|
429
|
+
const tokenJson = await tokenRes.json();
|
|
430
|
+
if (!tokenJson?.access_token) {
|
|
431
|
+
throw new Error(`WeCom gettoken failed: ${JSON.stringify(tokenJson)}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
cache.token = tokenJson.access_token;
|
|
435
|
+
cache.expiresAt = Date.now() + (tokenJson.expires_in || 7200) * 1000;
|
|
436
|
+
|
|
437
|
+
return cache.token;
|
|
438
|
+
} finally {
|
|
439
|
+
cache.refreshPromise = null;
|
|
440
|
+
}
|
|
441
|
+
})();
|
|
442
|
+
|
|
443
|
+
return cache.refreshPromise;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Markdown 转换为企业微信纯文本
|
|
447
|
+
// 企业微信不支持 Markdown 渲染,需要转换为可读的纯文本格式
|
|
448
|
+
function markdownToWecomText(markdown) {
|
|
449
|
+
if (!markdown) return markdown;
|
|
450
|
+
|
|
451
|
+
let text = markdown;
|
|
452
|
+
|
|
453
|
+
// 移除代码块标记,保留内容并添加缩进
|
|
454
|
+
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
|
|
455
|
+
const lines = code.trim().split('\n').map(line => ' ' + line).join('\n');
|
|
456
|
+
return lang ? `[${lang}]\n${lines}` : lines;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// 移除行内代码标记
|
|
460
|
+
text = text.replace(/`([^`]+)`/g, '$1');
|
|
461
|
+
|
|
462
|
+
// 转换标题为带符号的格式
|
|
463
|
+
text = text.replace(/^### (.+)$/gm, '▸ $1');
|
|
464
|
+
text = text.replace(/^## (.+)$/gm, '■ $1');
|
|
465
|
+
text = text.replace(/^# (.+)$/gm, '◆ $1');
|
|
466
|
+
|
|
467
|
+
// 移除粗体/斜体标记,保留内容
|
|
468
|
+
text = text.replace(/\*\*\*([^*]+)\*\*\*/g, '$1');
|
|
469
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
470
|
+
text = text.replace(/\*([^*]+)\*/g, '$1');
|
|
471
|
+
text = text.replace(/___([^_]+)___/g, '$1');
|
|
472
|
+
text = text.replace(/__([^_]+)__/g, '$1');
|
|
473
|
+
text = text.replace(/_([^_]+)_/g, '$1');
|
|
474
|
+
|
|
475
|
+
// 转换链接为 "文字 (URL)" 格式
|
|
476
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
|
|
477
|
+
|
|
478
|
+
// 转换无序列表标记
|
|
479
|
+
text = text.replace(/^[\*\-] /gm, '• ');
|
|
480
|
+
|
|
481
|
+
// 转换有序列表(保持原样,数字已经可读)
|
|
482
|
+
|
|
483
|
+
// 转换水平线
|
|
484
|
+
text = text.replace(/^[-*_]{3,}$/gm, '────────────');
|
|
485
|
+
|
|
486
|
+
// 移除图片标记,保留 alt 文字
|
|
487
|
+
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '[图片: $1]');
|
|
488
|
+
|
|
489
|
+
// 清理多余空行(保留最多两个连续换行)
|
|
490
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
491
|
+
|
|
492
|
+
return text.trim();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isAgentFailureText(text) {
|
|
496
|
+
const normalized = String(text ?? "").trim().toLowerCase();
|
|
497
|
+
if (!normalized) return true;
|
|
498
|
+
return normalized.includes("request was aborted") || normalized.includes("fetch failed");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function sleep(ms) {
|
|
502
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
506
|
+
let timer = null;
|
|
507
|
+
return Promise.race([
|
|
508
|
+
promise,
|
|
509
|
+
new Promise((_, reject) => {
|
|
510
|
+
timer = setTimeout(() => {
|
|
511
|
+
reject(new Error(timeoutMessage || `Operation timed out after ${timeoutMs}ms`));
|
|
512
|
+
}, timeoutMs);
|
|
513
|
+
}),
|
|
514
|
+
]).finally(() => {
|
|
515
|
+
if (timer) clearTimeout(timer);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isDispatchTimeoutError(err) {
|
|
520
|
+
const text = String(err?.message ?? err ?? "").toLowerCase();
|
|
521
|
+
return text.includes("dispatch timed out after") || text.includes("operation timed out after");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function normalizeAssistantReplyText(text) {
|
|
525
|
+
if (text == null) return "";
|
|
526
|
+
return String(text)
|
|
527
|
+
.replace(/\[\[\s*reply_to(?:_|:|\s*)current\s*\]\]/gi, "")
|
|
528
|
+
.replace(/\[\[\s*reply_to\s*:\s*current\s*\]\]/gi, "")
|
|
529
|
+
.trim();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function extractAssistantTextFromTranscriptMessage(message) {
|
|
533
|
+
if (!message || typeof message !== "object") return "";
|
|
534
|
+
if (message.role !== "assistant") return "";
|
|
535
|
+
const stopReason = String(message.stopReason ?? "").trim().toLowerCase();
|
|
536
|
+
if (stopReason === "error" || stopReason === "aborted") return "";
|
|
537
|
+
|
|
538
|
+
const content = message.content;
|
|
539
|
+
if (typeof content === "string") {
|
|
540
|
+
return normalizeAssistantReplyText(content);
|
|
541
|
+
}
|
|
542
|
+
if (!Array.isArray(content)) return "";
|
|
543
|
+
|
|
544
|
+
const chunks = [];
|
|
545
|
+
for (const block of content) {
|
|
546
|
+
if (typeof block === "string") {
|
|
547
|
+
const text = normalizeAssistantReplyText(block);
|
|
548
|
+
if (text) chunks.push(text);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (!block || typeof block !== "object") continue;
|
|
552
|
+
const blockType = String(block.type ?? "").trim().toLowerCase();
|
|
553
|
+
if (!["text", "output_text", "markdown", "final_text"].includes(blockType)) continue;
|
|
554
|
+
const text = normalizeAssistantReplyText(block.text);
|
|
555
|
+
if (text) chunks.push(text);
|
|
556
|
+
}
|
|
557
|
+
return normalizeAssistantReplyText(chunks.join("\n").trim());
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function pruneDeliveredTranscriptReplyCache(now = Date.now()) {
|
|
561
|
+
for (const [cacheKey, expiresAt] of DELIVERED_TRANSCRIPT_REPLY_CACHE.entries()) {
|
|
562
|
+
if (expiresAt <= now) DELIVERED_TRANSCRIPT_REPLY_CACHE.delete(cacheKey);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function markTranscriptReplyDelivered(sessionId, transcriptMessageId) {
|
|
567
|
+
const cacheKey = `${String(sessionId ?? "").trim().toLowerCase()}:${String(transcriptMessageId ?? "").trim()}`;
|
|
568
|
+
if (!cacheKey) return;
|
|
569
|
+
pruneDeliveredTranscriptReplyCache();
|
|
570
|
+
DELIVERED_TRANSCRIPT_REPLY_CACHE.set(cacheKey, Date.now() + TRANSCRIPT_REPLY_CACHE_TTL_MS);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function hasTranscriptReplyBeenDelivered(sessionId, transcriptMessageId) {
|
|
574
|
+
const cacheKey = `${String(sessionId ?? "").trim().toLowerCase()}:${String(transcriptMessageId ?? "").trim()}`;
|
|
575
|
+
if (!cacheKey) return false;
|
|
576
|
+
pruneDeliveredTranscriptReplyCache();
|
|
577
|
+
const expiresAt = DELIVERED_TRANSCRIPT_REPLY_CACHE.get(cacheKey);
|
|
578
|
+
return typeof expiresAt === "number" && expiresAt > Date.now();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function resolveSessionTranscriptFilePath({ storePath, sessionKey, sessionId, logger }) {
|
|
582
|
+
const fallbackPath = join(dirname(storePath), `${sessionId}.jsonl`);
|
|
583
|
+
try {
|
|
584
|
+
const raw = await readFile(storePath, "utf8");
|
|
585
|
+
const store = JSON.parse(raw);
|
|
586
|
+
if (!store || typeof store !== "object") return fallbackPath;
|
|
587
|
+
const entry =
|
|
588
|
+
store?.[sessionKey] ??
|
|
589
|
+
Object.values(store).find((value) => value?.sessionId === sessionId && typeof value?.sessionFile === "string");
|
|
590
|
+
const sessionFile = String(entry?.sessionFile ?? "").trim();
|
|
591
|
+
if (!sessionFile) return fallbackPath;
|
|
592
|
+
if (isAbsolute(sessionFile)) return sessionFile;
|
|
593
|
+
return join(dirname(storePath), sessionFile);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
logger?.warn?.(
|
|
596
|
+
`wecom: failed to resolve session transcript path from store (${sessionKey}): ${String(err?.message || err)}`,
|
|
597
|
+
);
|
|
598
|
+
return fallbackPath;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function readTranscriptAppendedChunk(filePath, offset) {
|
|
603
|
+
let fileStat;
|
|
604
|
+
try {
|
|
605
|
+
fileStat = await stat(filePath);
|
|
606
|
+
} catch {
|
|
607
|
+
return { nextOffset: offset, chunk: "" };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const fileSize = Number(fileStat.size ?? 0);
|
|
611
|
+
if (!Number.isFinite(fileSize) || fileSize <= offset) {
|
|
612
|
+
return { nextOffset: offset, chunk: "" };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const readLength = fileSize - offset;
|
|
616
|
+
const handle = await open(filePath, "r");
|
|
617
|
+
try {
|
|
618
|
+
const buffer = Buffer.alloc(readLength);
|
|
619
|
+
await handle.read(buffer, 0, readLength, offset);
|
|
620
|
+
return { nextOffset: fileSize, chunk: buffer.toString("utf8") };
|
|
621
|
+
} finally {
|
|
622
|
+
await handle.close();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function parseLateAssistantReplyFromTranscriptLine(line, minTimestamp = 0) {
|
|
627
|
+
if (!line?.trim()) return null;
|
|
628
|
+
try {
|
|
629
|
+
const entry = JSON.parse(line);
|
|
630
|
+
if (entry?.type !== "message") return null;
|
|
631
|
+
const message = entry?.message;
|
|
632
|
+
const text = extractAssistantTextFromTranscriptMessage(message);
|
|
633
|
+
if (!text || isAgentFailureText(text)) return null;
|
|
634
|
+
const timestamp = Number(message?.timestamp ?? Date.parse(String(entry?.timestamp ?? "")) ?? 0);
|
|
635
|
+
if (minTimestamp > 0 && Number.isFinite(timestamp) && timestamp > 0 && timestamp + 1000 < minTimestamp) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
const transcriptMessageId = String(entry?.id ?? "").trim() || `${timestamp || Date.now()}-${text.slice(0, 32)}`;
|
|
639
|
+
return {
|
|
640
|
+
transcriptMessageId,
|
|
641
|
+
text,
|
|
642
|
+
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
|
|
643
|
+
};
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function isWecomApiUrl(url) {
|
|
650
|
+
const raw = typeof url === "string" ? url : String(url ?? "");
|
|
651
|
+
if (!raw) return false;
|
|
652
|
+
try {
|
|
653
|
+
const parsed = new URL(raw);
|
|
654
|
+
return parsed.hostname === "qyapi.weixin.qq.com";
|
|
655
|
+
} catch {
|
|
656
|
+
return raw.includes("qyapi.weixin.qq.com");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isLikelyHttpProxyUrl(proxyUrl) {
|
|
661
|
+
return /^https?:\/\/\S+$/i.test(proxyUrl);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function sanitizeProxyForLog(proxyUrl) {
|
|
665
|
+
const raw = String(proxyUrl ?? "").trim();
|
|
666
|
+
if (!raw) return "";
|
|
667
|
+
try {
|
|
668
|
+
const parsed = new URL(raw);
|
|
669
|
+
if (parsed.username || parsed.password) {
|
|
670
|
+
parsed.username = "***";
|
|
671
|
+
parsed.password = "***";
|
|
672
|
+
}
|
|
673
|
+
return parsed.toString();
|
|
674
|
+
} catch {
|
|
675
|
+
return raw;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function resolveWecomProxyDispatcher(proxyUrl, logger) {
|
|
680
|
+
const normalized = String(proxyUrl ?? "").trim();
|
|
681
|
+
if (!normalized) return null;
|
|
682
|
+
const printableProxy = sanitizeProxyForLog(normalized);
|
|
683
|
+
if (WECOM_PROXY_DISPATCHER_CACHE.has(normalized)) {
|
|
684
|
+
return WECOM_PROXY_DISPATCHER_CACHE.get(normalized);
|
|
685
|
+
}
|
|
686
|
+
if (!isLikelyHttpProxyUrl(normalized)) {
|
|
687
|
+
if (!INVALID_PROXY_CACHE.has(normalized)) {
|
|
688
|
+
INVALID_PROXY_CACHE.add(normalized);
|
|
689
|
+
logger?.warn?.(`wecom: outboundProxy ignored (invalid url): ${printableProxy}`);
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const dispatcher = new ProxyAgent(normalized);
|
|
695
|
+
WECOM_PROXY_DISPATCHER_CACHE.set(normalized, dispatcher);
|
|
696
|
+
logger?.info?.(`wecom: outbound proxy enabled (${printableProxy})`);
|
|
697
|
+
return dispatcher;
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (!INVALID_PROXY_CACHE.has(normalized)) {
|
|
700
|
+
INVALID_PROXY_CACHE.add(normalized);
|
|
701
|
+
logger?.warn?.(
|
|
702
|
+
`wecom: outboundProxy init failed (${printableProxy}): ${String(err?.message || err)}`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function attachWecomProxyDispatcher(url, options = {}, { proxyUrl, logger } = {}) {
|
|
710
|
+
const shouldForceProxy = options?.forceProxy === true;
|
|
711
|
+
if (!isWecomApiUrl(url) && !shouldForceProxy) return options;
|
|
712
|
+
if (options?.dispatcher) return options;
|
|
713
|
+
const dispatcher = resolveWecomProxyDispatcher(proxyUrl, logger);
|
|
714
|
+
if (!dispatcher) return options;
|
|
715
|
+
const { forceProxy, ...restOptions } = options || {};
|
|
716
|
+
return {
|
|
717
|
+
...restOptions,
|
|
718
|
+
dispatcher,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 带重试机制的 fetch 包装函数
|
|
723
|
+
async function fetchWithRetry(url, options = {}, maxRetries = 3, initialDelay = 1000, requestContext = {}) {
|
|
724
|
+
let lastError = null;
|
|
725
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
726
|
+
try {
|
|
727
|
+
const requestOptions = attachWecomProxyDispatcher(url, options, requestContext);
|
|
728
|
+
const res = await fetch(url, requestOptions);
|
|
729
|
+
|
|
730
|
+
// 如果是 2xx 以外的状态码,可能需要重试(根据业务逻辑判断)
|
|
731
|
+
if (!res.ok && attempt < maxRetries) {
|
|
732
|
+
const delay = initialDelay * Math.pow(2, attempt);
|
|
733
|
+
await sleep(delay);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 如果是企业微信 API,检查 errcode
|
|
738
|
+
const contentType = res.headers.get("content-type") || "";
|
|
739
|
+
if (contentType.includes("application/json")) {
|
|
740
|
+
const json = await res.clone().json();
|
|
741
|
+
// errcode: -1 表示系统繁忙,建议重试
|
|
742
|
+
if (json?.errcode === -1 && attempt < maxRetries) {
|
|
743
|
+
const delay = initialDelay * Math.pow(2, attempt);
|
|
744
|
+
await sleep(delay);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return res;
|
|
750
|
+
} catch (err) {
|
|
751
|
+
lastError = err;
|
|
752
|
+
if (attempt < maxRetries) {
|
|
753
|
+
const delay = initialDelay * Math.pow(2, attempt);
|
|
754
|
+
await sleep(delay);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
throw lastError || new Error(`Fetch failed after ${maxRetries} retries`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function runProcessWithTimeout({ command, args, timeoutMs = 15000, allowNonZeroExitCode = false }) {
|
|
763
|
+
return new Promise((resolve, reject) => {
|
|
764
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
765
|
+
let stdout = "";
|
|
766
|
+
let stderr = "";
|
|
767
|
+
let timedOut = false;
|
|
768
|
+
const timer =
|
|
769
|
+
timeoutMs > 0
|
|
770
|
+
? setTimeout(() => {
|
|
771
|
+
timedOut = true;
|
|
772
|
+
child.kill("SIGKILL");
|
|
773
|
+
}, timeoutMs)
|
|
774
|
+
: null;
|
|
775
|
+
|
|
776
|
+
child.stdout.on("data", (chunk) => {
|
|
777
|
+
stdout += String(chunk);
|
|
778
|
+
if (stdout.length > 4000) stdout = stdout.slice(-4000);
|
|
779
|
+
});
|
|
780
|
+
child.stderr.on("data", (chunk) => {
|
|
781
|
+
stderr += String(chunk);
|
|
782
|
+
if (stderr.length > 4000) stderr = stderr.slice(-4000);
|
|
783
|
+
});
|
|
784
|
+
child.on("error", (err) => {
|
|
785
|
+
if (timer) clearTimeout(timer);
|
|
786
|
+
reject(err);
|
|
787
|
+
});
|
|
788
|
+
child.on("close", (code) => {
|
|
789
|
+
if (timer) clearTimeout(timer);
|
|
790
|
+
if (timedOut) {
|
|
791
|
+
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (code !== 0 && !allowNonZeroExitCode) {
|
|
795
|
+
reject(new Error(`${command} exited with code ${code}: ${stderr.trim().slice(0, 500)}`));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function checkCommandAvailable(command) {
|
|
804
|
+
const normalized = String(command ?? "").trim();
|
|
805
|
+
if (!normalized) return false;
|
|
806
|
+
if (COMMAND_PATH_CHECK_CACHE.has(normalized)) {
|
|
807
|
+
return COMMAND_PATH_CHECK_CACHE.get(normalized);
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
await runProcessWithTimeout({
|
|
811
|
+
command: normalized,
|
|
812
|
+
args: ["--help"],
|
|
813
|
+
timeoutMs: 4000,
|
|
814
|
+
allowNonZeroExitCode: true,
|
|
815
|
+
});
|
|
816
|
+
COMMAND_PATH_CHECK_CACHE.set(normalized, true);
|
|
817
|
+
return true;
|
|
818
|
+
} catch (err) {
|
|
819
|
+
COMMAND_PATH_CHECK_CACHE.set(normalized, false);
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function ensureFfmpegAvailable(logger) {
|
|
825
|
+
if (FFMPEG_PATH_CHECK_CACHE.checked) return FFMPEG_PATH_CHECK_CACHE.available;
|
|
826
|
+
const available = await checkCommandAvailable("ffmpeg");
|
|
827
|
+
FFMPEG_PATH_CHECK_CACHE.checked = true;
|
|
828
|
+
FFMPEG_PATH_CHECK_CACHE.available = available;
|
|
829
|
+
if (!available) {
|
|
830
|
+
logger?.warn?.("wecom: ffmpeg not available");
|
|
831
|
+
}
|
|
832
|
+
return available;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function resolveLocalWhisperCommand({ voiceConfig, logger }) {
|
|
836
|
+
const provider = String(voiceConfig.provider ?? "").trim().toLowerCase();
|
|
837
|
+
const explicitCommand = String(voiceConfig.command ?? "").trim();
|
|
838
|
+
const fallbackCandidates =
|
|
839
|
+
provider === "local-whisper"
|
|
840
|
+
? ["whisper"]
|
|
841
|
+
: provider === "local-whisper-cli"
|
|
842
|
+
? ["whisper-cli"]
|
|
843
|
+
: [];
|
|
844
|
+
const candidates = explicitCommand ? [explicitCommand, ...fallbackCandidates] : fallbackCandidates;
|
|
845
|
+
|
|
846
|
+
if (candidates.length === 0) {
|
|
847
|
+
throw new Error(
|
|
848
|
+
`unsupported voice transcription provider: ${provider || "unknown"} (supported: local-whisper-cli/local-whisper)`,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for (const cmd of candidates) {
|
|
853
|
+
if (await checkCommandAvailable(cmd)) {
|
|
854
|
+
if (explicitCommand && cmd !== explicitCommand) {
|
|
855
|
+
logger?.warn?.(`wecom: voice command ${explicitCommand} unavailable, fallback to ${cmd}`);
|
|
856
|
+
}
|
|
857
|
+
return cmd;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
throw new Error(`local transcription command not found: ${candidates.join(" / ")}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function resolveWecomVoiceTranscriptionConfig(api) {
|
|
865
|
+
const cfg = api?.config ?? {};
|
|
866
|
+
return resolveVoiceTranscriptionConfig({
|
|
867
|
+
channelConfig: cfg?.channels?.wecom,
|
|
868
|
+
envVars: cfg?.env?.vars ?? {},
|
|
869
|
+
processEnv: process.env,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function transcodeAudioToWav({
|
|
874
|
+
buffer,
|
|
875
|
+
inputContentType,
|
|
876
|
+
inputFileName,
|
|
877
|
+
logger,
|
|
878
|
+
timeoutMs = 30000,
|
|
879
|
+
}) {
|
|
880
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
881
|
+
await mkdir(tempDir, { recursive: true });
|
|
882
|
+
const nonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
883
|
+
const inputExt = pickAudioFileExtension({ contentType: inputContentType, fileName: inputFileName });
|
|
884
|
+
const inputPath = join(tempDir, `voice-input-${nonce}${inputExt || ".bin"}`);
|
|
885
|
+
const outputPath = join(tempDir, `voice-output-${nonce}.wav`);
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
await writeFile(inputPath, buffer);
|
|
889
|
+
await runProcessWithTimeout({
|
|
890
|
+
command: "ffmpeg",
|
|
891
|
+
args: [
|
|
892
|
+
"-y",
|
|
893
|
+
"-hide_banner",
|
|
894
|
+
"-loglevel",
|
|
895
|
+
"error",
|
|
896
|
+
"-i",
|
|
897
|
+
inputPath,
|
|
898
|
+
"-ac",
|
|
899
|
+
"1",
|
|
900
|
+
"-ar",
|
|
901
|
+
"16000",
|
|
902
|
+
"-f",
|
|
903
|
+
"wav",
|
|
904
|
+
outputPath,
|
|
905
|
+
],
|
|
906
|
+
timeoutMs,
|
|
907
|
+
});
|
|
908
|
+
const outputBuffer = await readFile(outputPath);
|
|
909
|
+
logger?.info?.(`wecom: transcoded voice to wav size=${outputBuffer.length} bytes`);
|
|
910
|
+
return {
|
|
911
|
+
buffer: outputBuffer,
|
|
912
|
+
contentType: "audio/wav",
|
|
913
|
+
fileName: `voice-${Date.now()}.wav`,
|
|
914
|
+
};
|
|
915
|
+
} finally {
|
|
916
|
+
await Promise.allSettled([unlink(inputPath), unlink(outputPath)]);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function transcribeWithWhisperCli({
|
|
921
|
+
command,
|
|
922
|
+
modelPath,
|
|
923
|
+
audioPath,
|
|
924
|
+
language,
|
|
925
|
+
prompt,
|
|
926
|
+
timeoutMs,
|
|
927
|
+
}) {
|
|
928
|
+
if (!modelPath) {
|
|
929
|
+
throw new Error("local-whisper-cli requires voiceTranscription.modelPath");
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
933
|
+
await mkdir(tempDir, { recursive: true });
|
|
934
|
+
const outputBase = join(tempDir, `voice-whisper-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
935
|
+
const outputTxt = `${outputBase}.txt`;
|
|
936
|
+
|
|
937
|
+
const args = ["-m", modelPath, "-f", audioPath, "-otxt", "-of", outputBase, "--no-prints"];
|
|
938
|
+
if (language) args.push("-l", language);
|
|
939
|
+
if (prompt) args.push("--prompt", prompt);
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
await runProcessWithTimeout({
|
|
943
|
+
command,
|
|
944
|
+
args,
|
|
945
|
+
timeoutMs,
|
|
946
|
+
});
|
|
947
|
+
const transcript = String(await readFile(outputTxt, "utf8")).trim();
|
|
948
|
+
if (!transcript) {
|
|
949
|
+
throw new Error("whisper-cli transcription output is empty");
|
|
950
|
+
}
|
|
951
|
+
return transcript;
|
|
952
|
+
} finally {
|
|
953
|
+
await Promise.allSettled([unlink(outputTxt)]);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function transcribeWithWhisperPython({
|
|
958
|
+
command,
|
|
959
|
+
model,
|
|
960
|
+
audioPath,
|
|
961
|
+
language,
|
|
962
|
+
prompt,
|
|
963
|
+
timeoutMs,
|
|
964
|
+
}) {
|
|
965
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
966
|
+
await mkdir(tempDir, { recursive: true });
|
|
967
|
+
const audioBaseName = basename(audioPath, extname(audioPath));
|
|
968
|
+
const outputTxt = join(tempDir, `${audioBaseName}.txt`);
|
|
969
|
+
|
|
970
|
+
const args = [
|
|
971
|
+
audioPath,
|
|
972
|
+
"--model",
|
|
973
|
+
model || "base",
|
|
974
|
+
"--output_format",
|
|
975
|
+
"txt",
|
|
976
|
+
"--output_dir",
|
|
977
|
+
tempDir,
|
|
978
|
+
"--task",
|
|
979
|
+
"transcribe",
|
|
980
|
+
];
|
|
981
|
+
if (language) args.push("--language", language);
|
|
982
|
+
if (prompt) args.push("--initial_prompt", prompt);
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
await runProcessWithTimeout({
|
|
986
|
+
command,
|
|
987
|
+
args,
|
|
988
|
+
timeoutMs,
|
|
989
|
+
});
|
|
990
|
+
const transcript = String(await readFile(outputTxt, "utf8")).trim();
|
|
991
|
+
if (!transcript) {
|
|
992
|
+
throw new Error("whisper transcription output is empty");
|
|
993
|
+
}
|
|
994
|
+
return transcript;
|
|
995
|
+
} finally {
|
|
996
|
+
await Promise.allSettled([unlink(outputTxt)]);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function transcribeInboundVoice({
|
|
1001
|
+
api,
|
|
1002
|
+
buffer,
|
|
1003
|
+
contentType,
|
|
1004
|
+
mediaId,
|
|
1005
|
+
voiceConfig,
|
|
1006
|
+
}) {
|
|
1007
|
+
if (!voiceConfig.enabled) {
|
|
1008
|
+
throw new Error("voice transcription is disabled");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
let audioBuffer = buffer;
|
|
1012
|
+
let normalizedContentType = normalizeAudioContentType(contentType) || "application/octet-stream";
|
|
1013
|
+
let fileName = `voice-${mediaId}${pickAudioFileExtension({
|
|
1014
|
+
contentType: normalizedContentType,
|
|
1015
|
+
fileName: `voice-${mediaId}`,
|
|
1016
|
+
})}`;
|
|
1017
|
+
|
|
1018
|
+
if (audioBuffer.length > voiceConfig.maxBytes) {
|
|
1019
|
+
throw new Error(`audio size ${audioBuffer.length} exceeds maxBytes ${voiceConfig.maxBytes}`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const isWav = normalizedContentType === "audio/wav" || normalizedContentType === "audio/x-wav";
|
|
1023
|
+
const unsupportedDirect = !isLocalVoiceInputTypeDirectlySupported(normalizedContentType);
|
|
1024
|
+
const shouldTranscode = unsupportedDirect || (voiceConfig.transcodeToWav === true && !isWav);
|
|
1025
|
+
if (shouldTranscode) {
|
|
1026
|
+
if (!voiceConfig.ffmpegEnabled) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
`content type ${normalizedContentType || "unknown"} requires ffmpeg conversion but ffmpegEnabled=false`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
const ffmpegAvailable = await ensureFfmpegAvailable(api.logger);
|
|
1032
|
+
if (!ffmpegAvailable) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`unsupported content type ${normalizedContentType || "unknown"} and ffmpeg not available`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
const transcoded = await transcodeAudioToWav({
|
|
1038
|
+
buffer: audioBuffer,
|
|
1039
|
+
inputContentType: normalizedContentType,
|
|
1040
|
+
inputFileName: fileName,
|
|
1041
|
+
logger: api.logger,
|
|
1042
|
+
timeoutMs: Math.max(10000, Math.min(voiceConfig.timeoutMs, 45000)),
|
|
1043
|
+
});
|
|
1044
|
+
audioBuffer = transcoded.buffer;
|
|
1045
|
+
normalizedContentType = transcoded.contentType;
|
|
1046
|
+
fileName = transcoded.fileName;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const command = await resolveLocalWhisperCommand({ voiceConfig, logger: api.logger });
|
|
1050
|
+
const provider = String(voiceConfig.provider ?? "").trim().toLowerCase();
|
|
1051
|
+
|
|
1052
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
1053
|
+
await mkdir(tempDir, { recursive: true });
|
|
1054
|
+
const audioPath = join(
|
|
1055
|
+
tempDir,
|
|
1056
|
+
`voice-transcribe-${Date.now()}-${Math.random().toString(36).slice(2)}${pickAudioFileExtension({
|
|
1057
|
+
contentType: normalizedContentType,
|
|
1058
|
+
fileName,
|
|
1059
|
+
})}`,
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
await writeFile(audioPath, audioBuffer);
|
|
1063
|
+
try {
|
|
1064
|
+
if (provider === "local-whisper-cli") {
|
|
1065
|
+
if (voiceConfig.requireModelPath !== false && !voiceConfig.modelPath) {
|
|
1066
|
+
throw new Error(
|
|
1067
|
+
"voiceTranscription.modelPath is required for local-whisper-cli (or set requireModelPath=false)",
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
const transcript = await transcribeWithWhisperCli({
|
|
1071
|
+
command,
|
|
1072
|
+
modelPath: voiceConfig.modelPath,
|
|
1073
|
+
audioPath,
|
|
1074
|
+
language: voiceConfig.language,
|
|
1075
|
+
prompt: voiceConfig.prompt,
|
|
1076
|
+
timeoutMs: voiceConfig.timeoutMs,
|
|
1077
|
+
});
|
|
1078
|
+
return transcript;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (provider === "local-whisper") {
|
|
1082
|
+
const transcript = await transcribeWithWhisperPython({
|
|
1083
|
+
command,
|
|
1084
|
+
model: voiceConfig.model,
|
|
1085
|
+
audioPath,
|
|
1086
|
+
language: voiceConfig.language,
|
|
1087
|
+
prompt: voiceConfig.prompt,
|
|
1088
|
+
timeoutMs: voiceConfig.timeoutMs,
|
|
1089
|
+
});
|
|
1090
|
+
return transcript;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
throw new Error(`unsupported local provider ${provider}`);
|
|
1094
|
+
} finally {
|
|
1095
|
+
await Promise.allSettled([unlink(audioPath)]);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// 简单的限流器,防止触发企业微信 API 限流
|
|
1100
|
+
class RateLimiter {
|
|
1101
|
+
constructor({ maxConcurrent = 3, minInterval = 200 }) {
|
|
1102
|
+
this.maxConcurrent = maxConcurrent;
|
|
1103
|
+
this.minInterval = minInterval;
|
|
1104
|
+
this.running = 0;
|
|
1105
|
+
this.queue = [];
|
|
1106
|
+
this.lastExecution = 0;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async execute(fn) {
|
|
1110
|
+
return new Promise((resolve, reject) => {
|
|
1111
|
+
this.queue.push({ fn, resolve, reject });
|
|
1112
|
+
this.processQueue();
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async processQueue() {
|
|
1117
|
+
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const now = Date.now();
|
|
1122
|
+
const waitTime = Math.max(0, this.lastExecution + this.minInterval - now);
|
|
1123
|
+
|
|
1124
|
+
if (waitTime > 0) {
|
|
1125
|
+
setTimeout(() => this.processQueue(), waitTime);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
this.running++;
|
|
1130
|
+
this.lastExecution = Date.now();
|
|
1131
|
+
|
|
1132
|
+
const { fn, resolve, reject } = this.queue.shift();
|
|
1133
|
+
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await fn();
|
|
1136
|
+
resolve(result);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
reject(err);
|
|
1139
|
+
} finally {
|
|
1140
|
+
this.running--;
|
|
1141
|
+
this.processQueue();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// API 调用限流器(最多3并发,200ms间隔)
|
|
1147
|
+
const apiLimiter = new RateLimiter({ maxConcurrent: 3, minInterval: 200 });
|
|
1148
|
+
|
|
1149
|
+
// 消息处理限流器(最多2并发,适合 1GB 内存环境)
|
|
1150
|
+
const messageProcessLimiter = new RateLimiter({ maxConcurrent: 2, minInterval: 0 });
|
|
1151
|
+
|
|
1152
|
+
// 发送单条文本消息(内部函数,带限流)
|
|
1153
|
+
async function sendWecomTextSingle({
|
|
1154
|
+
corpId,
|
|
1155
|
+
corpSecret,
|
|
1156
|
+
agentId,
|
|
1157
|
+
toUser,
|
|
1158
|
+
text,
|
|
1159
|
+
logger,
|
|
1160
|
+
proxyUrl,
|
|
1161
|
+
}) {
|
|
1162
|
+
return apiLimiter.execute(async () => {
|
|
1163
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
1164
|
+
|
|
1165
|
+
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
1166
|
+
const body = {
|
|
1167
|
+
touser: toUser,
|
|
1168
|
+
msgtype: "text",
|
|
1169
|
+
agentid: agentId,
|
|
1170
|
+
text: { content: text },
|
|
1171
|
+
safe: 0,
|
|
1172
|
+
};
|
|
1173
|
+
const sendRes = await fetchWithRetry(
|
|
1174
|
+
sendUrl,
|
|
1175
|
+
{
|
|
1176
|
+
method: "POST",
|
|
1177
|
+
headers: { "Content-Type": "application/json" },
|
|
1178
|
+
body: JSON.stringify(body),
|
|
1179
|
+
},
|
|
1180
|
+
3,
|
|
1181
|
+
1000,
|
|
1182
|
+
{ proxyUrl, logger },
|
|
1183
|
+
);
|
|
1184
|
+
const sendJson = await sendRes.json();
|
|
1185
|
+
if (sendJson?.errcode !== 0) {
|
|
1186
|
+
throw new Error(`WeCom message/send failed: ${JSON.stringify(sendJson)}`);
|
|
1187
|
+
}
|
|
1188
|
+
logger?.info?.(`wecom: message sent ok (to=${toUser}, msgid=${sendJson?.msgid || "n/a"})`);
|
|
1189
|
+
return sendJson;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// 发送文本消息(支持自动分段)
|
|
1194
|
+
async function sendWecomText({ corpId, corpSecret, agentId, toUser, text, logger, proxyUrl }) {
|
|
1195
|
+
const chunks = splitWecomText(text);
|
|
1196
|
+
|
|
1197
|
+
logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
|
|
1198
|
+
|
|
1199
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
1200
|
+
logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
|
|
1201
|
+
await sendWecomTextSingle({ corpId, corpSecret, agentId, toUser, text: chunks[i], logger, proxyUrl });
|
|
1202
|
+
// 分段发送时添加间隔,避免触发限流
|
|
1203
|
+
if (i < chunks.length - 1) {
|
|
1204
|
+
await sleep(300);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// 上传临时素材到企业微信
|
|
1210
|
+
async function uploadWecomMedia({ corpId, corpSecret, type, buffer, filename, logger, proxyUrl }) {
|
|
1211
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
1212
|
+
const uploadUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${encodeURIComponent(accessToken)}&type=${encodeURIComponent(type)}`;
|
|
1213
|
+
|
|
1214
|
+
// 构建 multipart/form-data
|
|
1215
|
+
const boundary = "----WecomMediaUpload" + Date.now();
|
|
1216
|
+
const header = Buffer.from(
|
|
1217
|
+
`--${boundary}\r\n` +
|
|
1218
|
+
`Content-Disposition: form-data; name="media"; filename="${filename}"\r\n` +
|
|
1219
|
+
`Content-Type: application/octet-stream\r\n\r\n`
|
|
1220
|
+
);
|
|
1221
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
1222
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
1223
|
+
|
|
1224
|
+
const res = await fetchWithRetry(
|
|
1225
|
+
uploadUrl,
|
|
1226
|
+
{
|
|
1227
|
+
method: "POST",
|
|
1228
|
+
headers: {
|
|
1229
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
1230
|
+
},
|
|
1231
|
+
body,
|
|
1232
|
+
},
|
|
1233
|
+
3,
|
|
1234
|
+
1000,
|
|
1235
|
+
{ proxyUrl, logger },
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
const json = await res.json();
|
|
1239
|
+
if (json.errcode !== 0) {
|
|
1240
|
+
throw new Error(`WeCom media upload failed: ${JSON.stringify(json)}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return json.media_id;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// 发送图片消息(带限流)
|
|
1247
|
+
async function sendWecomImage({ corpId, corpSecret, agentId, toUser, mediaId, logger, proxyUrl }) {
|
|
1248
|
+
return apiLimiter.execute(async () => {
|
|
1249
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
1250
|
+
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
1251
|
+
|
|
1252
|
+
const body = {
|
|
1253
|
+
touser: toUser,
|
|
1254
|
+
msgtype: "image",
|
|
1255
|
+
agentid: agentId,
|
|
1256
|
+
image: { media_id: mediaId },
|
|
1257
|
+
safe: 0,
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
const sendRes = await fetchWithRetry(
|
|
1261
|
+
sendUrl,
|
|
1262
|
+
{
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
headers: { "Content-Type": "application/json" },
|
|
1265
|
+
body: JSON.stringify(body),
|
|
1266
|
+
},
|
|
1267
|
+
3,
|
|
1268
|
+
1000,
|
|
1269
|
+
{ proxyUrl, logger },
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
const sendJson = await sendRes.json();
|
|
1273
|
+
if (sendJson?.errcode !== 0) {
|
|
1274
|
+
throw new Error(`WeCom image send failed: ${JSON.stringify(sendJson)}`);
|
|
1275
|
+
}
|
|
1276
|
+
return sendJson;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// 发送视频消息(带限流)
|
|
1281
|
+
async function sendWecomVideo({
|
|
1282
|
+
corpId,
|
|
1283
|
+
corpSecret,
|
|
1284
|
+
agentId,
|
|
1285
|
+
toUser,
|
|
1286
|
+
mediaId,
|
|
1287
|
+
title,
|
|
1288
|
+
description,
|
|
1289
|
+
logger,
|
|
1290
|
+
proxyUrl,
|
|
1291
|
+
}) {
|
|
1292
|
+
return apiLimiter.execute(async () => {
|
|
1293
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
1294
|
+
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
1295
|
+
const body = {
|
|
1296
|
+
touser: toUser,
|
|
1297
|
+
msgtype: "video",
|
|
1298
|
+
agentid: agentId,
|
|
1299
|
+
video: {
|
|
1300
|
+
media_id: mediaId,
|
|
1301
|
+
...(title ? { title } : {}),
|
|
1302
|
+
...(description ? { description } : {}),
|
|
1303
|
+
},
|
|
1304
|
+
safe: 0,
|
|
1305
|
+
};
|
|
1306
|
+
const sendRes = await fetchWithRetry(
|
|
1307
|
+
sendUrl,
|
|
1308
|
+
{
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { "Content-Type": "application/json" },
|
|
1311
|
+
body: JSON.stringify(body),
|
|
1312
|
+
},
|
|
1313
|
+
3,
|
|
1314
|
+
1000,
|
|
1315
|
+
{ proxyUrl, logger },
|
|
1316
|
+
);
|
|
1317
|
+
const sendJson = await sendRes.json();
|
|
1318
|
+
if (sendJson?.errcode !== 0) {
|
|
1319
|
+
throw new Error(`WeCom video send failed: ${JSON.stringify(sendJson)}`);
|
|
1320
|
+
}
|
|
1321
|
+
return sendJson;
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 发送文件消息(带限流)
|
|
1326
|
+
async function sendWecomFile({ corpId, corpSecret, agentId, toUser, mediaId, logger, proxyUrl }) {
|
|
1327
|
+
return apiLimiter.execute(async () => {
|
|
1328
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
1329
|
+
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
1330
|
+
const body = {
|
|
1331
|
+
touser: toUser,
|
|
1332
|
+
msgtype: "file",
|
|
1333
|
+
agentid: agentId,
|
|
1334
|
+
file: { media_id: mediaId },
|
|
1335
|
+
safe: 0,
|
|
1336
|
+
};
|
|
1337
|
+
const sendRes = await fetchWithRetry(
|
|
1338
|
+
sendUrl,
|
|
1339
|
+
{
|
|
1340
|
+
method: "POST",
|
|
1341
|
+
headers: { "Content-Type": "application/json" },
|
|
1342
|
+
body: JSON.stringify(body),
|
|
1343
|
+
},
|
|
1344
|
+
3,
|
|
1345
|
+
1000,
|
|
1346
|
+
{ proxyUrl, logger },
|
|
1347
|
+
);
|
|
1348
|
+
const sendJson = await sendRes.json();
|
|
1349
|
+
if (sendJson?.errcode !== 0) {
|
|
1350
|
+
throw new Error(`WeCom file send failed: ${JSON.stringify(sendJson)}`);
|
|
1351
|
+
}
|
|
1352
|
+
return sendJson;
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// 从 URL 下载媒体文件
|
|
1357
|
+
async function fetchMediaFromUrl(url, { proxyUrl, logger, forceProxy = false, maxBytes = 10 * 1024 * 1024 } = {}) {
|
|
1358
|
+
const res = await fetchWithRetry(
|
|
1359
|
+
url,
|
|
1360
|
+
{
|
|
1361
|
+
headers: {
|
|
1362
|
+
"User-Agent": `OpenClaw-Wechat/${PLUGIN_VERSION}`,
|
|
1363
|
+
Accept: "*/*",
|
|
1364
|
+
},
|
|
1365
|
+
forceProxy,
|
|
1366
|
+
},
|
|
1367
|
+
3,
|
|
1368
|
+
1000,
|
|
1369
|
+
{ proxyUrl, logger },
|
|
1370
|
+
);
|
|
1371
|
+
if (!res.ok) {
|
|
1372
|
+
throw new Error(`Failed to fetch media from URL: ${res.status}`);
|
|
1373
|
+
}
|
|
1374
|
+
const contentLength = Number(res.headers.get("content-length") ?? 0);
|
|
1375
|
+
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxBytes) {
|
|
1376
|
+
throw new Error(`Media too large (${contentLength} bytes > ${maxBytes} bytes)`);
|
|
1377
|
+
}
|
|
1378
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
1379
|
+
if (buffer.length > maxBytes) {
|
|
1380
|
+
throw new Error(`Media too large (${buffer.length} bytes > ${maxBytes} bytes)`);
|
|
1381
|
+
}
|
|
1382
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
1383
|
+
return { buffer, contentType };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function detectImageContentTypeFromBuffer(buffer) {
|
|
1387
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) return "";
|
|
1388
|
+
// JPEG
|
|
1389
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
|
1390
|
+
// PNG
|
|
1391
|
+
if (
|
|
1392
|
+
buffer.length >= 8 &&
|
|
1393
|
+
buffer[0] === 0x89 &&
|
|
1394
|
+
buffer[1] === 0x50 &&
|
|
1395
|
+
buffer[2] === 0x4e &&
|
|
1396
|
+
buffer[3] === 0x47 &&
|
|
1397
|
+
buffer[4] === 0x0d &&
|
|
1398
|
+
buffer[5] === 0x0a &&
|
|
1399
|
+
buffer[6] === 0x1a &&
|
|
1400
|
+
buffer[7] === 0x0a
|
|
1401
|
+
) {
|
|
1402
|
+
return "image/png";
|
|
1403
|
+
}
|
|
1404
|
+
// GIF87a / GIF89a
|
|
1405
|
+
if (
|
|
1406
|
+
buffer.length >= 6 &&
|
|
1407
|
+
(buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
1408
|
+
) {
|
|
1409
|
+
return "image/gif";
|
|
1410
|
+
}
|
|
1411
|
+
// WEBP: RIFF....WEBP
|
|
1412
|
+
if (
|
|
1413
|
+
buffer.length >= 12 &&
|
|
1414
|
+
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
1415
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
1416
|
+
) {
|
|
1417
|
+
return "image/webp";
|
|
1418
|
+
}
|
|
1419
|
+
// BMP
|
|
1420
|
+
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return "image/bmp";
|
|
1421
|
+
// HEIC / HEIF (ISO BMFF ftyp brand)
|
|
1422
|
+
if (buffer.length >= 12) {
|
|
1423
|
+
const boxType = buffer.subarray(4, 8).toString("ascii");
|
|
1424
|
+
const brand = buffer.subarray(8, 12).toString("ascii").toLowerCase();
|
|
1425
|
+
if (boxType === "ftyp") {
|
|
1426
|
+
if (brand.startsWith("heic") || brand.startsWith("heix") || brand.startsWith("hevc") || brand.startsWith("hevx")) {
|
|
1427
|
+
return "image/heic";
|
|
1428
|
+
}
|
|
1429
|
+
if (brand.startsWith("mif1") || brand.startsWith("msf1")) {
|
|
1430
|
+
return "image/heif";
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return "";
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function pickImageFileExtension({ contentType, sourceUrl }) {
|
|
1438
|
+
const normalizedType = String(contentType ?? "")
|
|
1439
|
+
.trim()
|
|
1440
|
+
.toLowerCase()
|
|
1441
|
+
.split(";")[0]
|
|
1442
|
+
.trim();
|
|
1443
|
+
if (normalizedType.includes("png")) return ".png";
|
|
1444
|
+
if (normalizedType.includes("gif")) return ".gif";
|
|
1445
|
+
if (normalizedType.includes("webp")) return ".webp";
|
|
1446
|
+
if (normalizedType.includes("bmp")) return ".bmp";
|
|
1447
|
+
if (normalizedType.includes("heic")) return ".heic";
|
|
1448
|
+
if (normalizedType.includes("heif")) return ".heif";
|
|
1449
|
+
if (normalizedType.includes("jpg") || normalizedType.includes("jpeg")) return ".jpg";
|
|
1450
|
+
|
|
1451
|
+
const rawPath = String(sourceUrl ?? "").trim().split("?")[0].split("#")[0];
|
|
1452
|
+
const ext = extname(rawPath).trim().toLowerCase();
|
|
1453
|
+
if (ext && ext.length <= 8 && ext.length >= 2) return ext;
|
|
1454
|
+
return ".jpg";
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function resolveWecomOutboundMediaTarget({ mediaUrl, mediaType }) {
|
|
1458
|
+
const normalizedType = String(mediaType ?? "").trim().toLowerCase();
|
|
1459
|
+
const lowerUrl = String(mediaUrl ?? "").trim().toLowerCase();
|
|
1460
|
+
const pathPart = lowerUrl.split("?")[0].split("#")[0];
|
|
1461
|
+
const ext = (pathPart.match(/\.([a-z0-9]{1,8})$/)?.[1] ?? "").toLowerCase();
|
|
1462
|
+
const inferredName = (() => {
|
|
1463
|
+
const raw = String(mediaUrl ?? "").trim();
|
|
1464
|
+
if (!raw) return "attachment";
|
|
1465
|
+
const withoutQuery = raw.split("?")[0].split("#")[0];
|
|
1466
|
+
const name = basename(withoutQuery);
|
|
1467
|
+
return name || "attachment";
|
|
1468
|
+
})();
|
|
1469
|
+
|
|
1470
|
+
if (normalizedType === "image") return { type: "image", filename: inferredName || "image.jpg" };
|
|
1471
|
+
if (normalizedType === "video") return { type: "video", filename: inferredName || "video.mp4" };
|
|
1472
|
+
if (normalizedType === "file") return { type: "file", filename: inferredName || "file.bin" };
|
|
1473
|
+
|
|
1474
|
+
const imageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
1475
|
+
const videoExts = new Set(["mp4", "mov", "m4v", "webm", "avi"]);
|
|
1476
|
+
|
|
1477
|
+
if (imageExts.has(ext)) return { type: "image", filename: inferredName || `image.${ext}` };
|
|
1478
|
+
if (videoExts.has(ext)) return { type: "video", filename: inferredName || `video.${ext}` };
|
|
1479
|
+
return { type: "file", filename: inferredName || "file.bin" };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const WecomChannelPlugin = {
|
|
1483
|
+
id: "wecom",
|
|
1484
|
+
meta: {
|
|
1485
|
+
id: "wecom",
|
|
1486
|
+
label: "WeCom",
|
|
1487
|
+
selectionLabel: "WeCom (企业微信自建应用)",
|
|
1488
|
+
docsPath: "/channels/wecom",
|
|
1489
|
+
blurb: "Enterprise WeChat internal app via callback + send API.",
|
|
1490
|
+
aliases: ["wework", "qiwei", "wxwork"],
|
|
1491
|
+
},
|
|
1492
|
+
capabilities: {
|
|
1493
|
+
chatTypes: ["direct", "group"],
|
|
1494
|
+
media: {
|
|
1495
|
+
inbound: true,
|
|
1496
|
+
outbound: true, // 阶段二完成:支持发送图片
|
|
1497
|
+
},
|
|
1498
|
+
markdown: true, // 阶段三完成:支持 Markdown 转换
|
|
1499
|
+
},
|
|
1500
|
+
config: {
|
|
1501
|
+
listAccountIds: (cfg) => listWecomAccountIds({ config: cfg }),
|
|
1502
|
+
resolveAccount: (cfg, accountId) =>
|
|
1503
|
+
(getWecomConfig({ config: cfg }, accountId ?? "default") ?? { accountId: accountId ?? "default" }),
|
|
1504
|
+
},
|
|
1505
|
+
outbound: {
|
|
1506
|
+
deliveryMode: "direct",
|
|
1507
|
+
resolveTarget: ({ to }) => {
|
|
1508
|
+
const trimmed = to?.trim();
|
|
1509
|
+
if (!trimmed) return { ok: false, error: new Error("WeCom requires --to <UserId>") };
|
|
1510
|
+
return { ok: true, to: trimmed };
|
|
1511
|
+
},
|
|
1512
|
+
sendText: async ({ to, text, accountId }) => {
|
|
1513
|
+
const config = getWecomConfig({ config: gatewayRuntime?.config }, accountId);
|
|
1514
|
+
if (!config?.corpId || !config?.corpSecret || !config?.agentId) {
|
|
1515
|
+
return { ok: false, error: new Error("WeCom not configured (check channels.wecom in openclaw.json)") };
|
|
1516
|
+
}
|
|
1517
|
+
await sendWecomText({
|
|
1518
|
+
corpId: config.corpId,
|
|
1519
|
+
corpSecret: config.corpSecret,
|
|
1520
|
+
agentId: config.agentId,
|
|
1521
|
+
toUser: to,
|
|
1522
|
+
text,
|
|
1523
|
+
logger: gatewayRuntime?.logger,
|
|
1524
|
+
proxyUrl: config.outboundProxy,
|
|
1525
|
+
});
|
|
1526
|
+
return { ok: true, provider: "wecom" };
|
|
1527
|
+
},
|
|
1528
|
+
},
|
|
1529
|
+
// 入站消息处理
|
|
1530
|
+
inbound: {
|
|
1531
|
+
// 当消息需要回复时会调用这个方法
|
|
1532
|
+
deliverReply: async ({ to, text, accountId, mediaUrl, mediaType }) => {
|
|
1533
|
+
const config = getWecomConfig({ config: gatewayRuntime?.config }, accountId);
|
|
1534
|
+
if (!config?.corpId || !config?.corpSecret || !config?.agentId) {
|
|
1535
|
+
throw new Error("WeCom not configured (check channels.wecom in openclaw.json)");
|
|
1536
|
+
}
|
|
1537
|
+
const { corpId, corpSecret, agentId, outboundProxy: proxyUrl } = config;
|
|
1538
|
+
// to 格式为 "wecom:userid",需要提取 userid
|
|
1539
|
+
const userId = to.startsWith("wecom:") ? to.slice(6) : to;
|
|
1540
|
+
|
|
1541
|
+
// 如果有媒体附件,先发送媒体
|
|
1542
|
+
if (mediaUrl) {
|
|
1543
|
+
try {
|
|
1544
|
+
const target = resolveWecomOutboundMediaTarget({ mediaUrl, mediaType });
|
|
1545
|
+
const { buffer } = await fetchMediaFromUrl(mediaUrl);
|
|
1546
|
+
const mediaId = await uploadWecomMedia({
|
|
1547
|
+
corpId, corpSecret,
|
|
1548
|
+
type: target.type,
|
|
1549
|
+
buffer,
|
|
1550
|
+
filename: target.filename,
|
|
1551
|
+
logger: gatewayRuntime?.logger,
|
|
1552
|
+
proxyUrl,
|
|
1553
|
+
});
|
|
1554
|
+
if (target.type === "image") {
|
|
1555
|
+
await sendWecomImage({
|
|
1556
|
+
corpId,
|
|
1557
|
+
corpSecret,
|
|
1558
|
+
agentId,
|
|
1559
|
+
toUser: userId,
|
|
1560
|
+
mediaId,
|
|
1561
|
+
logger: gatewayRuntime?.logger,
|
|
1562
|
+
proxyUrl,
|
|
1563
|
+
});
|
|
1564
|
+
} else if (target.type === "video") {
|
|
1565
|
+
await sendWecomVideo({
|
|
1566
|
+
corpId,
|
|
1567
|
+
corpSecret,
|
|
1568
|
+
agentId,
|
|
1569
|
+
toUser: userId,
|
|
1570
|
+
mediaId,
|
|
1571
|
+
logger: gatewayRuntime?.logger,
|
|
1572
|
+
proxyUrl,
|
|
1573
|
+
});
|
|
1574
|
+
} else {
|
|
1575
|
+
await sendWecomFile({
|
|
1576
|
+
corpId,
|
|
1577
|
+
corpSecret,
|
|
1578
|
+
agentId,
|
|
1579
|
+
toUser: userId,
|
|
1580
|
+
mediaId,
|
|
1581
|
+
logger: gatewayRuntime?.logger,
|
|
1582
|
+
proxyUrl,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
} catch (mediaErr) {
|
|
1586
|
+
// 媒体发送失败不阻止文本发送,只记录警告
|
|
1587
|
+
gatewayRuntime?.logger?.warn?.(`wecom: failed to send media: ${mediaErr.message}`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// 发送文本消息
|
|
1592
|
+
if (text) {
|
|
1593
|
+
await sendWecomText({
|
|
1594
|
+
corpId,
|
|
1595
|
+
corpSecret,
|
|
1596
|
+
agentId,
|
|
1597
|
+
toUser: userId,
|
|
1598
|
+
text,
|
|
1599
|
+
logger: gatewayRuntime?.logger,
|
|
1600
|
+
proxyUrl,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
return { ok: true };
|
|
1605
|
+
},
|
|
1606
|
+
},
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// 存储 runtime 引用以便在消息处理中使用
|
|
1610
|
+
let gatewayRuntime = null;
|
|
1611
|
+
|
|
1612
|
+
// 多账户配置存储
|
|
1613
|
+
const wecomAccounts = new Map(); // key: accountId, value: config
|
|
1614
|
+
let defaultAccountId = "default";
|
|
1615
|
+
|
|
1616
|
+
function normalizeAccountId(accountId) {
|
|
1617
|
+
const normalized = String(accountId ?? "default").trim().toLowerCase();
|
|
1618
|
+
return normalized || "default";
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function normalizeAccountConfig(raw, accountId) {
|
|
1622
|
+
const normalizedId = normalizeAccountId(accountId);
|
|
1623
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1624
|
+
|
|
1625
|
+
const corpId = String(raw.corpId ?? "").trim();
|
|
1626
|
+
const corpSecret = String(raw.corpSecret ?? "").trim();
|
|
1627
|
+
const agentId = asNumber(raw.agentId);
|
|
1628
|
+
const callbackToken = String(raw.callbackToken ?? "").trim();
|
|
1629
|
+
const callbackAesKey = String(raw.callbackAesKey ?? "").trim();
|
|
1630
|
+
const webhookPath = String(raw.webhookPath ?? "/wecom/callback").trim() || "/wecom/callback";
|
|
1631
|
+
const outboundProxy = String(raw.outboundProxy ?? raw.proxyUrl ?? raw.proxy ?? "").trim();
|
|
1632
|
+
const allowFrom = raw.allowFrom;
|
|
1633
|
+
const allowFromRejectMessage = String(
|
|
1634
|
+
raw.allowFromRejectMessage ?? raw.rejectUnauthorizedMessage ?? "",
|
|
1635
|
+
).trim();
|
|
1636
|
+
|
|
1637
|
+
if (!corpId || !corpSecret || !agentId) {
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
return {
|
|
1642
|
+
accountId: normalizedId,
|
|
1643
|
+
corpId,
|
|
1644
|
+
corpSecret,
|
|
1645
|
+
agentId,
|
|
1646
|
+
callbackToken,
|
|
1647
|
+
callbackAesKey,
|
|
1648
|
+
webhookPath,
|
|
1649
|
+
outboundProxy: outboundProxy || undefined,
|
|
1650
|
+
allowFrom,
|
|
1651
|
+
allowFromRejectMessage: allowFromRejectMessage || undefined,
|
|
1652
|
+
enabled: raw.enabled !== false,
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function readAccountConfigFromEnv({ envVars, accountId }) {
|
|
1657
|
+
const normalizedId = normalizeAccountId(accountId);
|
|
1658
|
+
const prefix = normalizedId === "default" ? "WECOM" : `WECOM_${normalizedId.toUpperCase()}`;
|
|
1659
|
+
|
|
1660
|
+
const readVar = (suffix) =>
|
|
1661
|
+
envVars?.[`${prefix}_${suffix}`] ??
|
|
1662
|
+
(normalizedId === "default" ? envVars?.[`WECOM_${suffix}`] : undefined) ??
|
|
1663
|
+
requireEnv(`${prefix}_${suffix}`) ??
|
|
1664
|
+
(normalizedId === "default" ? requireEnv(`WECOM_${suffix}`) : undefined);
|
|
1665
|
+
|
|
1666
|
+
const corpId = String(readVar("CORP_ID") ?? "").trim();
|
|
1667
|
+
const corpSecret = String(readVar("CORP_SECRET") ?? "").trim();
|
|
1668
|
+
const agentId = asNumber(readVar("AGENT_ID"));
|
|
1669
|
+
const callbackToken = String(readVar("CALLBACK_TOKEN") ?? "").trim();
|
|
1670
|
+
const callbackAesKey = String(readVar("CALLBACK_AES_KEY") ?? "").trim();
|
|
1671
|
+
const webhookPath = String(readVar("WEBHOOK_PATH") ?? "/wecom/callback").trim() || "/wecom/callback";
|
|
1672
|
+
const outboundProxyRaw =
|
|
1673
|
+
readVar("PROXY") ??
|
|
1674
|
+
(normalizedId === "default"
|
|
1675
|
+
? requireEnv("HTTPS_PROXY")
|
|
1676
|
+
: envVars?.WECOM_PROXY ?? requireEnv("WECOM_PROXY") ?? requireEnv("HTTPS_PROXY"));
|
|
1677
|
+
const outboundProxy = String(outboundProxyRaw ?? "").trim();
|
|
1678
|
+
const allowFrom = readVar("ALLOW_FROM");
|
|
1679
|
+
const allowFromRejectMessage = String(readVar("ALLOW_FROM_REJECT_MESSAGE") ?? "").trim();
|
|
1680
|
+
const enabledRaw = String(readVar("ENABLED") ?? "").trim().toLowerCase();
|
|
1681
|
+
const enabled = !["0", "false", "off", "no"].includes(enabledRaw);
|
|
1682
|
+
|
|
1683
|
+
if (!corpId || !corpSecret || !agentId) return null;
|
|
1684
|
+
|
|
1685
|
+
return {
|
|
1686
|
+
accountId: normalizedId,
|
|
1687
|
+
corpId,
|
|
1688
|
+
corpSecret,
|
|
1689
|
+
agentId,
|
|
1690
|
+
callbackToken,
|
|
1691
|
+
callbackAesKey,
|
|
1692
|
+
webhookPath,
|
|
1693
|
+
outboundProxy: outboundProxy || undefined,
|
|
1694
|
+
allowFrom,
|
|
1695
|
+
allowFromRejectMessage: allowFromRejectMessage || undefined,
|
|
1696
|
+
enabled,
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function rebuildWecomAccounts(api) {
|
|
1701
|
+
const cfg = api?.config ?? gatewayRuntime?.config ?? {};
|
|
1702
|
+
const channelConfig = cfg?.channels?.wecom;
|
|
1703
|
+
const envVars = cfg?.env?.vars ?? {};
|
|
1704
|
+
const resolved = new Map();
|
|
1705
|
+
|
|
1706
|
+
const upsert = (accountId, rawConfig) => {
|
|
1707
|
+
const normalized = normalizeAccountConfig(rawConfig, accountId);
|
|
1708
|
+
if (!normalized) return;
|
|
1709
|
+
resolved.set(normalized.accountId, normalized);
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
// 1) channels.wecom 顶层默认账户
|
|
1713
|
+
if (channelConfig && typeof channelConfig === "object") {
|
|
1714
|
+
upsert("default", channelConfig);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// 2) channels.wecom.accounts 多账户
|
|
1718
|
+
const channelAccounts = channelConfig?.accounts;
|
|
1719
|
+
if (channelAccounts && typeof channelAccounts === "object") {
|
|
1720
|
+
for (const [accountId, accountConfig] of Object.entries(channelAccounts)) {
|
|
1721
|
+
upsert(accountId, accountConfig);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// 3) env.vars / process.env 回退(兼容旧配置)
|
|
1726
|
+
const envAccountIds = new Set(["default"]);
|
|
1727
|
+
for (const key of Object.keys(envVars)) {
|
|
1728
|
+
const m = key.match(/^WECOM_([A-Z0-9]+)_CORP_ID$/);
|
|
1729
|
+
if (m && m[1] !== "CORP") envAccountIds.add(m[1].toLowerCase());
|
|
1730
|
+
}
|
|
1731
|
+
for (const key of Object.keys(process.env)) {
|
|
1732
|
+
const m = key.match(/^WECOM_([A-Z0-9]+)_CORP_ID$/);
|
|
1733
|
+
if (m && m[1] !== "CORP") envAccountIds.add(m[1].toLowerCase());
|
|
1734
|
+
}
|
|
1735
|
+
for (const accountId of envAccountIds) {
|
|
1736
|
+
if (resolved.has(normalizeAccountId(accountId))) continue;
|
|
1737
|
+
const envConfig = readAccountConfigFromEnv({ envVars, accountId });
|
|
1738
|
+
if (envConfig) resolved.set(envConfig.accountId, envConfig);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
for (const [accountId, config] of resolved.entries()) {
|
|
1742
|
+
config.outboundProxy = resolveWecomProxyConfig({
|
|
1743
|
+
channelConfig,
|
|
1744
|
+
accountConfig: config,
|
|
1745
|
+
envVars,
|
|
1746
|
+
processEnv: process.env,
|
|
1747
|
+
accountId,
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
wecomAccounts.clear();
|
|
1752
|
+
for (const [accountId, config] of resolved) {
|
|
1753
|
+
wecomAccounts.set(accountId, config);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
defaultAccountId = wecomAccounts.has("default")
|
|
1757
|
+
? "default"
|
|
1758
|
+
: (Array.from(wecomAccounts.keys())[0] ?? "default");
|
|
1759
|
+
|
|
1760
|
+
return wecomAccounts;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// 获取 wecom 配置(支持多账户)
|
|
1764
|
+
function getWecomConfig(api, accountId = null) {
|
|
1765
|
+
const accountMap = rebuildWecomAccounts(api);
|
|
1766
|
+
const targetAccountId = normalizeAccountId(accountId ?? defaultAccountId);
|
|
1767
|
+
|
|
1768
|
+
if (accountMap.has(targetAccountId)) {
|
|
1769
|
+
return accountMap.get(targetAccountId);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
if (targetAccountId !== "default" && accountMap.has("default")) {
|
|
1773
|
+
return accountMap.get("default");
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
return accountMap.values().next().value ?? null;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// 列出所有已配置的账户ID
|
|
1780
|
+
function listWecomAccountIds(api) {
|
|
1781
|
+
return Array.from(rebuildWecomAccounts(api).keys());
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function listEnabledWecomAccounts(api) {
|
|
1785
|
+
return Array.from(rebuildWecomAccounts(api).values()).filter((cfg) => cfg?.enabled !== false);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function groupAccountsByWebhookPath(api) {
|
|
1789
|
+
const grouped = new Map();
|
|
1790
|
+
for (const account of listEnabledWecomAccounts(api)) {
|
|
1791
|
+
const normalizedPath =
|
|
1792
|
+
normalizePluginHttpPath(account.webhookPath ?? "/wecom/callback", "/wecom/callback") ?? "/wecom/callback";
|
|
1793
|
+
const existing = grouped.get(normalizedPath);
|
|
1794
|
+
if (existing) existing.push(account);
|
|
1795
|
+
else grouped.set(normalizedPath, [account]);
|
|
1796
|
+
}
|
|
1797
|
+
return grouped;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function resolveWecomPolicyInputs(api) {
|
|
1801
|
+
const cfg = api?.config ?? gatewayRuntime?.config ?? {};
|
|
1802
|
+
return {
|
|
1803
|
+
channelConfig: cfg?.channels?.wecom ?? {},
|
|
1804
|
+
envVars: cfg?.env?.vars ?? {},
|
|
1805
|
+
processEnv: process.env,
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function resolveWecomBotConfig(api) {
|
|
1810
|
+
return resolveWecomBotModeConfig(resolveWecomPolicyInputs(api));
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function resolveWecomBotProxyConfig(api) {
|
|
1814
|
+
const inputs = resolveWecomPolicyInputs(api);
|
|
1815
|
+
return resolveWecomProxyConfig({
|
|
1816
|
+
...inputs,
|
|
1817
|
+
accountId: "bot",
|
|
1818
|
+
accountConfig: {},
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function resolveWecomCommandPolicy(api) {
|
|
1823
|
+
return resolveWecomCommandPolicyConfig(resolveWecomPolicyInputs(api));
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function resolveWecomAllowFromPolicy(api, accountId, accountConfig = {}) {
|
|
1827
|
+
const inputs = resolveWecomPolicyInputs(api);
|
|
1828
|
+
return resolveWecomAllowFromPolicyConfig({
|
|
1829
|
+
...inputs,
|
|
1830
|
+
accountId: normalizeAccountId(accountId ?? "default"),
|
|
1831
|
+
accountConfig: accountConfig ?? {},
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function resolveWecomGroupChatPolicy(api) {
|
|
1836
|
+
return resolveWecomGroupChatConfig(resolveWecomPolicyInputs(api));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function resolveWecomTextDebouncePolicy(api) {
|
|
1840
|
+
return resolveWecomDebounceConfig(resolveWecomPolicyInputs(api));
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function resolveWecomReplyStreamingPolicy(api) {
|
|
1844
|
+
return resolveWecomStreamingConfig(resolveWecomPolicyInputs(api));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function buildTextDebounceBufferKey({ accountId, fromUser, chatId, isGroupChat }) {
|
|
1848
|
+
const account = String(accountId ?? "default").trim().toLowerCase() || "default";
|
|
1849
|
+
const user = String(fromUser ?? "").trim().toLowerCase();
|
|
1850
|
+
const group = String(chatId ?? "").trim().toLowerCase();
|
|
1851
|
+
if (isGroupChat) {
|
|
1852
|
+
return `${account}:group:${group || "unknown"}:user:${user || "unknown"}`;
|
|
1853
|
+
}
|
|
1854
|
+
return `${account}:dm:${user || "unknown"}`;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function dispatchTextPayload(api, payload, reason = "direct") {
|
|
1858
|
+
messageProcessLimiter
|
|
1859
|
+
.execute(() => processInboundMessage(payload))
|
|
1860
|
+
.catch((err) => {
|
|
1861
|
+
api.logger.error?.(`wecom: async text processing failed (${reason}): ${err.message}`);
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function flushTextDebounceBuffer(api, debounceKey, reason = "timer") {
|
|
1866
|
+
const buffered = TEXT_MESSAGE_DEBOUNCE_BUFFERS.get(debounceKey);
|
|
1867
|
+
if (!buffered) return;
|
|
1868
|
+
|
|
1869
|
+
TEXT_MESSAGE_DEBOUNCE_BUFFERS.delete(debounceKey);
|
|
1870
|
+
if (buffered.timer) clearTimeout(buffered.timer);
|
|
1871
|
+
const mergedContent = buffered.messages.join("\n").trim();
|
|
1872
|
+
if (!mergedContent) return;
|
|
1873
|
+
|
|
1874
|
+
api.logger.info?.(
|
|
1875
|
+
`wecom: flushing debounced text buffer key=${debounceKey} count=${buffered.messages.length} reason=${reason}`,
|
|
1876
|
+
);
|
|
1877
|
+
dispatchTextPayload(
|
|
1878
|
+
api,
|
|
1879
|
+
{
|
|
1880
|
+
...buffered.basePayload,
|
|
1881
|
+
msgType: "text",
|
|
1882
|
+
content: mergedContent,
|
|
1883
|
+
msgId: buffered.msgIds[0] ?? buffered.basePayload.msgId ?? "",
|
|
1884
|
+
},
|
|
1885
|
+
`debounce:${reason}`,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function scheduleTextInboundProcessing(api, basePayload, content) {
|
|
1890
|
+
const text = String(content ?? "");
|
|
1891
|
+
let commandProbeText = text;
|
|
1892
|
+
if (basePayload?.isGroupChat) {
|
|
1893
|
+
const groupPolicy = resolveWecomGroupChatPolicy(api);
|
|
1894
|
+
commandProbeText = stripWecomGroupMentions(commandProbeText, groupPolicy.mentionPatterns);
|
|
1895
|
+
}
|
|
1896
|
+
const command = extractLeadingSlashCommand(commandProbeText);
|
|
1897
|
+
const debounceConfig = resolveWecomTextDebouncePolicy(api);
|
|
1898
|
+
const debounceKey = buildTextDebounceBufferKey(basePayload);
|
|
1899
|
+
|
|
1900
|
+
if (command) {
|
|
1901
|
+
flushTextDebounceBuffer(api, debounceKey, "command-priority");
|
|
1902
|
+
dispatchTextPayload(api, { ...basePayload, content: text, msgType: "text" }, "command");
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (!debounceConfig.enabled) {
|
|
1907
|
+
dispatchTextPayload(api, { ...basePayload, content: text, msgType: "text" }, "direct");
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const existing = TEXT_MESSAGE_DEBOUNCE_BUFFERS.get(debounceKey);
|
|
1912
|
+
if (!existing) {
|
|
1913
|
+
const timer = setTimeout(() => {
|
|
1914
|
+
flushTextDebounceBuffer(api, debounceKey, "window-expired");
|
|
1915
|
+
}, debounceConfig.windowMs);
|
|
1916
|
+
timer.unref?.();
|
|
1917
|
+
|
|
1918
|
+
TEXT_MESSAGE_DEBOUNCE_BUFFERS.set(debounceKey, {
|
|
1919
|
+
basePayload,
|
|
1920
|
+
messages: [text],
|
|
1921
|
+
msgIds: [basePayload.msgId ?? ""],
|
|
1922
|
+
timer,
|
|
1923
|
+
updatedAt: Date.now(),
|
|
1924
|
+
});
|
|
1925
|
+
api.logger.info?.(
|
|
1926
|
+
`wecom: buffered text message key=${debounceKey} count=1 windowMs=${debounceConfig.windowMs}`,
|
|
1927
|
+
);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (existing.timer) clearTimeout(existing.timer);
|
|
1932
|
+
existing.messages.push(text);
|
|
1933
|
+
existing.msgIds.push(basePayload.msgId ?? "");
|
|
1934
|
+
existing.updatedAt = Date.now();
|
|
1935
|
+
|
|
1936
|
+
if (existing.messages.length >= debounceConfig.maxBatch) {
|
|
1937
|
+
flushTextDebounceBuffer(api, debounceKey, "max-batch");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
existing.timer = setTimeout(() => {
|
|
1942
|
+
flushTextDebounceBuffer(api, debounceKey, "window-expired");
|
|
1943
|
+
}, debounceConfig.windowMs);
|
|
1944
|
+
existing.timer.unref?.();
|
|
1945
|
+
TEXT_MESSAGE_DEBOUNCE_BUFFERS.set(debounceKey, existing);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function registerWecomBotWebhookRoute(api) {
|
|
1949
|
+
const botConfig = resolveWecomBotConfig(api);
|
|
1950
|
+
if (!botConfig.enabled) return false;
|
|
1951
|
+
if (!botConfig.token || !botConfig.encodingAesKey) {
|
|
1952
|
+
api.logger.warn?.(
|
|
1953
|
+
"wecom(bot): enabled but missing token/encodingAesKey; route not registered",
|
|
1954
|
+
);
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const normalizedPath =
|
|
1959
|
+
normalizePluginHttpPath(botConfig.webhookPath ?? "/wecom/bot/callback", "/wecom/bot/callback") ??
|
|
1960
|
+
"/wecom/bot/callback";
|
|
1961
|
+
ensureBotStreamCleanupTimer(botConfig.streamExpireMs, api.logger);
|
|
1962
|
+
cleanupExpiredBotStreams(botConfig.streamExpireMs);
|
|
1963
|
+
|
|
1964
|
+
api.registerHttpRoute({
|
|
1965
|
+
path: normalizedPath,
|
|
1966
|
+
handler: async (req, res) => {
|
|
1967
|
+
try {
|
|
1968
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1969
|
+
const msg_signature = url.searchParams.get("msg_signature") ?? "";
|
|
1970
|
+
const timestamp = url.searchParams.get("timestamp") ?? "";
|
|
1971
|
+
const nonce = url.searchParams.get("nonce") ?? "";
|
|
1972
|
+
const echostr = url.searchParams.get("echostr") ?? "";
|
|
1973
|
+
|
|
1974
|
+
if (req.method === "GET" && !echostr) {
|
|
1975
|
+
res.statusCode = 200;
|
|
1976
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1977
|
+
res.end("wecom bot webhook ok");
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
if (req.method === "GET") {
|
|
1982
|
+
if (!msg_signature || !timestamp || !nonce || !echostr) {
|
|
1983
|
+
res.statusCode = 400;
|
|
1984
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1985
|
+
res.end("Missing query params");
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const expected = computeMsgSignature({
|
|
1989
|
+
token: botConfig.token,
|
|
1990
|
+
timestamp,
|
|
1991
|
+
nonce,
|
|
1992
|
+
encrypt: echostr,
|
|
1993
|
+
});
|
|
1994
|
+
if (expected !== msg_signature) {
|
|
1995
|
+
res.statusCode = 401;
|
|
1996
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1997
|
+
res.end("Invalid signature");
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const { msg: plainEchostr } = decryptWecom({
|
|
2001
|
+
aesKey: botConfig.encodingAesKey,
|
|
2002
|
+
cipherTextBase64: echostr,
|
|
2003
|
+
});
|
|
2004
|
+
res.statusCode = 200;
|
|
2005
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2006
|
+
res.end(plainEchostr);
|
|
2007
|
+
api.logger.info?.(`wecom(bot): verified callback URL at ${normalizedPath}`);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (req.method !== "POST") {
|
|
2012
|
+
res.statusCode = 405;
|
|
2013
|
+
res.setHeader("Allow", "GET, POST");
|
|
2014
|
+
res.end();
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
let encryptedBody = "";
|
|
2019
|
+
try {
|
|
2020
|
+
const rawBody = await readRequestBody(req);
|
|
2021
|
+
const parsedBody = parseIncomingJson(rawBody);
|
|
2022
|
+
encryptedBody = String(parsedBody?.encrypt ?? "").trim();
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
res.statusCode = 400;
|
|
2025
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2026
|
+
res.end("Invalid request body");
|
|
2027
|
+
api.logger.warn?.(`wecom(bot): failed to parse callback body: ${String(err?.message || err)}`);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (!msg_signature || !timestamp || !nonce || !encryptedBody) {
|
|
2032
|
+
res.statusCode = 400;
|
|
2033
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2034
|
+
res.end("Missing required params");
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const expected = computeMsgSignature({
|
|
2039
|
+
token: botConfig.token,
|
|
2040
|
+
timestamp,
|
|
2041
|
+
nonce,
|
|
2042
|
+
encrypt: encryptedBody,
|
|
2043
|
+
});
|
|
2044
|
+
if (expected !== msg_signature) {
|
|
2045
|
+
res.statusCode = 401;
|
|
2046
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2047
|
+
res.end("Invalid signature");
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
let incomingPayload = null;
|
|
2052
|
+
try {
|
|
2053
|
+
const { msg: decryptedPayload } = decryptWecom({
|
|
2054
|
+
aesKey: botConfig.encodingAesKey,
|
|
2055
|
+
cipherTextBase64: encryptedBody,
|
|
2056
|
+
});
|
|
2057
|
+
incomingPayload = parseIncomingJson(decryptedPayload);
|
|
2058
|
+
} catch (err) {
|
|
2059
|
+
res.statusCode = 400;
|
|
2060
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2061
|
+
res.end("Decrypt failed");
|
|
2062
|
+
api.logger.warn?.(`wecom(bot): failed to decrypt payload: ${String(err?.message || err)}`);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const parsed = parseWecomBotInboundMessage(incomingPayload);
|
|
2067
|
+
api.logger.info?.(`wecom(bot): inbound ${describeWecomBotParsedMessage(parsed)}`);
|
|
2068
|
+
if (!parsed) {
|
|
2069
|
+
res.statusCode = 200;
|
|
2070
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2071
|
+
res.end("success");
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (parsed.kind === "stream-refresh") {
|
|
2076
|
+
cleanupExpiredBotStreams(botConfig.streamExpireMs);
|
|
2077
|
+
const streamId = parsed.streamId || `stream-${Date.now()}`;
|
|
2078
|
+
const stream = getBotStream(streamId);
|
|
2079
|
+
const plainPayload = {
|
|
2080
|
+
msgtype: "stream",
|
|
2081
|
+
stream: {
|
|
2082
|
+
id: streamId,
|
|
2083
|
+
content: stream?.content ?? "会话已过期",
|
|
2084
|
+
finish: stream ? stream.finished === true : true,
|
|
2085
|
+
},
|
|
2086
|
+
};
|
|
2087
|
+
const encryptedResponse = buildWecomBotEncryptedResponse({
|
|
2088
|
+
token: botConfig.token,
|
|
2089
|
+
aesKey: botConfig.encodingAesKey,
|
|
2090
|
+
timestamp,
|
|
2091
|
+
nonce,
|
|
2092
|
+
plainPayload,
|
|
2093
|
+
});
|
|
2094
|
+
res.statusCode = 200;
|
|
2095
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2096
|
+
res.end(encryptedResponse);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
if (parsed.kind === "event") {
|
|
2101
|
+
res.statusCode = 200;
|
|
2102
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2103
|
+
res.end("success");
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (parsed.kind === "unsupported" || parsed.kind === "invalid") {
|
|
2108
|
+
res.statusCode = 200;
|
|
2109
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2110
|
+
res.end("success");
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
if (parsed.kind === "message") {
|
|
2115
|
+
const dedupeStub = {
|
|
2116
|
+
MsgId: parsed.msgId,
|
|
2117
|
+
FromUserName: parsed.fromUser,
|
|
2118
|
+
MsgType: parsed.msgType,
|
|
2119
|
+
Content: parsed.content,
|
|
2120
|
+
CreateTime: String(Math.floor(Date.now() / 1000)),
|
|
2121
|
+
};
|
|
2122
|
+
if (!markInboundMessageSeen(dedupeStub, "bot")) {
|
|
2123
|
+
res.statusCode = 200;
|
|
2124
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2125
|
+
res.end("success");
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const streamId = `stream_${crypto.randomUUID?.() || `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`}`;
|
|
2130
|
+
createBotStream(streamId, botConfig.placeholderText);
|
|
2131
|
+
const encryptedResponse = buildWecomBotEncryptedResponse({
|
|
2132
|
+
token: botConfig.token,
|
|
2133
|
+
aesKey: botConfig.encodingAesKey,
|
|
2134
|
+
timestamp,
|
|
2135
|
+
nonce,
|
|
2136
|
+
plainPayload: {
|
|
2137
|
+
msgtype: "stream",
|
|
2138
|
+
stream: {
|
|
2139
|
+
id: streamId,
|
|
2140
|
+
content: botConfig.placeholderText,
|
|
2141
|
+
finish: false,
|
|
2142
|
+
},
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
res.statusCode = 200;
|
|
2146
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2147
|
+
res.end(encryptedResponse);
|
|
2148
|
+
|
|
2149
|
+
messageProcessLimiter
|
|
2150
|
+
.execute(() =>
|
|
2151
|
+
processBotInboundMessage({
|
|
2152
|
+
api,
|
|
2153
|
+
streamId,
|
|
2154
|
+
fromUser: parsed.fromUser,
|
|
2155
|
+
content: parsed.content,
|
|
2156
|
+
msgType: parsed.msgType,
|
|
2157
|
+
msgId: parsed.msgId,
|
|
2158
|
+
chatId: parsed.chatId,
|
|
2159
|
+
isGroupChat: parsed.isGroupChat,
|
|
2160
|
+
imageUrls: parsed.imageUrls,
|
|
2161
|
+
}),
|
|
2162
|
+
)
|
|
2163
|
+
.catch((err) => {
|
|
2164
|
+
api.logger.error?.(`wecom(bot): async message processing failed: ${String(err?.message || err)}`);
|
|
2165
|
+
finishBotStream(
|
|
2166
|
+
streamId,
|
|
2167
|
+
`抱歉,当前模型请求失败,请稍后重试。\n故障信息: ${String(err?.message || err).slice(0, 160)}`,
|
|
2168
|
+
);
|
|
2169
|
+
});
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
res.statusCode = 200;
|
|
2174
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2175
|
+
res.end("success");
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
api.logger.error?.(`wecom(bot): webhook handler failed: ${String(err?.message || err)}`);
|
|
2178
|
+
if (!res.writableEnded) {
|
|
2179
|
+
res.statusCode = 500;
|
|
2180
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2181
|
+
res.end("Internal error");
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
api.logger.info?.(`wecom(bot): registered webhook at ${normalizedPath}`);
|
|
2188
|
+
return true;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
export default function register(api) {
|
|
2192
|
+
// 保存 runtime 引用
|
|
2193
|
+
gatewayRuntime = api.runtime;
|
|
2194
|
+
|
|
2195
|
+
// 初始化配置
|
|
2196
|
+
const botModeConfig = resolveWecomBotConfig(api);
|
|
2197
|
+
const cfg = getWecomConfig(api);
|
|
2198
|
+
if (cfg) {
|
|
2199
|
+
api.logger.info?.(
|
|
2200
|
+
`wecom: config loaded (corpId=${cfg.corpId?.slice(0, 8)}..., proxy=${cfg.outboundProxy ? "on" : "off"})`,
|
|
2201
|
+
);
|
|
2202
|
+
} else if (botModeConfig.enabled) {
|
|
2203
|
+
api.logger.info?.(
|
|
2204
|
+
`wecom(bot): config loaded (webhook=${botModeConfig.webhookPath}, streamExpireMs=${botModeConfig.streamExpireMs})`,
|
|
2205
|
+
);
|
|
2206
|
+
} else {
|
|
2207
|
+
api.logger.warn?.("wecom: no configuration found (check channels.wecom in openclaw.json)");
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
api.registerChannel({ plugin: WecomChannelPlugin });
|
|
2211
|
+
const botRouteRegistered = registerWecomBotWebhookRoute(api);
|
|
2212
|
+
|
|
2213
|
+
const webhookGroups = groupAccountsByWebhookPath(api);
|
|
2214
|
+
if (webhookGroups.size === 0 && !botRouteRegistered) {
|
|
2215
|
+
api.logger.warn?.("wecom: no enabled account with valid config found; webhook route not registered");
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
for (const [normalizedPath, accounts] of webhookGroups.entries()) {
|
|
2220
|
+
api.registerHttpRoute({
|
|
2221
|
+
path: normalizedPath,
|
|
2222
|
+
handler: async (req, res) => {
|
|
2223
|
+
try {
|
|
2224
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2225
|
+
const msg_signature = url.searchParams.get("msg_signature") ?? "";
|
|
2226
|
+
const timestamp = url.searchParams.get("timestamp") ?? "";
|
|
2227
|
+
const nonce = url.searchParams.get("nonce") ?? "";
|
|
2228
|
+
const echostr = url.searchParams.get("echostr") ?? "";
|
|
2229
|
+
const signedAccounts = accounts.filter((a) => a.callbackToken && a.callbackAesKey);
|
|
2230
|
+
|
|
2231
|
+
// Health check
|
|
2232
|
+
if (req.method === "GET" && !echostr) {
|
|
2233
|
+
res.statusCode = signedAccounts.length > 0 ? 200 : 500;
|
|
2234
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2235
|
+
res.end(signedAccounts.length > 0 ? "wecom webhook ok" : "wecom webhook not configured");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
if (signedAccounts.length === 0) {
|
|
2240
|
+
res.statusCode = 500;
|
|
2241
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2242
|
+
res.end("WeCom plugin not configured (missing callbackToken/callbackAesKey)");
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (req.method === "GET") {
|
|
2247
|
+
const matchedAccount = pickAccountBySignature({
|
|
2248
|
+
accounts: signedAccounts,
|
|
2249
|
+
msgSignature: msg_signature,
|
|
2250
|
+
timestamp,
|
|
2251
|
+
nonce,
|
|
2252
|
+
encrypt: echostr,
|
|
2253
|
+
});
|
|
2254
|
+
if (!matchedAccount) {
|
|
2255
|
+
res.statusCode = 401;
|
|
2256
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2257
|
+
res.end("Invalid signature");
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const { msg: plainEchostr } = decryptWecom({
|
|
2262
|
+
aesKey: matchedAccount.callbackAesKey,
|
|
2263
|
+
cipherTextBase64: echostr,
|
|
2264
|
+
});
|
|
2265
|
+
res.statusCode = 200;
|
|
2266
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2267
|
+
res.end(plainEchostr);
|
|
2268
|
+
api.logger.info?.(`wecom: verified callback URL for account=${matchedAccount.accountId}`);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
if (req.method !== "POST") {
|
|
2273
|
+
res.statusCode = 405;
|
|
2274
|
+
res.setHeader("Allow", "GET, POST");
|
|
2275
|
+
res.end();
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
let encrypt = "";
|
|
2280
|
+
try {
|
|
2281
|
+
const rawXml = await readRequestBody(req);
|
|
2282
|
+
const incoming = parseIncomingXml(rawXml);
|
|
2283
|
+
encrypt = String(incoming?.Encrypt ?? "");
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
res.statusCode = 400;
|
|
2286
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2287
|
+
res.end("Invalid request body");
|
|
2288
|
+
api.logger.warn?.(`wecom: failed to parse callback body: ${String(err?.message || err)}`);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (!encrypt) {
|
|
2293
|
+
res.statusCode = 400;
|
|
2294
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2295
|
+
res.end("Missing Encrypt");
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
const matchedAccount = pickAccountBySignature({
|
|
2300
|
+
accounts: signedAccounts,
|
|
2301
|
+
msgSignature: msg_signature,
|
|
2302
|
+
timestamp,
|
|
2303
|
+
nonce,
|
|
2304
|
+
encrypt,
|
|
2305
|
+
});
|
|
2306
|
+
if (!matchedAccount) {
|
|
2307
|
+
res.statusCode = 401;
|
|
2308
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2309
|
+
res.end("Invalid signature");
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// ACK quickly (WeCom expects fast response within 5 seconds)
|
|
2314
|
+
res.statusCode = 200;
|
|
2315
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2316
|
+
res.end("success");
|
|
2317
|
+
|
|
2318
|
+
let msgObj;
|
|
2319
|
+
try {
|
|
2320
|
+
const { msg: decryptedXml } = decryptWecom({
|
|
2321
|
+
aesKey: matchedAccount.callbackAesKey,
|
|
2322
|
+
cipherTextBase64: encrypt,
|
|
2323
|
+
});
|
|
2324
|
+
msgObj = parseIncomingXml(decryptedXml);
|
|
2325
|
+
} catch (err) {
|
|
2326
|
+
api.logger.error?.(`wecom: failed to decrypt payload for account=${matchedAccount.accountId}: ${String(err?.message || err)}`);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
if (!markInboundMessageSeen(msgObj, matchedAccount.accountId)) {
|
|
2331
|
+
api.logger.info?.(`wecom: duplicate inbound skipped msgId=${msgObj?.MsgId ?? "n/a"}`);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// 检测是否为群聊消息
|
|
2336
|
+
const chatId = msgObj.ChatId || null;
|
|
2337
|
+
const isGroupChat = !!chatId;
|
|
2338
|
+
const fromUser = msgObj.FromUserName;
|
|
2339
|
+
const msgType = msgObj.MsgType;
|
|
2340
|
+
const msgId = String(msgObj.MsgId ?? "").trim();
|
|
2341
|
+
|
|
2342
|
+
api.logger.info?.(
|
|
2343
|
+
`wecom inbound: account=${matchedAccount.accountId} from=${msgObj?.FromUserName} msgType=${msgType} chatId=${chatId || "N/A"} content=${(msgObj?.Content ?? "").slice?.(0, 80)}`,
|
|
2344
|
+
);
|
|
2345
|
+
|
|
2346
|
+
if (!fromUser) {
|
|
2347
|
+
api.logger.warn?.("wecom: inbound message missing FromUserName, dropped");
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const basePayload = {
|
|
2352
|
+
api,
|
|
2353
|
+
accountId: matchedAccount.accountId,
|
|
2354
|
+
fromUser,
|
|
2355
|
+
chatId,
|
|
2356
|
+
isGroupChat,
|
|
2357
|
+
msgId,
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
// 异步处理消息,不阻塞响应
|
|
2361
|
+
if (msgType === "text" && msgObj?.Content) {
|
|
2362
|
+
scheduleTextInboundProcessing(api, basePayload, msgObj.Content);
|
|
2363
|
+
} else if (msgType === "image" && msgObj?.MediaId) {
|
|
2364
|
+
messageProcessLimiter.execute(() =>
|
|
2365
|
+
processInboundMessage({
|
|
2366
|
+
...basePayload,
|
|
2367
|
+
mediaId: msgObj.MediaId,
|
|
2368
|
+
msgType: "image",
|
|
2369
|
+
picUrl: msgObj.PicUrl,
|
|
2370
|
+
})
|
|
2371
|
+
).catch((err) => {
|
|
2372
|
+
api.logger.error?.(`wecom: async image processing failed: ${err.message}`);
|
|
2373
|
+
});
|
|
2374
|
+
} else if (msgType === "voice" && msgObj?.MediaId) {
|
|
2375
|
+
messageProcessLimiter.execute(() =>
|
|
2376
|
+
processInboundMessage({
|
|
2377
|
+
...basePayload,
|
|
2378
|
+
mediaId: msgObj.MediaId,
|
|
2379
|
+
msgType: "voice",
|
|
2380
|
+
recognition: msgObj.Recognition,
|
|
2381
|
+
})
|
|
2382
|
+
).catch((err) => {
|
|
2383
|
+
api.logger.error?.(`wecom: async voice processing failed: ${err.message}`);
|
|
2384
|
+
});
|
|
2385
|
+
} else if (msgType === "video" && msgObj?.MediaId) {
|
|
2386
|
+
messageProcessLimiter.execute(() =>
|
|
2387
|
+
processInboundMessage({
|
|
2388
|
+
...basePayload,
|
|
2389
|
+
mediaId: msgObj.MediaId,
|
|
2390
|
+
msgType: "video",
|
|
2391
|
+
thumbMediaId: msgObj.ThumbMediaId,
|
|
2392
|
+
})
|
|
2393
|
+
).catch((err) => {
|
|
2394
|
+
api.logger.error?.(`wecom: async video processing failed: ${err.message}`);
|
|
2395
|
+
});
|
|
2396
|
+
} else if (msgType === "file" && msgObj?.MediaId) {
|
|
2397
|
+
messageProcessLimiter.execute(() =>
|
|
2398
|
+
processInboundMessage({
|
|
2399
|
+
...basePayload,
|
|
2400
|
+
mediaId: msgObj.MediaId,
|
|
2401
|
+
msgType: "file",
|
|
2402
|
+
fileName: msgObj.FileName,
|
|
2403
|
+
fileSize: msgObj.FileSize,
|
|
2404
|
+
})
|
|
2405
|
+
).catch((err) => {
|
|
2406
|
+
api.logger.error?.(`wecom: async file processing failed: ${err.message}`);
|
|
2407
|
+
});
|
|
2408
|
+
} else if (msgType === "link") {
|
|
2409
|
+
messageProcessLimiter.execute(() =>
|
|
2410
|
+
processInboundMessage({
|
|
2411
|
+
...basePayload,
|
|
2412
|
+
msgType: "link",
|
|
2413
|
+
linkTitle: msgObj.Title,
|
|
2414
|
+
linkDescription: msgObj.Description,
|
|
2415
|
+
linkUrl: msgObj.Url,
|
|
2416
|
+
linkPicUrl: msgObj.PicUrl,
|
|
2417
|
+
})
|
|
2418
|
+
).catch((err) => {
|
|
2419
|
+
api.logger.error?.(`wecom: async link processing failed: ${err.message}`);
|
|
2420
|
+
});
|
|
2421
|
+
} else {
|
|
2422
|
+
api.logger.info?.(`wecom: ignoring unsupported message type=${msgType}`);
|
|
2423
|
+
}
|
|
2424
|
+
} catch (err) {
|
|
2425
|
+
api.logger.error?.(`wecom: webhook handler failed: ${String(err?.message || err)}`);
|
|
2426
|
+
if (!res.writableEnded) {
|
|
2427
|
+
res.statusCode = 500;
|
|
2428
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2429
|
+
res.end("Internal error");
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
},
|
|
2433
|
+
});
|
|
2434
|
+
|
|
2435
|
+
const accountIds = accounts.map((a) => a.accountId).join(", ");
|
|
2436
|
+
api.logger.info?.(`wecom: registered webhook at ${normalizedPath} (accounts=${accountIds})`);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// 下载企业微信媒体文件
|
|
2441
|
+
async function downloadWecomMedia({ corpId, corpSecret, mediaId, proxyUrl, logger }) {
|
|
2442
|
+
const accessToken = await getWecomAccessToken({ corpId, corpSecret, proxyUrl, logger });
|
|
2443
|
+
const mediaUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(accessToken)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
2444
|
+
|
|
2445
|
+
const res = await fetchWithRetry(mediaUrl, {}, 3, 1000, { proxyUrl, logger });
|
|
2446
|
+
if (!res.ok) {
|
|
2447
|
+
throw new Error(`Failed to download media: ${res.status}`);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
const contentType = res.headers.get("content-type") || "";
|
|
2451
|
+
|
|
2452
|
+
// 如果返回 JSON,说明有错误
|
|
2453
|
+
if (contentType.includes("application/json")) {
|
|
2454
|
+
const json = await res.json();
|
|
2455
|
+
throw new Error(`WeCom media download failed: ${JSON.stringify(json)}`);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
const buffer = await res.arrayBuffer();
|
|
2459
|
+
return {
|
|
2460
|
+
buffer: Buffer.from(buffer),
|
|
2461
|
+
contentType,
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// 命令处理函数
|
|
2466
|
+
async function handleHelpCommand({ api, fromUser, corpId, corpSecret, agentId, proxyUrl }) {
|
|
2467
|
+
const helpText = `🤖 AI 助手使用帮助
|
|
2468
|
+
|
|
2469
|
+
可用命令:
|
|
2470
|
+
/help - 显示此帮助信息
|
|
2471
|
+
/clear - 重置会话(等价于 /reset)
|
|
2472
|
+
/status - 查看系统状态
|
|
2473
|
+
|
|
2474
|
+
直接发送消息即可与 AI 对话。
|
|
2475
|
+
支持发送图片,AI 会分析图片内容。`;
|
|
2476
|
+
|
|
2477
|
+
await sendWecomText({ corpId, corpSecret, agentId, toUser: fromUser, text: helpText, proxyUrl, logger: api.logger });
|
|
2478
|
+
return true;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
async function handleStatusCommand({ api, fromUser, corpId, corpSecret, agentId, accountId, proxyUrl }) {
|
|
2482
|
+
const config = getWecomConfig(api, accountId);
|
|
2483
|
+
const accountIds = listWecomAccountIds(api);
|
|
2484
|
+
const voiceConfig = resolveWecomVoiceTranscriptionConfig(api);
|
|
2485
|
+
const commandPolicy = resolveWecomCommandPolicy(api);
|
|
2486
|
+
const allowFromPolicy = resolveWecomAllowFromPolicy(api, config?.accountId, config);
|
|
2487
|
+
const groupPolicy = resolveWecomGroupChatPolicy(api);
|
|
2488
|
+
const debouncePolicy = resolveWecomTextDebouncePolicy(api);
|
|
2489
|
+
const streamingPolicy = resolveWecomReplyStreamingPolicy(api);
|
|
2490
|
+
const proxyEnabled = Boolean(config?.outboundProxy);
|
|
2491
|
+
const voiceStatusLine = voiceConfig.enabled
|
|
2492
|
+
? `✅ 语音消息转写(本地 ${voiceConfig.provider},模型: ${voiceConfig.modelPath || voiceConfig.model})`
|
|
2493
|
+
: "⚠️ 语音消息转写回退未启用(仅使用企业微信 Recognition)";
|
|
2494
|
+
const commandPolicyLine = commandPolicy.enabled
|
|
2495
|
+
? `✅ 指令白名单已启用(${commandPolicy.allowlist.length} 条,管理员 ${commandPolicy.adminUsers.length} 人)`
|
|
2496
|
+
: "ℹ️ 指令白名单未启用";
|
|
2497
|
+
const allowFromPolicyLine =
|
|
2498
|
+
allowFromPolicy.allowFrom.length === 0 || allowFromPolicy.allowFrom.includes("*")
|
|
2499
|
+
? "ℹ️ 发送者授权:未限制(allowFrom 未配置)"
|
|
2500
|
+
: `✅ 发送者授权:已限制 ${allowFromPolicy.allowFrom.length} 个用户`;
|
|
2501
|
+
const groupPolicyLine = groupPolicy.enabled
|
|
2502
|
+
? groupPolicy.requireMention
|
|
2503
|
+
? "✅ 群聊触发:仅 @ 命中后处理"
|
|
2504
|
+
: "✅ 群聊触发:无需 @(全部处理)"
|
|
2505
|
+
: "⚠️ 群聊处理未启用";
|
|
2506
|
+
const debouncePolicyLine = debouncePolicy.enabled
|
|
2507
|
+
? `✅ 文本防抖合并已启用(${debouncePolicy.windowMs}ms / 最多 ${debouncePolicy.maxBatch} 条)`
|
|
2508
|
+
: "ℹ️ 文本防抖合并未启用";
|
|
2509
|
+
const streamingPolicyLine = streamingPolicy.enabled
|
|
2510
|
+
? `✅ Agent 增量回包已启用(最小片段 ${streamingPolicy.minChars} 字符 / 最短间隔 ${streamingPolicy.minIntervalMs}ms)`
|
|
2511
|
+
: "ℹ️ Agent 增量回包未启用";
|
|
2512
|
+
|
|
2513
|
+
const statusText = `📊 系统状态
|
|
2514
|
+
|
|
2515
|
+
渠道:企业微信 (WeCom)
|
|
2516
|
+
会话ID:wecom:${fromUser}
|
|
2517
|
+
账户ID:${config?.accountId || "default"}
|
|
2518
|
+
已配置账户:${accountIds.join(", ")}
|
|
2519
|
+
插件版本:${PLUGIN_VERSION}
|
|
2520
|
+
|
|
2521
|
+
功能状态:
|
|
2522
|
+
✅ 文本消息
|
|
2523
|
+
✅ 图片发送/接收
|
|
2524
|
+
✅ 消息分段 (2048字符)
|
|
2525
|
+
✅ 命令系统
|
|
2526
|
+
✅ Markdown 转换
|
|
2527
|
+
✅ API 限流
|
|
2528
|
+
✅ 多账户支持
|
|
2529
|
+
${commandPolicyLine}
|
|
2530
|
+
${allowFromPolicyLine}
|
|
2531
|
+
${groupPolicyLine}
|
|
2532
|
+
${debouncePolicyLine}
|
|
2533
|
+
${streamingPolicyLine}
|
|
2534
|
+
${proxyEnabled ? "✅ WeCom 出站代理已启用" : "ℹ️ WeCom 出站代理未启用"}
|
|
2535
|
+
${voiceStatusLine}`;
|
|
2536
|
+
|
|
2537
|
+
await sendWecomText({
|
|
2538
|
+
corpId,
|
|
2539
|
+
corpSecret,
|
|
2540
|
+
agentId,
|
|
2541
|
+
toUser: fromUser,
|
|
2542
|
+
text: statusText,
|
|
2543
|
+
logger: api.logger,
|
|
2544
|
+
proxyUrl,
|
|
2545
|
+
});
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
const COMMANDS = {
|
|
2550
|
+
"/help": handleHelpCommand,
|
|
2551
|
+
"/status": handleStatusCommand,
|
|
2552
|
+
};
|
|
2553
|
+
|
|
2554
|
+
function buildWecomBotHelpText() {
|
|
2555
|
+
return `🤖 AI 助手使用帮助(Bot 流式模式)
|
|
2556
|
+
|
|
2557
|
+
可用命令:
|
|
2558
|
+
/help - 显示帮助信息
|
|
2559
|
+
/status - 查看系统状态
|
|
2560
|
+
/clear - 重置会话(等价于 /reset)
|
|
2561
|
+
|
|
2562
|
+
直接发送消息即可与 AI 对话。`;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function buildWecomBotStatusText(api, fromUser) {
|
|
2566
|
+
const commandPolicy = resolveWecomCommandPolicy(api);
|
|
2567
|
+
const allowFromPolicy = resolveWecomAllowFromPolicy(api, "default", {});
|
|
2568
|
+
const groupPolicy = resolveWecomGroupChatPolicy(api);
|
|
2569
|
+
const botConfig = resolveWecomBotConfig(api);
|
|
2570
|
+
const commandPolicyLine = commandPolicy.enabled
|
|
2571
|
+
? `✅ 指令白名单已启用(${commandPolicy.allowlist.length} 条,管理员 ${commandPolicy.adminUsers.length} 人)`
|
|
2572
|
+
: "ℹ️ 指令白名单未启用";
|
|
2573
|
+
const allowFromPolicyLine =
|
|
2574
|
+
allowFromPolicy.allowFrom.length === 0 || allowFromPolicy.allowFrom.includes("*")
|
|
2575
|
+
? "ℹ️ 发送者授权:未限制(allowFrom 未配置)"
|
|
2576
|
+
: `✅ 发送者授权:已限制 ${allowFromPolicy.allowFrom.length} 个用户`;
|
|
2577
|
+
const groupPolicyLine = groupPolicy.enabled
|
|
2578
|
+
? groupPolicy.requireMention
|
|
2579
|
+
? "✅ 群聊触发:仅 @ 命中后处理"
|
|
2580
|
+
: "✅ 群聊触发:无需 @(全部处理)"
|
|
2581
|
+
: "⚠️ 群聊处理未启用";
|
|
2582
|
+
return `📊 系统状态
|
|
2583
|
+
|
|
2584
|
+
渠道:企业微信 AI 机器人 (Bot)
|
|
2585
|
+
会话ID:wecom:${fromUser}
|
|
2586
|
+
插件版本:${PLUGIN_VERSION}
|
|
2587
|
+
Bot Webhook:${botConfig.webhookPath}
|
|
2588
|
+
|
|
2589
|
+
功能状态:
|
|
2590
|
+
✅ 原生流式回复(stream)
|
|
2591
|
+
${commandPolicyLine}
|
|
2592
|
+
${allowFromPolicyLine}
|
|
2593
|
+
${groupPolicyLine}`;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
async function processBotInboundMessage({
|
|
2597
|
+
api,
|
|
2598
|
+
streamId,
|
|
2599
|
+
fromUser,
|
|
2600
|
+
content,
|
|
2601
|
+
msgType = "text",
|
|
2602
|
+
msgId,
|
|
2603
|
+
chatId,
|
|
2604
|
+
isGroupChat = false,
|
|
2605
|
+
imageUrls = [],
|
|
2606
|
+
}) {
|
|
2607
|
+
const runtime = api.runtime;
|
|
2608
|
+
const cfg = api.config;
|
|
2609
|
+
const sessionId = buildWecomSessionId(fromUser);
|
|
2610
|
+
const fromAddress = `wecom:${fromUser}`;
|
|
2611
|
+
const normalizedFromUser = String(fromUser ?? "").trim().toLowerCase();
|
|
2612
|
+
const originalContent = String(content ?? "");
|
|
2613
|
+
let commandBody = originalContent;
|
|
2614
|
+
const dispatchStartedAt = Date.now();
|
|
2615
|
+
const tempPathsToCleanup = [];
|
|
2616
|
+
const botProxyUrl = resolveWecomBotProxyConfig(api);
|
|
2617
|
+
const normalizedImageUrls = Array.from(
|
|
2618
|
+
new Set(
|
|
2619
|
+
(Array.isArray(imageUrls) ? imageUrls : [])
|
|
2620
|
+
.map((item) => String(item ?? "").trim())
|
|
2621
|
+
.filter(Boolean),
|
|
2622
|
+
),
|
|
2623
|
+
);
|
|
2624
|
+
|
|
2625
|
+
const safeFinishStream = (text) => {
|
|
2626
|
+
if (!BOT_STREAMS.has(streamId)) return;
|
|
2627
|
+
finishBotStream(streamId, String(text ?? ""));
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2630
|
+
try {
|
|
2631
|
+
if (isGroupChat && msgType === "text") {
|
|
2632
|
+
const groupChatPolicy = resolveWecomGroupChatPolicy(api);
|
|
2633
|
+
if (!groupChatPolicy.enabled) {
|
|
2634
|
+
safeFinishStream("当前群聊消息处理未启用。");
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (!shouldTriggerWecomGroupResponse(commandBody, groupChatPolicy)) {
|
|
2638
|
+
const hint = groupChatPolicy.requireMention ? "请先 @ 机器人后再发送消息。" : "当前消息不满足群聊触发条件。";
|
|
2639
|
+
safeFinishStream(hint);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
commandBody = stripWecomGroupMentions(commandBody, groupChatPolicy.mentionPatterns);
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
const commandPolicy = resolveWecomCommandPolicy(api);
|
|
2646
|
+
const isAdminUser = commandPolicy.adminUsers.includes(normalizedFromUser);
|
|
2647
|
+
const allowFromPolicy = resolveWecomAllowFromPolicy(api, "default", {});
|
|
2648
|
+
const senderAllowed = isAdminUser || isWecomSenderAllowed({
|
|
2649
|
+
senderId: normalizedFromUser,
|
|
2650
|
+
allowFrom: allowFromPolicy.allowFrom,
|
|
2651
|
+
});
|
|
2652
|
+
if (!senderAllowed) {
|
|
2653
|
+
safeFinishStream(allowFromPolicy.rejectMessage || "当前账号未授权,请联系管理员。");
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
if (msgType === "text") {
|
|
2658
|
+
let commandKey = extractLeadingSlashCommand(commandBody);
|
|
2659
|
+
if (commandKey === "/clear") {
|
|
2660
|
+
commandBody = commandBody.replace(/^\/clear\b/i, "/reset");
|
|
2661
|
+
commandKey = "/reset";
|
|
2662
|
+
}
|
|
2663
|
+
if (commandKey) {
|
|
2664
|
+
const commandAllowed =
|
|
2665
|
+
commandPolicy.allowlist.includes(commandKey) ||
|
|
2666
|
+
(commandKey === "/reset" && commandPolicy.allowlist.includes("/clear"));
|
|
2667
|
+
if (commandPolicy.enabled && !isAdminUser && !commandAllowed) {
|
|
2668
|
+
safeFinishStream(commandPolicy.rejectMessage);
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
if (commandKey === "/help") {
|
|
2672
|
+
safeFinishStream(buildWecomBotHelpText());
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
if (commandKey === "/status") {
|
|
2676
|
+
safeFinishStream(buildWecomBotStatusText(api, fromUser));
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
let messageText = String(commandBody ?? "").trim();
|
|
2683
|
+
if (normalizedImageUrls.length > 0) {
|
|
2684
|
+
const fetchedImagePaths = [];
|
|
2685
|
+
const imageUrlsToFetch = normalizedImageUrls.slice(0, 3);
|
|
2686
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
2687
|
+
const botModeConfig = resolveWecomBotConfig(api);
|
|
2688
|
+
await mkdir(tempDir, { recursive: true });
|
|
2689
|
+
for (const imageUrl of imageUrlsToFetch) {
|
|
2690
|
+
try {
|
|
2691
|
+
const { buffer, contentType } = await fetchMediaFromUrl(imageUrl, {
|
|
2692
|
+
proxyUrl: botProxyUrl,
|
|
2693
|
+
logger: api.logger,
|
|
2694
|
+
forceProxy: Boolean(botProxyUrl),
|
|
2695
|
+
maxBytes: 8 * 1024 * 1024,
|
|
2696
|
+
});
|
|
2697
|
+
const normalizedType = String(contentType ?? "")
|
|
2698
|
+
.trim()
|
|
2699
|
+
.toLowerCase()
|
|
2700
|
+
.split(";")[0]
|
|
2701
|
+
.trim();
|
|
2702
|
+
let effectiveBuffer = buffer;
|
|
2703
|
+
let effectiveImageType =
|
|
2704
|
+
normalizedType.startsWith("image/") ? normalizedType : detectImageContentTypeFromBuffer(buffer);
|
|
2705
|
+
if (!effectiveImageType && botModeConfig?.encodingAesKey) {
|
|
2706
|
+
try {
|
|
2707
|
+
const decryptedBuffer = decryptWecomMediaBuffer({
|
|
2708
|
+
aesKey: botModeConfig.encodingAesKey,
|
|
2709
|
+
encryptedBuffer: buffer,
|
|
2710
|
+
});
|
|
2711
|
+
const decryptedImageType = detectImageContentTypeFromBuffer(decryptedBuffer);
|
|
2712
|
+
if (decryptedImageType) {
|
|
2713
|
+
effectiveBuffer = decryptedBuffer;
|
|
2714
|
+
effectiveImageType = decryptedImageType;
|
|
2715
|
+
api.logger.info?.(
|
|
2716
|
+
`wecom(bot): decrypted media buffer from content-type=${normalizedType || "unknown"} to ${decryptedImageType}`,
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
} catch (decryptErr) {
|
|
2720
|
+
api.logger.warn?.(`wecom(bot): media decrypt attempt failed: ${String(decryptErr?.message || decryptErr)}`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
if (!effectiveImageType) {
|
|
2724
|
+
const headerHex = buffer.subarray(0, 16).toString("hex");
|
|
2725
|
+
throw new Error(`unexpected content-type: ${normalizedType || "unknown"} header=${headerHex}`);
|
|
2726
|
+
}
|
|
2727
|
+
const ext = pickImageFileExtension({ contentType: effectiveImageType, sourceUrl: imageUrl });
|
|
2728
|
+
const imageTempPath = join(
|
|
2729
|
+
tempDir,
|
|
2730
|
+
`bot-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}${ext}`,
|
|
2731
|
+
);
|
|
2732
|
+
await writeFile(imageTempPath, effectiveBuffer);
|
|
2733
|
+
fetchedImagePaths.push(imageTempPath);
|
|
2734
|
+
tempPathsToCleanup.push(imageTempPath);
|
|
2735
|
+
api.logger.info?.(
|
|
2736
|
+
`wecom(bot): downloaded image from url, size=${effectiveBuffer.length} bytes, path=${imageTempPath}`,
|
|
2737
|
+
);
|
|
2738
|
+
} catch (imageErr) {
|
|
2739
|
+
api.logger.warn?.(`wecom(bot): failed to fetch image url: ${String(imageErr?.message || imageErr)}`);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
if (fetchedImagePaths.length > 0) {
|
|
2744
|
+
const intro = fetchedImagePaths.length > 1 ? "[用户发送了多张图片]" : "[用户发送了一张图片]";
|
|
2745
|
+
const parts = [];
|
|
2746
|
+
if (messageText) parts.push(messageText);
|
|
2747
|
+
parts.push(intro);
|
|
2748
|
+
for (let i = 0; i < fetchedImagePaths.length; i += 1) {
|
|
2749
|
+
parts.push(`图片${i + 1}: ${fetchedImagePaths[i]}`);
|
|
2750
|
+
}
|
|
2751
|
+
parts.push("请使用 Read 工具查看图片并基于图片内容回复用户。");
|
|
2752
|
+
messageText = parts.join("\n").trim();
|
|
2753
|
+
} else if (!messageText || messageText === "[图片]") {
|
|
2754
|
+
safeFinishStream("图片接收失败(下载失败或链接失效),请重新发送原图后重试。");
|
|
2755
|
+
return;
|
|
2756
|
+
} else {
|
|
2757
|
+
messageText = `${messageText}\n\n[附加说明] 用户还发送了图片,但插件下载失败。`;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
if (!messageText) {
|
|
2762
|
+
safeFinishStream("消息内容为空,请发送有效文本。");
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
2767
|
+
cfg,
|
|
2768
|
+
sessionKey: sessionId,
|
|
2769
|
+
channel: "wecom",
|
|
2770
|
+
accountId: "bot",
|
|
2771
|
+
});
|
|
2772
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
2773
|
+
agentId: route.agentId,
|
|
2774
|
+
});
|
|
2775
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
2776
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
2777
|
+
channel: "WeCom Bot",
|
|
2778
|
+
from: isGroupChat && chatId ? `${fromUser} (group:${chatId})` : fromUser,
|
|
2779
|
+
timestamp: Date.now(),
|
|
2780
|
+
body: messageText,
|
|
2781
|
+
chatType: isGroupChat ? "group" : "direct",
|
|
2782
|
+
sender: {
|
|
2783
|
+
name: fromUser,
|
|
2784
|
+
id: fromUser,
|
|
2785
|
+
},
|
|
2786
|
+
...envelopeOptions,
|
|
2787
|
+
});
|
|
2788
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
2789
|
+
Body: body,
|
|
2790
|
+
BodyForAgent: messageText,
|
|
2791
|
+
RawBody: originalContent,
|
|
2792
|
+
CommandBody: commandBody,
|
|
2793
|
+
From: fromAddress,
|
|
2794
|
+
To: fromAddress,
|
|
2795
|
+
SessionKey: sessionId,
|
|
2796
|
+
AccountId: "bot",
|
|
2797
|
+
ChatType: isGroupChat ? "group" : "direct",
|
|
2798
|
+
ConversationLabel: isGroupChat && chatId ? `group:${chatId}` : fromUser,
|
|
2799
|
+
SenderName: fromUser,
|
|
2800
|
+
SenderId: fromUser,
|
|
2801
|
+
Provider: "wecom",
|
|
2802
|
+
Surface: "wecom-bot",
|
|
2803
|
+
MessageSid: msgId || `wecom-bot-${Date.now()}`,
|
|
2804
|
+
Timestamp: Date.now(),
|
|
2805
|
+
OriginatingChannel: "wecom",
|
|
2806
|
+
OriginatingTo: fromAddress,
|
|
2807
|
+
});
|
|
2808
|
+
const sessionRuntimeId = String(ctxPayload.SessionId ?? "").trim();
|
|
2809
|
+
|
|
2810
|
+
await runtime.channel.session.recordInboundSession({
|
|
2811
|
+
storePath,
|
|
2812
|
+
sessionKey: sessionId,
|
|
2813
|
+
ctx: ctxPayload,
|
|
2814
|
+
updateLastRoute: {
|
|
2815
|
+
sessionKey: sessionId,
|
|
2816
|
+
channel: "wecom",
|
|
2817
|
+
to: fromUser,
|
|
2818
|
+
accountId: "bot",
|
|
2819
|
+
},
|
|
2820
|
+
onRecordError: (err) => {
|
|
2821
|
+
api.logger.warn?.(`wecom(bot): failed to record session: ${err}`);
|
|
2822
|
+
},
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
runtime.channel.activity.record({
|
|
2826
|
+
channel: "wecom",
|
|
2827
|
+
accountId: "bot",
|
|
2828
|
+
direction: "inbound",
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
let blockText = "";
|
|
2832
|
+
let streamFinished = false;
|
|
2833
|
+
const replyTimeoutMs = Math.max(
|
|
2834
|
+
15000,
|
|
2835
|
+
asNumber(cfg?.env?.vars?.WECOM_REPLY_TIMEOUT_MS ?? requireEnv("WECOM_REPLY_TIMEOUT_MS"), 90000),
|
|
2836
|
+
);
|
|
2837
|
+
const tryFinishFromTranscript = async (minTimestamp = dispatchStartedAt) => {
|
|
2838
|
+
try {
|
|
2839
|
+
const transcriptPath = await resolveSessionTranscriptFilePath({
|
|
2840
|
+
storePath,
|
|
2841
|
+
sessionKey: sessionId,
|
|
2842
|
+
sessionId: sessionRuntimeId || sessionId,
|
|
2843
|
+
logger: api.logger,
|
|
2844
|
+
});
|
|
2845
|
+
const { chunk } = await readTranscriptAppendedChunk(transcriptPath, 0);
|
|
2846
|
+
if (!chunk) return false;
|
|
2847
|
+
const lines = chunk.split("\n");
|
|
2848
|
+
let latestReply = null;
|
|
2849
|
+
for (const line of lines) {
|
|
2850
|
+
const parsedReply = parseLateAssistantReplyFromTranscriptLine(line, minTimestamp);
|
|
2851
|
+
if (!parsedReply) continue;
|
|
2852
|
+
latestReply = parsedReply;
|
|
2853
|
+
}
|
|
2854
|
+
if (!latestReply?.text) return false;
|
|
2855
|
+
const transcriptText = markdownToWecomText(latestReply.text).trim();
|
|
2856
|
+
if (!transcriptText) return false;
|
|
2857
|
+
safeFinishStream(transcriptText);
|
|
2858
|
+
streamFinished = true;
|
|
2859
|
+
api.logger.info?.(
|
|
2860
|
+
`wecom(bot): filled reply from transcript session=${sessionId} messageId=${latestReply.transcriptMessageId}`,
|
|
2861
|
+
);
|
|
2862
|
+
return true;
|
|
2863
|
+
} catch (err) {
|
|
2864
|
+
api.logger.warn?.(`wecom(bot): transcript fallback failed: ${String(err?.message || err)}`);
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
|
|
2869
|
+
await withTimeout(
|
|
2870
|
+
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2871
|
+
ctx: ctxPayload,
|
|
2872
|
+
cfg,
|
|
2873
|
+
replyOptions: {
|
|
2874
|
+
disableBlockStreaming: false,
|
|
2875
|
+
},
|
|
2876
|
+
dispatcherOptions: {
|
|
2877
|
+
deliver: async (payload, info) => {
|
|
2878
|
+
if (!BOT_STREAMS.has(streamId)) return;
|
|
2879
|
+
if (info.kind === "block") {
|
|
2880
|
+
if (!payload?.text) return;
|
|
2881
|
+
const incomingBlock = String(payload.text);
|
|
2882
|
+
if (incomingBlock.startsWith(blockText)) {
|
|
2883
|
+
blockText = incomingBlock;
|
|
2884
|
+
} else if (!blockText.endsWith(incomingBlock)) {
|
|
2885
|
+
blockText += incomingBlock;
|
|
2886
|
+
}
|
|
2887
|
+
updateBotStream(streamId, markdownToWecomText(blockText), { append: false, finished: false });
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
if (info.kind !== "final") return;
|
|
2891
|
+
if (payload?.text) {
|
|
2892
|
+
if (isAgentFailureText(payload.text)) {
|
|
2893
|
+
safeFinishStream(`抱歉,请求失败:${payload.text}`);
|
|
2894
|
+
streamFinished = true;
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
const finalText = markdownToWecomText(payload.text).trim();
|
|
2898
|
+
if (finalText) {
|
|
2899
|
+
safeFinishStream(finalText);
|
|
2900
|
+
streamFinished = true;
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
if (payload?.mediaUrl || (payload?.mediaUrls?.length ?? 0) > 0) {
|
|
2905
|
+
safeFinishStream("已收到模型返回的媒体结果,但 Bot 流式模式暂不支持直接回传该媒体。");
|
|
2906
|
+
streamFinished = true;
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
},
|
|
2910
|
+
onError: async (err, info) => {
|
|
2911
|
+
api.logger.error?.(`wecom(bot): ${info.kind} reply failed: ${String(err)}`);
|
|
2912
|
+
safeFinishStream(`抱歉,当前模型请求失败,请稍后重试。\n故障信息: ${String(err?.message || err).slice(0, 160)}`);
|
|
2913
|
+
streamFinished = true;
|
|
2914
|
+
},
|
|
2915
|
+
},
|
|
2916
|
+
}),
|
|
2917
|
+
replyTimeoutMs,
|
|
2918
|
+
`dispatch timed out after ${replyTimeoutMs}ms`,
|
|
2919
|
+
);
|
|
2920
|
+
|
|
2921
|
+
if (!streamFinished) {
|
|
2922
|
+
const filledFromTranscript = await tryFinishFromTranscript(dispatchStartedAt);
|
|
2923
|
+
if (filledFromTranscript) return;
|
|
2924
|
+
const fallback = markdownToWecomText(blockText).trim();
|
|
2925
|
+
if (fallback) {
|
|
2926
|
+
safeFinishStream(fallback);
|
|
2927
|
+
} else {
|
|
2928
|
+
api.logger.warn?.(
|
|
2929
|
+
`wecom(bot): dispatch finished without deliverable content; fallback to timeout text session=${sessionId}`,
|
|
2930
|
+
);
|
|
2931
|
+
safeFinishStream("抱歉,当前模型请求超时或网络不稳定,请稍后重试。");
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
} catch (err) {
|
|
2935
|
+
api.logger.warn?.(`wecom(bot): processing failed: ${String(err?.message || err)}`);
|
|
2936
|
+
try {
|
|
2937
|
+
const fallbackFromTranscript = await (async () => {
|
|
2938
|
+
try {
|
|
2939
|
+
const runtimeSessionId = buildWecomSessionId(fromUser);
|
|
2940
|
+
const runtimeStorePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
2941
|
+
agentId: runtime.channel.routing.resolveAgentRoute({
|
|
2942
|
+
cfg,
|
|
2943
|
+
sessionKey: runtimeSessionId,
|
|
2944
|
+
channel: "wecom",
|
|
2945
|
+
accountId: "bot",
|
|
2946
|
+
}).agentId,
|
|
2947
|
+
});
|
|
2948
|
+
const transcriptPath = await resolveSessionTranscriptFilePath({
|
|
2949
|
+
storePath: runtimeStorePath,
|
|
2950
|
+
sessionKey: runtimeSessionId,
|
|
2951
|
+
sessionId: runtimeSessionId,
|
|
2952
|
+
logger: api.logger,
|
|
2953
|
+
});
|
|
2954
|
+
const { chunk } = await readTranscriptAppendedChunk(transcriptPath, 0);
|
|
2955
|
+
if (!chunk) return "";
|
|
2956
|
+
const lines = chunk.split("\n");
|
|
2957
|
+
let latestReply = null;
|
|
2958
|
+
for (const line of lines) {
|
|
2959
|
+
const parsedReply = parseLateAssistantReplyFromTranscriptLine(line, dispatchStartedAt);
|
|
2960
|
+
if (!parsedReply) continue;
|
|
2961
|
+
latestReply = parsedReply;
|
|
2962
|
+
}
|
|
2963
|
+
const text = latestReply?.text ? markdownToWecomText(latestReply.text).trim() : "";
|
|
2964
|
+
return text || "";
|
|
2965
|
+
} catch {
|
|
2966
|
+
return "";
|
|
2967
|
+
}
|
|
2968
|
+
})();
|
|
2969
|
+
if (fallbackFromTranscript) {
|
|
2970
|
+
safeFinishStream(fallbackFromTranscript);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
} catch {
|
|
2974
|
+
// ignore transcript fallback errors in catch block
|
|
2975
|
+
}
|
|
2976
|
+
safeFinishStream(`抱歉,当前模型请求超时或网络不稳定,请稍后重试。\n故障信息: ${String(err?.message || err).slice(0, 160)}`);
|
|
2977
|
+
} finally {
|
|
2978
|
+
for (const filePath of tempPathsToCleanup) {
|
|
2979
|
+
scheduleTempFileCleanup(filePath, api.logger);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// 异步处理入站消息 - 使用 gateway 内部 agent runtime API
|
|
2985
|
+
async function processInboundMessage({
|
|
2986
|
+
api,
|
|
2987
|
+
accountId,
|
|
2988
|
+
fromUser,
|
|
2989
|
+
content,
|
|
2990
|
+
msgType,
|
|
2991
|
+
mediaId,
|
|
2992
|
+
picUrl,
|
|
2993
|
+
recognition,
|
|
2994
|
+
thumbMediaId,
|
|
2995
|
+
fileName,
|
|
2996
|
+
fileSize,
|
|
2997
|
+
linkTitle,
|
|
2998
|
+
linkDescription,
|
|
2999
|
+
linkUrl,
|
|
3000
|
+
linkPicUrl,
|
|
3001
|
+
chatId,
|
|
3002
|
+
isGroupChat,
|
|
3003
|
+
msgId,
|
|
3004
|
+
}) {
|
|
3005
|
+
const config = getWecomConfig(api, accountId);
|
|
3006
|
+
const cfg = api.config;
|
|
3007
|
+
const runtime = api.runtime;
|
|
3008
|
+
|
|
3009
|
+
if (!config?.corpId || !config?.corpSecret || !config?.agentId) {
|
|
3010
|
+
api.logger.warn?.("wecom: not configured (check channels.wecom in openclaw.json)");
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
const { corpId, corpSecret, agentId, outboundProxy: proxyUrl } = config;
|
|
3015
|
+
|
|
3016
|
+
try {
|
|
3017
|
+
// 一用户一会话:群聊和私聊统一归并到 wecom:<userid>
|
|
3018
|
+
const sessionId = buildWecomSessionId(fromUser);
|
|
3019
|
+
const fromAddress = `wecom:${fromUser}`;
|
|
3020
|
+
const normalizedFromUser = String(fromUser ?? "").trim().toLowerCase();
|
|
3021
|
+
const originalContent = content || "";
|
|
3022
|
+
let commandBody = originalContent;
|
|
3023
|
+
api.logger.info?.(`wecom: processing ${msgType} message for session ${sessionId}${isGroupChat ? " (group)" : ""}`);
|
|
3024
|
+
|
|
3025
|
+
// 群聊触发策略(仅对文本消息)
|
|
3026
|
+
if (msgType === "text" && isGroupChat) {
|
|
3027
|
+
const groupChatPolicy = resolveWecomGroupChatPolicy(api);
|
|
3028
|
+
if (!groupChatPolicy.enabled) {
|
|
3029
|
+
api.logger.info?.(`wecom: group chat processing disabled, skipped chatId=${chatId || "unknown"}`);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
if (!shouldTriggerWecomGroupResponse(commandBody, groupChatPolicy)) {
|
|
3033
|
+
api.logger.info?.(
|
|
3034
|
+
`wecom: group message skipped by trigger policy chatId=${chatId || "unknown"} requireMention=${groupChatPolicy.requireMention}`,
|
|
3035
|
+
);
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
commandBody = stripWecomGroupMentions(commandBody, groupChatPolicy.mentionPatterns);
|
|
3039
|
+
if (!commandBody.trim()) {
|
|
3040
|
+
api.logger.info?.(`wecom: group message became empty after mention strip chatId=${chatId || "unknown"}`);
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
const commandPolicy = resolveWecomCommandPolicy(api);
|
|
3046
|
+
const isAdminUser = commandPolicy.adminUsers.includes(normalizedFromUser);
|
|
3047
|
+
const allowFromPolicy = resolveWecomAllowFromPolicy(api, config.accountId || accountId || "default", config);
|
|
3048
|
+
const senderAllowed = isAdminUser || isWecomSenderAllowed({
|
|
3049
|
+
senderId: normalizedFromUser,
|
|
3050
|
+
allowFrom: allowFromPolicy.allowFrom,
|
|
3051
|
+
});
|
|
3052
|
+
if (!senderAllowed) {
|
|
3053
|
+
api.logger.warn?.(
|
|
3054
|
+
`wecom: sender blocked by allowFrom account=${config.accountId || "default"} user=${normalizedFromUser}`,
|
|
3055
|
+
);
|
|
3056
|
+
if (allowFromPolicy.rejectMessage) {
|
|
3057
|
+
await sendWecomText({
|
|
3058
|
+
corpId,
|
|
3059
|
+
corpSecret,
|
|
3060
|
+
agentId,
|
|
3061
|
+
toUser: fromUser,
|
|
3062
|
+
text: allowFromPolicy.rejectMessage,
|
|
3063
|
+
logger: api.logger,
|
|
3064
|
+
proxyUrl,
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// 命令检测(仅对文本消息)
|
|
3071
|
+
if (msgType === "text") {
|
|
3072
|
+
let commandKey = extractLeadingSlashCommand(commandBody);
|
|
3073
|
+
if (commandKey === "/clear") {
|
|
3074
|
+
api.logger.info?.("wecom: translating /clear to native /reset command");
|
|
3075
|
+
commandBody = commandBody.replace(/^\/clear\b/i, "/reset");
|
|
3076
|
+
commandKey = "/reset";
|
|
3077
|
+
}
|
|
3078
|
+
if (commandKey) {
|
|
3079
|
+
const commandAllowed =
|
|
3080
|
+
commandPolicy.allowlist.includes(commandKey) ||
|
|
3081
|
+
(commandKey === "/reset" && commandPolicy.allowlist.includes("/clear"));
|
|
3082
|
+
if (commandPolicy.enabled && !isAdminUser && !commandAllowed) {
|
|
3083
|
+
api.logger.info?.(`wecom: command blocked by allowlist user=${fromUser} command=${commandKey}`);
|
|
3084
|
+
await sendWecomText({
|
|
3085
|
+
corpId,
|
|
3086
|
+
corpSecret,
|
|
3087
|
+
agentId,
|
|
3088
|
+
toUser: fromUser,
|
|
3089
|
+
text: commandPolicy.rejectMessage,
|
|
3090
|
+
logger: api.logger,
|
|
3091
|
+
proxyUrl,
|
|
3092
|
+
});
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
const handler = COMMANDS[commandKey];
|
|
3096
|
+
if (handler) {
|
|
3097
|
+
api.logger.info?.(`wecom: handling command ${commandKey}`);
|
|
3098
|
+
await handler({
|
|
3099
|
+
api,
|
|
3100
|
+
fromUser,
|
|
3101
|
+
corpId,
|
|
3102
|
+
corpSecret,
|
|
3103
|
+
agentId,
|
|
3104
|
+
accountId: config.accountId || "default",
|
|
3105
|
+
proxyUrl,
|
|
3106
|
+
chatId,
|
|
3107
|
+
isGroupChat,
|
|
3108
|
+
});
|
|
3109
|
+
return; // 命令已处理,不再调用 AI
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
let messageText = msgType === "text" ? commandBody : originalContent;
|
|
3115
|
+
const tempPathsToCleanup = [];
|
|
3116
|
+
|
|
3117
|
+
// 处理图片消息 - 真正的 Vision 能力
|
|
3118
|
+
let imageBase64 = null;
|
|
3119
|
+
let imageMimeType = null;
|
|
3120
|
+
|
|
3121
|
+
if (msgType === "image" && mediaId) {
|
|
3122
|
+
api.logger.info?.(`wecom: downloading image mediaId=${mediaId}`);
|
|
3123
|
+
|
|
3124
|
+
try {
|
|
3125
|
+
// 优先使用 mediaId 下载原图
|
|
3126
|
+
const { buffer, contentType } = await downloadWecomMedia({
|
|
3127
|
+
corpId,
|
|
3128
|
+
corpSecret,
|
|
3129
|
+
mediaId,
|
|
3130
|
+
proxyUrl,
|
|
3131
|
+
logger: api.logger,
|
|
3132
|
+
});
|
|
3133
|
+
imageBase64 = buffer.toString("base64");
|
|
3134
|
+
imageMimeType = contentType || "image/jpeg";
|
|
3135
|
+
messageText = "[用户发送了一张图片]";
|
|
3136
|
+
api.logger.info?.(`wecom: image downloaded, size=${buffer.length} bytes, type=${imageMimeType}`);
|
|
3137
|
+
} catch (downloadErr) {
|
|
3138
|
+
api.logger.warn?.(`wecom: failed to download image via mediaId: ${downloadErr.message}`);
|
|
3139
|
+
|
|
3140
|
+
// 降级:尝试通过 PicUrl 下载
|
|
3141
|
+
if (picUrl) {
|
|
3142
|
+
try {
|
|
3143
|
+
const { buffer, contentType } = await fetchMediaFromUrl(picUrl);
|
|
3144
|
+
imageBase64 = buffer.toString("base64");
|
|
3145
|
+
imageMimeType = contentType || "image/jpeg";
|
|
3146
|
+
messageText = "[用户发送了一张图片]";
|
|
3147
|
+
api.logger.info?.(`wecom: image downloaded via PicUrl, size=${buffer.length} bytes`);
|
|
3148
|
+
} catch (picUrlErr) {
|
|
3149
|
+
api.logger.warn?.(`wecom: failed to download image via PicUrl: ${picUrlErr.message}`);
|
|
3150
|
+
messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
3151
|
+
}
|
|
3152
|
+
} else {
|
|
3153
|
+
messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// 处理语音消息
|
|
3159
|
+
if (msgType === "voice" && mediaId) {
|
|
3160
|
+
api.logger.info?.(`wecom: received voice message mediaId=${mediaId}`);
|
|
3161
|
+
const recognizedText = String(recognition ?? "").trim();
|
|
3162
|
+
if (recognizedText) {
|
|
3163
|
+
api.logger.info?.(`wecom: voice recognition result from WeCom: ${recognizedText.slice(0, 50)}...`);
|
|
3164
|
+
messageText = `[语音消息转写]\n${recognizedText}`;
|
|
3165
|
+
} else {
|
|
3166
|
+
const voiceConfig = resolveWecomVoiceTranscriptionConfig(api);
|
|
3167
|
+
if (!voiceConfig.enabled) {
|
|
3168
|
+
api.logger.info?.("wecom: voice transcription fallback disabled; asking user to send text");
|
|
3169
|
+
await sendWecomText({
|
|
3170
|
+
corpId,
|
|
3171
|
+
corpSecret,
|
|
3172
|
+
agentId,
|
|
3173
|
+
toUser: fromUser,
|
|
3174
|
+
text: "语音识别未启用,请先开启企业微信语音识别,或直接发送文字消息。",
|
|
3175
|
+
logger: api.logger,
|
|
3176
|
+
proxyUrl,
|
|
3177
|
+
});
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
try {
|
|
3182
|
+
const { buffer, contentType } = await downloadWecomMedia({
|
|
3183
|
+
corpId,
|
|
3184
|
+
corpSecret,
|
|
3185
|
+
mediaId,
|
|
3186
|
+
proxyUrl,
|
|
3187
|
+
logger: api.logger,
|
|
3188
|
+
});
|
|
3189
|
+
api.logger.info?.(
|
|
3190
|
+
`wecom: downloaded voice media for transcription, size=${buffer.length}, type=${contentType || "unknown"}`,
|
|
3191
|
+
);
|
|
3192
|
+
const transcript = await transcribeInboundVoice({
|
|
3193
|
+
api,
|
|
3194
|
+
buffer,
|
|
3195
|
+
contentType,
|
|
3196
|
+
mediaId,
|
|
3197
|
+
voiceConfig,
|
|
3198
|
+
});
|
|
3199
|
+
messageText = `[语音消息转写]\n${transcript}`;
|
|
3200
|
+
api.logger.info?.(`wecom: voice transcribed via ${voiceConfig.model}: ${transcript.slice(0, 80)}...`);
|
|
3201
|
+
} catch (voiceErr) {
|
|
3202
|
+
api.logger.warn?.(`wecom: voice transcription failed: ${String(voiceErr?.message || voiceErr)}`);
|
|
3203
|
+
await sendWecomText({
|
|
3204
|
+
corpId,
|
|
3205
|
+
corpSecret,
|
|
3206
|
+
agentId,
|
|
3207
|
+
toUser: fromUser,
|
|
3208
|
+
text:
|
|
3209
|
+
"语音识别失败,请稍后重试。\n" +
|
|
3210
|
+
"如持续失败,请确认本地 whisper 命令可用、模型路径已配置,并已安装 ffmpeg。",
|
|
3211
|
+
logger: api.logger,
|
|
3212
|
+
proxyUrl,
|
|
3213
|
+
});
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
// 处理视频消息
|
|
3220
|
+
if (msgType === "video" && mediaId) {
|
|
3221
|
+
api.logger.info?.(`wecom: received video message mediaId=${mediaId}`);
|
|
3222
|
+
try {
|
|
3223
|
+
const { buffer, contentType } = await downloadWecomMedia({
|
|
3224
|
+
corpId,
|
|
3225
|
+
corpSecret,
|
|
3226
|
+
mediaId,
|
|
3227
|
+
proxyUrl,
|
|
3228
|
+
logger: api.logger,
|
|
3229
|
+
});
|
|
3230
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
3231
|
+
await mkdir(tempDir, { recursive: true });
|
|
3232
|
+
const videoTempPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
3233
|
+
await writeFile(videoTempPath, buffer);
|
|
3234
|
+
tempPathsToCleanup.push(videoTempPath);
|
|
3235
|
+
api.logger.info?.(`wecom: saved video to ${videoTempPath}, size=${buffer.length} bytes`);
|
|
3236
|
+
messageText = `[用户发送了一个视频文件,已保存到: ${videoTempPath}]\n\n请告知用户您已收到视频。`;
|
|
3237
|
+
} catch (downloadErr) {
|
|
3238
|
+
api.logger.warn?.(`wecom: failed to download video: ${downloadErr.message}`);
|
|
3239
|
+
messageText = "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
// 处理文件消息
|
|
3244
|
+
if (msgType === "file" && mediaId) {
|
|
3245
|
+
api.logger.info?.(`wecom: received file message mediaId=${mediaId}, fileName=${fileName}, size=${fileSize}`);
|
|
3246
|
+
try {
|
|
3247
|
+
const { buffer, contentType } = await downloadWecomMedia({
|
|
3248
|
+
corpId,
|
|
3249
|
+
corpSecret,
|
|
3250
|
+
mediaId,
|
|
3251
|
+
proxyUrl,
|
|
3252
|
+
logger: api.logger,
|
|
3253
|
+
});
|
|
3254
|
+
const ext = fileName ? fileName.split(".").pop() : "bin";
|
|
3255
|
+
const safeFileName = fileName || `file-${Date.now()}.${ext}`;
|
|
3256
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
3257
|
+
await mkdir(tempDir, { recursive: true });
|
|
3258
|
+
const fileTempPath = join(tempDir, `${Date.now()}-${safeFileName}`);
|
|
3259
|
+
await writeFile(fileTempPath, buffer);
|
|
3260
|
+
tempPathsToCleanup.push(fileTempPath);
|
|
3261
|
+
api.logger.info?.(`wecom: saved file to ${fileTempPath}, size=${buffer.length} bytes`);
|
|
3262
|
+
|
|
3263
|
+
const readableTypes = [".txt", ".md", ".json", ".xml", ".csv", ".log", ".pdf"];
|
|
3264
|
+
const isReadable = readableTypes.some((t) => safeFileName.toLowerCase().endsWith(t));
|
|
3265
|
+
|
|
3266
|
+
if (isReadable) {
|
|
3267
|
+
messageText = `[用户发送了一个文件: ${safeFileName},已保存到: ${fileTempPath}]\n\n请使用 Read 工具查看这个文件的内容。`;
|
|
3268
|
+
} else {
|
|
3269
|
+
messageText = `[用户发送了一个文件: ${safeFileName},大小: ${fileSize || buffer.length} 字节,已保存到: ${fileTempPath}]\n\n请告知用户您已收到文件。`;
|
|
3270
|
+
}
|
|
3271
|
+
} catch (downloadErr) {
|
|
3272
|
+
api.logger.warn?.(`wecom: failed to download file: ${downloadErr.message}`);
|
|
3273
|
+
messageText = `[用户发送了一个文件${fileName ? `: ${fileName}` : ""},但下载失败]\n\n请告诉用户文件处理暂时不可用。`;
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// 处理链接分享消息
|
|
3278
|
+
if (msgType === "link") {
|
|
3279
|
+
api.logger.info?.(`wecom: received link message title=${linkTitle}, url=${linkUrl}`);
|
|
3280
|
+
messageText = `[用户分享了一个链接]\n标题: ${linkTitle || '(无标题)'}\n描述: ${linkDescription || '(无描述)'}\n链接: ${linkUrl || '(无链接)'}\n\n请根据链接内容回复用户。如需要,可以使用 WebFetch 工具获取链接内容。`;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
if (!messageText) {
|
|
3284
|
+
api.logger.warn?.("wecom: empty message content");
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// 如果有图片,保存到临时文件供 AI 读取
|
|
3289
|
+
let imageTempPath = null;
|
|
3290
|
+
if (imageBase64 && imageMimeType) {
|
|
3291
|
+
try {
|
|
3292
|
+
const ext = imageMimeType.includes("png") ? "png" : imageMimeType.includes("gif") ? "gif" : "jpg";
|
|
3293
|
+
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
|
|
3294
|
+
await mkdir(tempDir, { recursive: true });
|
|
3295
|
+
imageTempPath = join(tempDir, `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
3296
|
+
await writeFile(imageTempPath, Buffer.from(imageBase64, "base64"));
|
|
3297
|
+
tempPathsToCleanup.push(imageTempPath);
|
|
3298
|
+
api.logger.info?.(`wecom: saved image to ${imageTempPath}`);
|
|
3299
|
+
// 更新消息文本,告知 AI 图片位置
|
|
3300
|
+
messageText = `[用户发送了一张图片,已保存到: ${imageTempPath}]\n\n请使用 Read 工具查看这张图片并描述内容。`;
|
|
3301
|
+
} catch (saveErr) {
|
|
3302
|
+
api.logger.warn?.(`wecom: failed to save image: ${saveErr.message}`);
|
|
3303
|
+
messageText = "[用户发送了一张图片,但保存失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
3304
|
+
imageTempPath = null;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// 获取路由信息
|
|
3309
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
3310
|
+
cfg,
|
|
3311
|
+
sessionKey: sessionId,
|
|
3312
|
+
channel: "wecom",
|
|
3313
|
+
accountId: config.accountId || "default",
|
|
3314
|
+
});
|
|
3315
|
+
|
|
3316
|
+
// 获取 storePath
|
|
3317
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
3318
|
+
agentId: route.agentId,
|
|
3319
|
+
});
|
|
3320
|
+
|
|
3321
|
+
// 格式化消息体
|
|
3322
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
3323
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
3324
|
+
channel: "WeCom",
|
|
3325
|
+
from: isGroupChat && chatId ? `${fromUser} (group:${chatId})` : fromUser,
|
|
3326
|
+
timestamp: Date.now(),
|
|
3327
|
+
body: messageText,
|
|
3328
|
+
chatType: isGroupChat ? "group" : "direct",
|
|
3329
|
+
sender: {
|
|
3330
|
+
name: fromUser,
|
|
3331
|
+
id: fromUser,
|
|
3332
|
+
},
|
|
3333
|
+
...envelopeOptions,
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
// 构建 Session 上下文对象
|
|
3337
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
3338
|
+
Body: body,
|
|
3339
|
+
BodyForAgent: messageText,
|
|
3340
|
+
RawBody: originalContent,
|
|
3341
|
+
CommandBody: commandBody,
|
|
3342
|
+
From: fromAddress,
|
|
3343
|
+
To: fromAddress,
|
|
3344
|
+
SessionKey: sessionId,
|
|
3345
|
+
AccountId: config.accountId || "default",
|
|
3346
|
+
ChatType: isGroupChat ? "group" : "direct",
|
|
3347
|
+
ConversationLabel: isGroupChat && chatId ? `group:${chatId}` : fromUser,
|
|
3348
|
+
SenderName: fromUser,
|
|
3349
|
+
SenderId: fromUser,
|
|
3350
|
+
Provider: "wecom",
|
|
3351
|
+
Surface: "wecom",
|
|
3352
|
+
MessageSid: msgId || `wecom-${Date.now()}`,
|
|
3353
|
+
Timestamp: Date.now(),
|
|
3354
|
+
OriginatingChannel: "wecom",
|
|
3355
|
+
OriginatingTo: fromAddress,
|
|
3356
|
+
});
|
|
3357
|
+
|
|
3358
|
+
// 注册会话到 Sessions UI
|
|
3359
|
+
await runtime.channel.session.recordInboundSession({
|
|
3360
|
+
storePath,
|
|
3361
|
+
sessionKey: sessionId,
|
|
3362
|
+
ctx: ctxPayload,
|
|
3363
|
+
updateLastRoute: {
|
|
3364
|
+
sessionKey: sessionId,
|
|
3365
|
+
channel: "wecom",
|
|
3366
|
+
to: fromUser,
|
|
3367
|
+
accountId: config.accountId || "default",
|
|
3368
|
+
},
|
|
3369
|
+
onRecordError: (err) => {
|
|
3370
|
+
api.logger.warn?.(`wecom: failed to record session: ${err}`);
|
|
3371
|
+
},
|
|
3372
|
+
});
|
|
3373
|
+
api.logger.info?.(`wecom: session registered for ${sessionId}`);
|
|
3374
|
+
|
|
3375
|
+
// 记录渠道活动
|
|
3376
|
+
runtime.channel.activity.record({
|
|
3377
|
+
channel: "wecom",
|
|
3378
|
+
accountId: config.accountId || "default",
|
|
3379
|
+
direction: "inbound",
|
|
3380
|
+
});
|
|
3381
|
+
|
|
3382
|
+
api.logger.info?.(`wecom: dispatching message via agent runtime for session ${sessionId}`);
|
|
3383
|
+
|
|
3384
|
+
// 使用 gateway 内部 agent runtime API 调用 AI
|
|
3385
|
+
// 对标 Telegram 的 dispatchReplyWithBufferedBlockDispatcher
|
|
3386
|
+
|
|
3387
|
+
let hasDeliveredReply = false;
|
|
3388
|
+
let hasDeliveredPartialReply = false;
|
|
3389
|
+
let hasSentProgressNotice = false;
|
|
3390
|
+
let blockTextFallback = "";
|
|
3391
|
+
let streamChunkBuffer = "";
|
|
3392
|
+
let streamChunkLastSentAt = 0;
|
|
3393
|
+
let streamChunkSentCount = 0;
|
|
3394
|
+
let streamChunkSendChain = Promise.resolve();
|
|
3395
|
+
let suppressLateDispatcherDeliveries = false;
|
|
3396
|
+
let progressNoticeTimer = null;
|
|
3397
|
+
let lateReplyWatcherPromise = null;
|
|
3398
|
+
const streamingPolicy = resolveWecomReplyStreamingPolicy(api);
|
|
3399
|
+
const streamingEnabled = streamingPolicy.enabled === true;
|
|
3400
|
+
const replyTimeoutMs = Math.max(
|
|
3401
|
+
15000,
|
|
3402
|
+
asNumber(cfg?.env?.vars?.WECOM_REPLY_TIMEOUT_MS ?? requireEnv("WECOM_REPLY_TIMEOUT_MS"), 90000),
|
|
3403
|
+
);
|
|
3404
|
+
const progressNoticeDelayMs = Math.max(
|
|
3405
|
+
0,
|
|
3406
|
+
asNumber(cfg?.env?.vars?.WECOM_PROGRESS_NOTICE_MS ?? requireEnv("WECOM_PROGRESS_NOTICE_MS"), 8000),
|
|
3407
|
+
);
|
|
3408
|
+
const lateReplyWatchMs = Math.max(
|
|
3409
|
+
30000,
|
|
3410
|
+
Math.min(
|
|
3411
|
+
10 * 60 * 1000,
|
|
3412
|
+
asNumber(
|
|
3413
|
+
cfg?.env?.vars?.WECOM_LATE_REPLY_WATCH_MS ?? requireEnv("WECOM_LATE_REPLY_WATCH_MS"),
|
|
3414
|
+
Math.max(replyTimeoutMs, 180000),
|
|
3415
|
+
),
|
|
3416
|
+
),
|
|
3417
|
+
);
|
|
3418
|
+
const lateReplyPollMs = Math.max(
|
|
3419
|
+
500,
|
|
3420
|
+
Math.min(
|
|
3421
|
+
10000,
|
|
3422
|
+
asNumber(cfg?.env?.vars?.WECOM_LATE_REPLY_POLL_MS ?? requireEnv("WECOM_LATE_REPLY_POLL_MS"), 2000),
|
|
3423
|
+
),
|
|
3424
|
+
);
|
|
3425
|
+
const processingNoticeText = "消息已收到,正在处理中,请稍等片刻。";
|
|
3426
|
+
const queuedNoticeText = "上一条消息仍在处理中,你的新消息已加入队列,请稍等片刻。";
|
|
3427
|
+
const enqueueStreamingChunk = async (text, reason = "stream") => {
|
|
3428
|
+
const chunkText = String(text ?? "").trim();
|
|
3429
|
+
if (!chunkText || hasDeliveredReply) return;
|
|
3430
|
+
hasDeliveredPartialReply = true;
|
|
3431
|
+
streamChunkSendChain = streamChunkSendChain
|
|
3432
|
+
.then(async () => {
|
|
3433
|
+
await sendWecomText({
|
|
3434
|
+
corpId,
|
|
3435
|
+
corpSecret,
|
|
3436
|
+
agentId,
|
|
3437
|
+
toUser: fromUser,
|
|
3438
|
+
text: chunkText,
|
|
3439
|
+
logger: api.logger,
|
|
3440
|
+
proxyUrl,
|
|
3441
|
+
});
|
|
3442
|
+
streamChunkLastSentAt = Date.now();
|
|
3443
|
+
streamChunkSentCount += 1;
|
|
3444
|
+
api.logger.info?.(
|
|
3445
|
+
`wecom: streamed block chunk ${streamChunkSentCount} (${reason}), bytes=${getByteLength(chunkText)}`,
|
|
3446
|
+
);
|
|
3447
|
+
})
|
|
3448
|
+
.catch((streamErr) => {
|
|
3449
|
+
api.logger.warn?.(`wecom: failed to send streaming block chunk: ${String(streamErr)}`);
|
|
3450
|
+
});
|
|
3451
|
+
await streamChunkSendChain;
|
|
3452
|
+
};
|
|
3453
|
+
const flushStreamingBuffer = async ({ force = false, reason = "stream" } = {}) => {
|
|
3454
|
+
if (!streamingEnabled || hasDeliveredReply) return false;
|
|
3455
|
+
const pendingText = String(streamChunkBuffer ?? "");
|
|
3456
|
+
const candidate = markdownToWecomText(pendingText).trim();
|
|
3457
|
+
if (!candidate) return false;
|
|
3458
|
+
|
|
3459
|
+
const minChars = Math.max(20, Number(streamingPolicy.minChars || 120));
|
|
3460
|
+
const minIntervalMs = Math.max(200, Number(streamingPolicy.minIntervalMs || 1200));
|
|
3461
|
+
if (!force) {
|
|
3462
|
+
if (candidate.length < minChars) return false;
|
|
3463
|
+
if (Date.now() - streamChunkLastSentAt < minIntervalMs) return false;
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
streamChunkBuffer = "";
|
|
3467
|
+
await enqueueStreamingChunk(candidate, reason);
|
|
3468
|
+
return true;
|
|
3469
|
+
};
|
|
3470
|
+
const sendProgressNotice = async (text = processingNoticeText) => {
|
|
3471
|
+
if (hasDeliveredReply || hasDeliveredPartialReply || hasSentProgressNotice) return;
|
|
3472
|
+
hasSentProgressNotice = true;
|
|
3473
|
+
await sendWecomText({
|
|
3474
|
+
corpId,
|
|
3475
|
+
corpSecret,
|
|
3476
|
+
agentId,
|
|
3477
|
+
toUser: fromUser,
|
|
3478
|
+
text,
|
|
3479
|
+
logger: api.logger,
|
|
3480
|
+
proxyUrl,
|
|
3481
|
+
});
|
|
3482
|
+
};
|
|
3483
|
+
const sendFailureFallback = async (reason) => {
|
|
3484
|
+
if (hasDeliveredReply) return;
|
|
3485
|
+
hasDeliveredReply = true;
|
|
3486
|
+
const reasonText = String(reason ?? "unknown").slice(0, 160);
|
|
3487
|
+
await sendWecomText({
|
|
3488
|
+
corpId,
|
|
3489
|
+
corpSecret,
|
|
3490
|
+
agentId,
|
|
3491
|
+
toUser: fromUser,
|
|
3492
|
+
text: `抱歉,当前模型请求超时或网络不稳定,请稍后重试。\n故障信息: ${reasonText}`,
|
|
3493
|
+
logger: api.logger,
|
|
3494
|
+
proxyUrl,
|
|
3495
|
+
});
|
|
3496
|
+
};
|
|
3497
|
+
const startLateReplyWatcher = async (reason = "pending-final") => {
|
|
3498
|
+
if (hasDeliveredReply || hasDeliveredPartialReply || lateReplyWatcherPromise) return;
|
|
3499
|
+
|
|
3500
|
+
const watchStartedAt = Date.now();
|
|
3501
|
+
const watchId = `${sessionId}:${msgId || watchStartedAt}:${Math.random().toString(36).slice(2, 8)}`;
|
|
3502
|
+
ACTIVE_LATE_REPLY_WATCHERS.set(watchId, {
|
|
3503
|
+
sessionId,
|
|
3504
|
+
sessionKey: sessionId,
|
|
3505
|
+
accountId: config.accountId || "default",
|
|
3506
|
+
startedAt: watchStartedAt,
|
|
3507
|
+
reason,
|
|
3508
|
+
});
|
|
3509
|
+
|
|
3510
|
+
lateReplyWatcherPromise = (async () => {
|
|
3511
|
+
try {
|
|
3512
|
+
const transcriptPath = await resolveSessionTranscriptFilePath({
|
|
3513
|
+
storePath,
|
|
3514
|
+
sessionKey: sessionId,
|
|
3515
|
+
sessionId: ctxPayload.SessionId || sessionId,
|
|
3516
|
+
logger: api.logger,
|
|
3517
|
+
});
|
|
3518
|
+
let offset = 0;
|
|
3519
|
+
let remainder = "";
|
|
3520
|
+
try {
|
|
3521
|
+
const fileStat = await stat(transcriptPath);
|
|
3522
|
+
offset = Number(fileStat.size ?? 0);
|
|
3523
|
+
} catch {
|
|
3524
|
+
offset = 0;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
const deadline = watchStartedAt + lateReplyWatchMs;
|
|
3528
|
+
api.logger.info?.(
|
|
3529
|
+
`wecom: late reply watcher started session=${sessionId} reason=${reason} timeoutMs=${lateReplyWatchMs}`,
|
|
3530
|
+
);
|
|
3531
|
+
|
|
3532
|
+
while (Date.now() < deadline) {
|
|
3533
|
+
if (hasDeliveredReply) return;
|
|
3534
|
+
await sleep(lateReplyPollMs);
|
|
3535
|
+
if (hasDeliveredReply) return;
|
|
3536
|
+
|
|
3537
|
+
const { nextOffset, chunk } = await readTranscriptAppendedChunk(transcriptPath, offset);
|
|
3538
|
+
offset = nextOffset;
|
|
3539
|
+
if (!chunk) continue;
|
|
3540
|
+
|
|
3541
|
+
const combined = remainder + chunk;
|
|
3542
|
+
const lines = combined.split("\n");
|
|
3543
|
+
remainder = lines.pop() ?? "";
|
|
3544
|
+
|
|
3545
|
+
for (const line of lines) {
|
|
3546
|
+
const parsed = parseLateAssistantReplyFromTranscriptLine(line, watchStartedAt);
|
|
3547
|
+
if (!parsed) continue;
|
|
3548
|
+
if (hasTranscriptReplyBeenDelivered(sessionId, parsed.transcriptMessageId)) continue;
|
|
3549
|
+
if (hasDeliveredReply) return;
|
|
3550
|
+
|
|
3551
|
+
const formattedReply = markdownToWecomText(parsed.text);
|
|
3552
|
+
if (!formattedReply) continue;
|
|
3553
|
+
|
|
3554
|
+
await sendWecomText({
|
|
3555
|
+
corpId,
|
|
3556
|
+
corpSecret,
|
|
3557
|
+
agentId,
|
|
3558
|
+
toUser: fromUser,
|
|
3559
|
+
text: formattedReply,
|
|
3560
|
+
logger: api.logger,
|
|
3561
|
+
proxyUrl,
|
|
3562
|
+
});
|
|
3563
|
+
markTranscriptReplyDelivered(sessionId, parsed.transcriptMessageId);
|
|
3564
|
+
hasDeliveredReply = true;
|
|
3565
|
+
api.logger.info?.(
|
|
3566
|
+
`wecom: delivered async late reply session=${sessionId} transcriptMessageId=${parsed.transcriptMessageId}`,
|
|
3567
|
+
);
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
if (!hasDeliveredReply) {
|
|
3573
|
+
api.logger.warn?.(
|
|
3574
|
+
`wecom: late reply watcher timed out session=${sessionId} timeoutMs=${lateReplyWatchMs}`,
|
|
3575
|
+
);
|
|
3576
|
+
await sendFailureFallback(`late reply watcher timed out after ${lateReplyWatchMs}ms`);
|
|
3577
|
+
}
|
|
3578
|
+
} catch (err) {
|
|
3579
|
+
api.logger.warn?.(`wecom: late reply watcher failed: ${String(err?.message || err)}`);
|
|
3580
|
+
if (!hasDeliveredReply) {
|
|
3581
|
+
await sendFailureFallback(err);
|
|
3582
|
+
}
|
|
3583
|
+
} finally {
|
|
3584
|
+
ACTIVE_LATE_REPLY_WATCHERS.delete(watchId);
|
|
3585
|
+
lateReplyWatcherPromise = null;
|
|
3586
|
+
}
|
|
3587
|
+
})();
|
|
3588
|
+
};
|
|
3589
|
+
|
|
3590
|
+
try {
|
|
3591
|
+
if (progressNoticeDelayMs > 0) {
|
|
3592
|
+
progressNoticeTimer = setTimeout(() => {
|
|
3593
|
+
sendProgressNotice().catch((noticeErr) => {
|
|
3594
|
+
api.logger.warn?.(`wecom: failed to send progress notice: ${String(noticeErr)}`);
|
|
3595
|
+
});
|
|
3596
|
+
}, progressNoticeDelayMs);
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
let dispatchResult = null;
|
|
3600
|
+
api.logger.info?.(`wecom: waiting for agent reply (timeout=${replyTimeoutMs}ms)`);
|
|
3601
|
+
dispatchResult = await withTimeout(
|
|
3602
|
+
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
3603
|
+
ctx: ctxPayload,
|
|
3604
|
+
cfg,
|
|
3605
|
+
dispatcherOptions: {
|
|
3606
|
+
deliver: async (payload, info) => {
|
|
3607
|
+
if (suppressLateDispatcherDeliveries) {
|
|
3608
|
+
api.logger.info?.("wecom: suppressed late dispatcher delivery after timeout handoff");
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
if (hasDeliveredReply) {
|
|
3612
|
+
api.logger.info?.("wecom: ignoring late reply because a reply was already delivered");
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
if (info.kind === "block") {
|
|
3616
|
+
if (payload.text) {
|
|
3617
|
+
if (blockTextFallback) blockTextFallback += "\n";
|
|
3618
|
+
blockTextFallback += payload.text;
|
|
3619
|
+
if (streamingEnabled) {
|
|
3620
|
+
streamChunkBuffer += payload.text;
|
|
3621
|
+
await flushStreamingBuffer({ force: false, reason: "block" });
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
if (info.kind !== "final") return;
|
|
3627
|
+
// 发送回复到企业微信
|
|
3628
|
+
if (payload.text) {
|
|
3629
|
+
if (isAgentFailureText(payload.text)) {
|
|
3630
|
+
api.logger.warn?.(`wecom: upstream returned failure-like payload: ${payload.text}`);
|
|
3631
|
+
await sendFailureFallback(payload.text);
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
api.logger.info?.(`wecom: delivering ${info.kind} reply, length=${payload.text.length}`);
|
|
3636
|
+
if (streamingEnabled) {
|
|
3637
|
+
await flushStreamingBuffer({ force: true, reason: "final" });
|
|
3638
|
+
await streamChunkSendChain;
|
|
3639
|
+
if (streamChunkSentCount > 0) {
|
|
3640
|
+
const finalText = markdownToWecomText(payload.text).trim();
|
|
3641
|
+
const streamedText = markdownToWecomText(blockTextFallback).trim();
|
|
3642
|
+
const tailText =
|
|
3643
|
+
finalText && streamedText && finalText.startsWith(streamedText)
|
|
3644
|
+
? finalText.slice(streamedText.length).trim()
|
|
3645
|
+
: "";
|
|
3646
|
+
if (tailText) {
|
|
3647
|
+
await sendWecomText({
|
|
3648
|
+
corpId,
|
|
3649
|
+
corpSecret,
|
|
3650
|
+
agentId,
|
|
3651
|
+
toUser: fromUser,
|
|
3652
|
+
text: tailText,
|
|
3653
|
+
logger: api.logger,
|
|
3654
|
+
proxyUrl,
|
|
3655
|
+
});
|
|
3656
|
+
}
|
|
3657
|
+
hasDeliveredReply = true;
|
|
3658
|
+
api.logger.info?.(
|
|
3659
|
+
`wecom: streaming reply completed for ${fromUser}, chunks=${streamChunkSentCount}${tailText ? " +tail" : ""}`,
|
|
3660
|
+
);
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// 应用 Markdown 转换
|
|
3666
|
+
const formattedReply = markdownToWecomText(payload.text);
|
|
3667
|
+
await sendWecomText({
|
|
3668
|
+
corpId,
|
|
3669
|
+
corpSecret,
|
|
3670
|
+
agentId,
|
|
3671
|
+
toUser: fromUser,
|
|
3672
|
+
text: formattedReply,
|
|
3673
|
+
logger: api.logger,
|
|
3674
|
+
proxyUrl,
|
|
3675
|
+
});
|
|
3676
|
+
hasDeliveredReply = true;
|
|
3677
|
+
api.logger.info?.(`wecom: sent AI reply to ${fromUser}: ${formattedReply.slice(0, 50)}...`);
|
|
3678
|
+
} else if (payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0) {
|
|
3679
|
+
// 当前插件只稳定支持文本回包;若上游仅返回媒体,先给用户明确提示避免无响应。
|
|
3680
|
+
await sendWecomText({
|
|
3681
|
+
corpId,
|
|
3682
|
+
corpSecret,
|
|
3683
|
+
agentId,
|
|
3684
|
+
toUser: fromUser,
|
|
3685
|
+
text: "已收到模型返回的媒体结果,但当前版本暂不支持直接回传该媒体,请稍后重试文本请求。",
|
|
3686
|
+
logger: api.logger,
|
|
3687
|
+
proxyUrl,
|
|
3688
|
+
});
|
|
3689
|
+
hasDeliveredReply = true;
|
|
3690
|
+
}
|
|
3691
|
+
},
|
|
3692
|
+
onError: async (err, info) => {
|
|
3693
|
+
if (suppressLateDispatcherDeliveries) return;
|
|
3694
|
+
api.logger.error?.(`wecom: ${info.kind} reply failed: ${String(err)}`);
|
|
3695
|
+
try {
|
|
3696
|
+
await sendFailureFallback(err);
|
|
3697
|
+
} catch (fallbackErr) {
|
|
3698
|
+
api.logger.error?.(`wecom: failed to send fallback reply: ${fallbackErr.message}`);
|
|
3699
|
+
}
|
|
3700
|
+
},
|
|
3701
|
+
},
|
|
3702
|
+
replyOptions: {
|
|
3703
|
+
// 企业微信不支持编辑消息;开启流式时会以“多条文本消息”模拟增量输出。
|
|
3704
|
+
disableBlockStreaming: !streamingEnabled,
|
|
3705
|
+
},
|
|
3706
|
+
}),
|
|
3707
|
+
replyTimeoutMs,
|
|
3708
|
+
`dispatch timed out after ${replyTimeoutMs}ms`,
|
|
3709
|
+
);
|
|
3710
|
+
|
|
3711
|
+
if (streamingEnabled) {
|
|
3712
|
+
await flushStreamingBuffer({ force: true, reason: "post-dispatch" });
|
|
3713
|
+
await streamChunkSendChain;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
if (!hasDeliveredReply && !hasDeliveredPartialReply) {
|
|
3717
|
+
const blockText = String(blockTextFallback || "").trim();
|
|
3718
|
+
if (blockText) {
|
|
3719
|
+
await sendWecomText({
|
|
3720
|
+
corpId,
|
|
3721
|
+
corpSecret,
|
|
3722
|
+
agentId,
|
|
3723
|
+
toUser: fromUser,
|
|
3724
|
+
text: markdownToWecomText(blockText),
|
|
3725
|
+
logger: api.logger,
|
|
3726
|
+
proxyUrl,
|
|
3727
|
+
});
|
|
3728
|
+
hasDeliveredReply = true;
|
|
3729
|
+
api.logger.info?.("wecom: delivered accumulated block reply as final fallback");
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
if (!hasDeliveredReply && !hasDeliveredPartialReply) {
|
|
3734
|
+
const counts = dispatchResult?.counts ?? {};
|
|
3735
|
+
const queuedFinal = dispatchResult?.queuedFinal === true;
|
|
3736
|
+
const deliveredCount = Number(counts.final ?? 0) + Number(counts.block ?? 0) + Number(counts.tool ?? 0);
|
|
3737
|
+
if (!queuedFinal && deliveredCount === 0) {
|
|
3738
|
+
// 常见于同一会话已有活跃 run:当前消息被排队,暂无可立即发送的最终回复
|
|
3739
|
+
api.logger.warn?.("wecom: no immediate deliverable reply (likely queued behind active run)");
|
|
3740
|
+
await sendProgressNotice(queuedNoticeText);
|
|
3741
|
+
await startLateReplyWatcher("queued-no-final");
|
|
3742
|
+
} else {
|
|
3743
|
+
// 进入这里说明 dispatcher 有输出或已排队,但当前回调还没有拿到可立即下发的 final。
|
|
3744
|
+
// 发送处理中提示,避免用户感知为“无响应”。
|
|
3745
|
+
api.logger.warn?.(
|
|
3746
|
+
"wecom: dispatch finished without direct final delivery; sending processing notice",
|
|
3747
|
+
);
|
|
3748
|
+
await sendProgressNotice(processingNoticeText);
|
|
3749
|
+
await startLateReplyWatcher("dispatch-finished-without-final");
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
} catch (dispatchErr) {
|
|
3753
|
+
api.logger.warn?.(`wecom: dispatch failed: ${String(dispatchErr)}`);
|
|
3754
|
+
if (isDispatchTimeoutError(dispatchErr)) {
|
|
3755
|
+
suppressLateDispatcherDeliveries = true;
|
|
3756
|
+
await sendProgressNotice(queuedNoticeText);
|
|
3757
|
+
await startLateReplyWatcher("dispatch-timeout");
|
|
3758
|
+
} else {
|
|
3759
|
+
await sendFailureFallback(dispatchErr);
|
|
3760
|
+
}
|
|
3761
|
+
} finally {
|
|
3762
|
+
if (progressNoticeTimer) clearTimeout(progressNoticeTimer);
|
|
3763
|
+
for (const filePath of tempPathsToCleanup) {
|
|
3764
|
+
scheduleTempFileCleanup(filePath, api.logger);
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
} catch (err) {
|
|
3769
|
+
api.logger.error?.(`wecom: failed to process message: ${err.message}`);
|
|
3770
|
+
api.logger.error?.(`wecom: stack trace: ${err.stack}`);
|
|
3771
|
+
|
|
3772
|
+
// 发送错误提示给用户
|
|
3773
|
+
try {
|
|
3774
|
+
await sendWecomText({
|
|
3775
|
+
corpId,
|
|
3776
|
+
corpSecret,
|
|
3777
|
+
agentId,
|
|
3778
|
+
toUser: fromUser,
|
|
3779
|
+
text: `抱歉,处理您的消息时出现错误,请稍后重试。\n错误: ${err.message?.slice(0, 100) || "未知错误"}`,
|
|
3780
|
+
logger: api.logger,
|
|
3781
|
+
proxyUrl,
|
|
3782
|
+
});
|
|
3783
|
+
} catch (sendErr) {
|
|
3784
|
+
api.logger.error?.(`wecom: failed to send error message: ${sendErr.message}`);
|
|
3785
|
+
api.logger.error?.(`wecom: send error stack: ${sendErr.stack}`);
|
|
3786
|
+
api.logger.error?.(`wecom: original error was: ${err.message}`);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
export const __internal = {
|
|
3792
|
+
buildWecomSessionId,
|
|
3793
|
+
buildInboundDedupeKey,
|
|
3794
|
+
markInboundMessageSeen,
|
|
3795
|
+
resetInboundMessageDedupeForTests,
|
|
3796
|
+
splitWecomText,
|
|
3797
|
+
getByteLength,
|
|
3798
|
+
computeMsgSignature,
|
|
3799
|
+
pickAccountBySignature,
|
|
3800
|
+
};
|