@yanhaidao/wecom 2.2.7 → 2.3.2
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/.github/workflows/release.yml +56 -0
- package/CLAUDE.md +1 -1
- package/GOVERNANCE.md +26 -0
- package/LICENSE +7 -0
- package/README.md +275 -91
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +70 -0
- package/compat-single-account.md +118 -0
- package/package.json +10 -2
- package/src/accounts.ts +17 -55
- package/src/agent/api-client.ts +84 -37
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.event-filter.test.ts +50 -0
- package/src/agent/handler.ts +147 -145
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +234 -0
- package/src/channel.ts +90 -140
- package/src/config/accounts.resolve.test.ts +38 -0
- package/src/config/accounts.ts +257 -22
- package/src/config/index.ts +6 -0
- package/src/config/network.ts +9 -5
- package/src/config/routing.test.ts +88 -0
- package/src/config/routing.ts +26 -0
- package/src/config/schema.ts +35 -4
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +13 -13
- package/src/gateway-monitor.ts +200 -0
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor/state.queue.test.ts +1 -1
- package/src/monitor/state.ts +1 -1
- package/src/monitor/types.ts +1 -1
- package/src/monitor.active.test.ts +13 -7
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.ts +948 -128
- package/src/monitor.webhook.test.ts +288 -3
- package/src/outbound.test.ts +130 -0
- package/src/outbound.ts +44 -9
- package/src/shared/command-auth.ts +4 -2
- package/src/shared/xml-parser.test.ts +21 -1
- package/src/shared/xml-parser.ts +18 -0
- package/src/types/account.ts +43 -14
- package/src/types/config.ts +37 -2
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
- package/GEMINI.md +0 -76
- package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +0 -360
package/src/monitor.ts
CHANGED
|
@@ -7,16 +7,17 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
|
7
7
|
|
|
8
8
|
import type { ResolvedAgentAccount } from "./types/index.js";
|
|
9
9
|
import type { ResolvedBotAccount } from "./types/index.js";
|
|
10
|
-
import type { WecomInboundMessage, WecomInboundQuote } from "./types.js";
|
|
10
|
+
import type { WecomBotInboundMessage as WecomInboundMessage, WecomInboundQuote } from "./types/index.js";
|
|
11
11
|
import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
|
|
12
|
+
import { extractEncryptFromXml } from "./crypto/xml.js";
|
|
12
13
|
import { getWecomRuntime } from "./runtime.js";
|
|
13
|
-
import {
|
|
14
|
-
import { WEBHOOK_PATHS } from "./types/constants.js";
|
|
14
|
+
import { decryptWecomMediaWithMeta } from "./media.js";
|
|
15
|
+
import { WEBHOOK_PATHS, LIMITS as WECOM_LIMITS } from "./types/constants.js";
|
|
15
16
|
import { handleAgentWebhook } from "./agent/index.js";
|
|
16
|
-
import {
|
|
17
|
+
import { resolveWecomAccount, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "./config/index.js";
|
|
17
18
|
import { wecomFetch } from "./http.js";
|
|
18
19
|
import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
|
|
19
|
-
import
|
|
20
|
+
import { extractAgentId, parseXml } from "./shared/xml-parser.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* **核心监控模块 (Monitor Loop)**
|
|
@@ -45,11 +46,10 @@ type AgentWebhookTarget = {
|
|
|
45
46
|
agent: ResolvedAgentAccount;
|
|
46
47
|
config: OpenClawConfig;
|
|
47
48
|
runtime: WecomRuntimeEnv;
|
|
49
|
+
path: string;
|
|
48
50
|
// ...
|
|
49
51
|
};
|
|
50
|
-
const agentTargets = new Map<string, AgentWebhookTarget>();
|
|
51
|
-
|
|
52
|
-
const pendingInbounds = new Map<string, PendingInbound>();
|
|
52
|
+
const agentTargets = new Map<string, AgentWebhookTarget[]>();
|
|
53
53
|
|
|
54
54
|
const STREAM_MAX_BYTES = LIMITS.STREAM_MAX_BYTES;
|
|
55
55
|
const STREAM_MAX_DM_BYTES = 200_000;
|
|
@@ -214,6 +214,101 @@ function resolveSignatureParam(params: URLSearchParams): string {
|
|
|
214
214
|
);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
type RouteFailureReason =
|
|
218
|
+
| "wecom_account_not_found"
|
|
219
|
+
| "wecom_account_conflict"
|
|
220
|
+
| "wecom_identity_mismatch"
|
|
221
|
+
| "wecom_matrix_path_required";
|
|
222
|
+
|
|
223
|
+
function isLegacyWecomPath(path: string): boolean {
|
|
224
|
+
return path === WEBHOOK_PATHS.BOT || path === WEBHOOK_PATHS.BOT_ALT || path === WEBHOOK_PATHS.AGENT;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hasMatrixExplicitRoutesRegistered(): boolean {
|
|
228
|
+
for (const key of webhookTargets.keys()) {
|
|
229
|
+
if (key.startsWith(`${WEBHOOK_PATHS.BOT_ALT}/`)) return true;
|
|
230
|
+
}
|
|
231
|
+
for (const key of agentTargets.keys()) {
|
|
232
|
+
if (key.startsWith(`${WEBHOOK_PATHS.AGENT}/`)) return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function maskAccountId(accountId: string): string {
|
|
238
|
+
const normalized = accountId.trim();
|
|
239
|
+
if (!normalized) return "***";
|
|
240
|
+
if (normalized.length <= 4) return `${normalized[0] ?? "*"}***`;
|
|
241
|
+
return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function logRouteFailure(params: {
|
|
245
|
+
reqId: string;
|
|
246
|
+
path: string;
|
|
247
|
+
method: string;
|
|
248
|
+
reason: RouteFailureReason;
|
|
249
|
+
candidateAccountIds: string[];
|
|
250
|
+
}): void {
|
|
251
|
+
const payload = {
|
|
252
|
+
reqId: params.reqId,
|
|
253
|
+
path: params.path,
|
|
254
|
+
method: params.method,
|
|
255
|
+
reason: params.reason,
|
|
256
|
+
candidateAccountIds: params.candidateAccountIds.map(maskAccountId),
|
|
257
|
+
};
|
|
258
|
+
console.error(`[wecom] route-error ${JSON.stringify(payload)}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function writeRouteFailure(
|
|
262
|
+
res: ServerResponse,
|
|
263
|
+
reason: RouteFailureReason,
|
|
264
|
+
message: string,
|
|
265
|
+
): void {
|
|
266
|
+
res.statusCode = 401;
|
|
267
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
268
|
+
res.end(JSON.stringify({ error: reason, message }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function readTextBody(req: IncomingMessage, maxBytes: number): Promise<{ ok: true; value: string } | { ok: false; error: string }> {
|
|
272
|
+
const chunks: Buffer[] = [];
|
|
273
|
+
let total = 0;
|
|
274
|
+
return await new Promise((resolve) => {
|
|
275
|
+
req.on("data", (chunk: Buffer) => {
|
|
276
|
+
total += chunk.length;
|
|
277
|
+
if (total > maxBytes) {
|
|
278
|
+
resolve({ ok: false as const, error: "payload too large" });
|
|
279
|
+
req.destroy();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
chunks.push(chunk);
|
|
283
|
+
});
|
|
284
|
+
req.on("end", () => {
|
|
285
|
+
resolve({ ok: true as const, value: Buffer.concat(chunks).toString("utf8") });
|
|
286
|
+
});
|
|
287
|
+
req.on("error", (err) => {
|
|
288
|
+
resolve({ ok: false as const, error: err instanceof Error ? err.message : String(err) });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizeAgentIdValue(value: unknown): number | undefined {
|
|
294
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
295
|
+
const raw = String(value ?? "").trim();
|
|
296
|
+
if (!raw) return undefined;
|
|
297
|
+
const parsed = Number(raw);
|
|
298
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function resolveBotIdentitySet(target: WecomWebhookTarget): Set<string> {
|
|
302
|
+
const ids = new Set<string>();
|
|
303
|
+
const single = target.account.config.aibotid?.trim();
|
|
304
|
+
if (single) ids.add(single);
|
|
305
|
+
for (const botId of target.account.config.botIds ?? []) {
|
|
306
|
+
const normalized = String(botId ?? "").trim();
|
|
307
|
+
if (normalized) ids.add(normalized);
|
|
308
|
+
}
|
|
309
|
+
return ids;
|
|
310
|
+
}
|
|
311
|
+
|
|
217
312
|
function buildStreamPlaceholderReply(params: {
|
|
218
313
|
streamId: string;
|
|
219
314
|
placeholderContent?: string;
|
|
@@ -284,8 +379,8 @@ function computeTaskKey(target: WecomWebhookTarget, msg: WecomInboundMessage): s
|
|
|
284
379
|
return `bot:${target.account.accountId}:${aibotid}:${msgid}`;
|
|
285
380
|
}
|
|
286
381
|
|
|
287
|
-
function resolveAgentAccountOrUndefined(cfg: OpenClawConfig): ResolvedAgentAccount | undefined {
|
|
288
|
-
const agent =
|
|
382
|
+
function resolveAgentAccountOrUndefined(cfg: OpenClawConfig, accountId: string): ResolvedAgentAccount | undefined {
|
|
383
|
+
const agent = resolveWecomAccount({ cfg, accountId }).agent;
|
|
289
384
|
return agent?.configured ? agent : undefined;
|
|
290
385
|
}
|
|
291
386
|
|
|
@@ -342,6 +437,27 @@ async function sendBotFallbackPromptNow(params: { streamId: string; text: string
|
|
|
342
437
|
});
|
|
343
438
|
}
|
|
344
439
|
|
|
440
|
+
async function pushFinalStreamReplyNow(streamId: string): Promise<void> {
|
|
441
|
+
const state = streamStore.getStream(streamId);
|
|
442
|
+
const responseUrl = getActiveReplyUrl(streamId);
|
|
443
|
+
if (!state || !responseUrl) return;
|
|
444
|
+
const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
|
|
445
|
+
await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
|
|
446
|
+
const res = await wecomFetch(
|
|
447
|
+
responseUrl,
|
|
448
|
+
{
|
|
449
|
+
method: "POST",
|
|
450
|
+
headers: { "Content-Type": "application/json" },
|
|
451
|
+
body: JSON.stringify(finalReply),
|
|
452
|
+
},
|
|
453
|
+
{ proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
|
|
454
|
+
);
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
throw new Error(`final stream push failed: ${res.status}`);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
345
461
|
async function sendAgentDmText(params: {
|
|
346
462
|
agent: ResolvedAgentAccount;
|
|
347
463
|
userId: string;
|
|
@@ -405,10 +521,10 @@ function extractLocalImagePathsFromText(params: {
|
|
|
405
521
|
const mustAlsoAppearIn = params.mustAlsoAppearIn;
|
|
406
522
|
if (!text.trim()) return [];
|
|
407
523
|
|
|
408
|
-
// Conservative: only accept common
|
|
524
|
+
// Conservative: only accept common absolute paths for macOS/Linux hosts.
|
|
409
525
|
// Also require that the exact path appeared in the user's original message to prevent exfil.
|
|
410
526
|
const exts = "(png|jpg|jpeg|gif|webp|bmp)";
|
|
411
|
-
const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+?\.${exts})`, "gi");
|
|
527
|
+
const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+?\.${exts})`, "gi");
|
|
412
528
|
const found = new Set<string>();
|
|
413
529
|
let m: RegExpExecArray | null;
|
|
414
530
|
while ((m = re.exec(text))) {
|
|
@@ -423,9 +539,9 @@ function extractLocalImagePathsFromText(params: {
|
|
|
423
539
|
function extractLocalFilePathsFromText(text: string): string[] {
|
|
424
540
|
if (!text.trim()) return [];
|
|
425
541
|
|
|
426
|
-
// Conservative: only accept common
|
|
542
|
+
// Conservative: only accept common absolute paths for macOS/Linux hosts.
|
|
427
543
|
// This is primarily for “send local file” style requests (operator/debug usage).
|
|
428
|
-
const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+)`, "g");
|
|
544
|
+
const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+)`, "g");
|
|
429
545
|
const found = new Set<string>();
|
|
430
546
|
let m: RegExpExecArray | null;
|
|
431
547
|
while ((m = re.exec(text))) {
|
|
@@ -436,23 +552,287 @@ function extractLocalFilePathsFromText(text: string): string[] {
|
|
|
436
552
|
return Array.from(found);
|
|
437
553
|
}
|
|
438
554
|
|
|
555
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
556
|
+
png: "image/png",
|
|
557
|
+
jpg: "image/jpeg",
|
|
558
|
+
jpeg: "image/jpeg",
|
|
559
|
+
gif: "image/gif",
|
|
560
|
+
webp: "image/webp",
|
|
561
|
+
bmp: "image/bmp",
|
|
562
|
+
pdf: "application/pdf",
|
|
563
|
+
txt: "text/plain",
|
|
564
|
+
csv: "text/csv",
|
|
565
|
+
tsv: "text/tab-separated-values",
|
|
566
|
+
md: "text/markdown",
|
|
567
|
+
json: "application/json",
|
|
568
|
+
xml: "application/xml",
|
|
569
|
+
yaml: "application/yaml",
|
|
570
|
+
yml: "application/yaml",
|
|
571
|
+
zip: "application/zip",
|
|
572
|
+
rar: "application/vnd.rar",
|
|
573
|
+
"7z": "application/x-7z-compressed",
|
|
574
|
+
tar: "application/x-tar",
|
|
575
|
+
gz: "application/gzip",
|
|
576
|
+
tgz: "application/gzip",
|
|
577
|
+
doc: "application/msword",
|
|
578
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
579
|
+
xls: "application/vnd.ms-excel",
|
|
580
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
581
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
582
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
583
|
+
rtf: "application/rtf",
|
|
584
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
585
|
+
mp3: "audio/mpeg",
|
|
586
|
+
wav: "audio/wav",
|
|
587
|
+
ogg: "audio/ogg",
|
|
588
|
+
amr: "voice/amr",
|
|
589
|
+
m4a: "audio/mp4",
|
|
590
|
+
mp4: "video/mp4",
|
|
591
|
+
mov: "video/quicktime",
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
595
|
+
...Object.fromEntries(Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext])),
|
|
596
|
+
"application/octet-stream": "bin",
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const GENERIC_CONTENT_TYPES = new Set([
|
|
600
|
+
"application/octet-stream",
|
|
601
|
+
"binary/octet-stream",
|
|
602
|
+
"application/download",
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
function normalizeContentType(raw?: string | null): string | undefined {
|
|
606
|
+
const normalized = String(raw ?? "").trim().split(";")[0]?.trim().toLowerCase();
|
|
607
|
+
return normalized || undefined;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isGenericContentType(raw?: string | null): boolean {
|
|
611
|
+
const normalized = normalizeContentType(raw);
|
|
612
|
+
if (!normalized) return true;
|
|
613
|
+
return GENERIC_CONTENT_TYPES.has(normalized);
|
|
614
|
+
}
|
|
615
|
+
|
|
439
616
|
function guessContentTypeFromPath(filePath: string): string | undefined {
|
|
440
617
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
441
618
|
if (!ext) return undefined;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
619
|
+
return MIME_BY_EXT[ext];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function guessExtensionFromContentType(contentType?: string): string | undefined {
|
|
623
|
+
const normalized = normalizeContentType(contentType);
|
|
624
|
+
if (!normalized) return undefined;
|
|
625
|
+
if (normalized === "image/jpeg") return "jpg";
|
|
626
|
+
return EXT_BY_MIME[normalized];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function extractFileNameFromUrl(rawUrl?: string): string | undefined {
|
|
630
|
+
const s = String(rawUrl ?? "").trim();
|
|
631
|
+
if (!s) return undefined;
|
|
632
|
+
try {
|
|
633
|
+
const u = new URL(s);
|
|
634
|
+
const name = decodeURIComponent(u.pathname.split("/").pop() ?? "").trim();
|
|
635
|
+
return name || undefined;
|
|
636
|
+
} catch {
|
|
637
|
+
return undefined;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function sanitizeInboundFilename(raw?: string): string | undefined {
|
|
642
|
+
const s = String(raw ?? "").trim();
|
|
643
|
+
if (!s) return undefined;
|
|
644
|
+
const base = s.split(/[\\/]/).pop()?.trim() ?? "";
|
|
645
|
+
if (!base) return undefined;
|
|
646
|
+
const sanitized = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
|
|
647
|
+
return sanitized || undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function hasLikelyExtension(name?: string): boolean {
|
|
651
|
+
if (!name) return false;
|
|
652
|
+
return /\.[a-z0-9]{1,16}$/i.test(name);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function detectMimeFromBuffer(buffer: Buffer): string | undefined {
|
|
656
|
+
if (!buffer || buffer.length < 4) return undefined;
|
|
657
|
+
|
|
658
|
+
// PNG
|
|
659
|
+
if (
|
|
660
|
+
buffer.length >= 8 &&
|
|
661
|
+
buffer[0] === 0x89 &&
|
|
662
|
+
buffer[1] === 0x50 &&
|
|
663
|
+
buffer[2] === 0x4e &&
|
|
664
|
+
buffer[3] === 0x47 &&
|
|
665
|
+
buffer[4] === 0x0d &&
|
|
666
|
+
buffer[5] === 0x0a &&
|
|
667
|
+
buffer[6] === 0x1a &&
|
|
668
|
+
buffer[7] === 0x0a
|
|
669
|
+
) {
|
|
670
|
+
return "image/png";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// JPEG
|
|
674
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
675
|
+
return "image/jpeg";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// GIF
|
|
679
|
+
if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
|
|
680
|
+
return "image/gif";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// WEBP
|
|
684
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
685
|
+
return "image/webp";
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// BMP
|
|
689
|
+
if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
|
|
690
|
+
return "image/bmp";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// PDF
|
|
694
|
+
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
|
|
695
|
+
return "application/pdf";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// OGG
|
|
699
|
+
if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
|
|
700
|
+
return "audio/ogg";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// WAV
|
|
704
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
|
|
705
|
+
return "audio/wav";
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// MP3
|
|
709
|
+
if (buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)) {
|
|
710
|
+
return "audio/mpeg";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// MP4/MOV family
|
|
714
|
+
if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
715
|
+
return "video/mp4";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Legacy Office (OLE Compound File)
|
|
719
|
+
if (
|
|
720
|
+
buffer.length >= 8 &&
|
|
721
|
+
buffer[0] === 0xd0 &&
|
|
722
|
+
buffer[1] === 0xcf &&
|
|
723
|
+
buffer[2] === 0x11 &&
|
|
724
|
+
buffer[3] === 0xe0 &&
|
|
725
|
+
buffer[4] === 0xa1 &&
|
|
726
|
+
buffer[5] === 0xb1 &&
|
|
727
|
+
buffer[6] === 0x1a &&
|
|
728
|
+
buffer[7] === 0xe1
|
|
729
|
+
) {
|
|
730
|
+
return "application/msword";
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ZIP / OOXML
|
|
734
|
+
const zipMagic =
|
|
735
|
+
(buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) ||
|
|
736
|
+
(buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x05 && buffer[3] === 0x06) ||
|
|
737
|
+
(buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x07 && buffer[3] === 0x08);
|
|
738
|
+
if (zipMagic) {
|
|
739
|
+
const probe = buffer.subarray(0, Math.min(buffer.length, 512 * 1024));
|
|
740
|
+
if (probe.includes(Buffer.from("word/"))) {
|
|
741
|
+
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
742
|
+
}
|
|
743
|
+
if (probe.includes(Buffer.from("xl/"))) {
|
|
744
|
+
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
745
|
+
}
|
|
746
|
+
if (probe.includes(Buffer.from("ppt/"))) {
|
|
747
|
+
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
748
|
+
}
|
|
749
|
+
return "application/zip";
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Plain text heuristic
|
|
753
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
|
754
|
+
let printable = 0;
|
|
755
|
+
for (const b of sample) {
|
|
756
|
+
if (b === 0x00) return undefined;
|
|
757
|
+
if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
|
|
758
|
+
printable += 1;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (sample.length > 0 && printable / sample.length > 0.95) {
|
|
762
|
+
return "text/plain";
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function resolveInlineFileName(input: unknown): string | undefined {
|
|
769
|
+
const raw = String(input ?? "").trim();
|
|
770
|
+
return sanitizeInboundFilename(raw);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function pickBotFileName(msg: WecomInboundMessage, item?: Record<string, any>): string | undefined {
|
|
774
|
+
const fromItem = item
|
|
775
|
+
? resolveInlineFileName(
|
|
776
|
+
item?.filename ??
|
|
777
|
+
item?.file_name ??
|
|
778
|
+
item?.fileName ??
|
|
779
|
+
item?.name ??
|
|
780
|
+
item?.title,
|
|
781
|
+
)
|
|
782
|
+
: undefined;
|
|
783
|
+
if (fromItem) return fromItem;
|
|
784
|
+
|
|
785
|
+
const fromFile = resolveInlineFileName(
|
|
786
|
+
(msg as any)?.file?.filename ??
|
|
787
|
+
(msg as any)?.file?.file_name ??
|
|
788
|
+
(msg as any)?.file?.fileName ??
|
|
789
|
+
(msg as any)?.file?.name ??
|
|
790
|
+
(msg as any)?.file?.title ??
|
|
791
|
+
(msg as any)?.filename ??
|
|
792
|
+
(msg as any)?.fileName ??
|
|
793
|
+
(msg as any)?.FileName,
|
|
794
|
+
);
|
|
795
|
+
return fromFile;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function inferInboundMediaMeta(params: {
|
|
799
|
+
kind: "image" | "file";
|
|
800
|
+
buffer: Buffer;
|
|
801
|
+
sourceUrl?: string;
|
|
802
|
+
sourceContentType?: string;
|
|
803
|
+
sourceFilename?: string;
|
|
804
|
+
explicitFilename?: string;
|
|
805
|
+
}): { contentType: string; filename: string } {
|
|
806
|
+
const headerType = normalizeContentType(params.sourceContentType);
|
|
807
|
+
const magicType = detectMimeFromBuffer(params.buffer);
|
|
808
|
+
const rawUrlName = sanitizeInboundFilename(extractFileNameFromUrl(params.sourceUrl));
|
|
809
|
+
const guessedByUrl = hasLikelyExtension(rawUrlName) ? rawUrlName : undefined;
|
|
810
|
+
const explicitName = sanitizeInboundFilename(params.explicitFilename);
|
|
811
|
+
const sourceName = sanitizeInboundFilename(params.sourceFilename);
|
|
812
|
+
const chosenName = explicitName || sourceName || guessedByUrl;
|
|
813
|
+
const typeByName = chosenName ? guessContentTypeFromPath(chosenName) : undefined;
|
|
814
|
+
|
|
815
|
+
let contentType: string;
|
|
816
|
+
if (params.kind === "image") {
|
|
817
|
+
if (magicType?.startsWith("image/")) contentType = magicType;
|
|
818
|
+
else if (headerType?.startsWith("image/")) contentType = headerType;
|
|
819
|
+
else if (typeByName?.startsWith("image/")) contentType = typeByName;
|
|
820
|
+
else contentType = "image/jpeg";
|
|
821
|
+
} else {
|
|
822
|
+
contentType =
|
|
823
|
+
magicType ||
|
|
824
|
+
(!isGenericContentType(headerType) ? headerType! : undefined) ||
|
|
825
|
+
typeByName ||
|
|
826
|
+
"application/octet-stream";
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const hasExt = Boolean(chosenName && /\.[a-z0-9]{1,16}$/i.test(chosenName));
|
|
830
|
+
const ext = guessExtensionFromContentType(contentType) || (params.kind === "image" ? "jpg" : "bin");
|
|
831
|
+
const filename = chosenName
|
|
832
|
+
? (hasExt ? chosenName : `${chosenName}.${ext}`)
|
|
833
|
+
: `${params.kind}.${ext}`;
|
|
834
|
+
|
|
835
|
+
return { contentType, filename };
|
|
456
836
|
}
|
|
457
837
|
|
|
458
838
|
function looksLikeSendLocalFileIntent(rawBody: string): boolean {
|
|
@@ -501,6 +881,40 @@ function resolveWecomSenderUserId(msg: WecomInboundMessage): string | undefined
|
|
|
501
881
|
return legacy || undefined;
|
|
502
882
|
}
|
|
503
883
|
|
|
884
|
+
export type BotInboundProcessDecision = {
|
|
885
|
+
shouldProcess: boolean;
|
|
886
|
+
reason: string;
|
|
887
|
+
senderUserId?: string;
|
|
888
|
+
chatId?: string;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* 仅允许“真实用户消息”进入 Bot 会话:
|
|
893
|
+
* - 发送者缺失 -> 丢弃,避免落到 unknown 会话导致串会话
|
|
894
|
+
* - 发送者是 sys -> 丢弃,避免系统回调触发 AI 自动回复
|
|
895
|
+
* - 群消息缺失 chatid -> 丢弃,避免 group:unknown 串群
|
|
896
|
+
*/
|
|
897
|
+
export function shouldProcessBotInboundMessage(msg: WecomInboundMessage): BotInboundProcessDecision {
|
|
898
|
+
const senderUserId = resolveWecomSenderUserId(msg)?.trim();
|
|
899
|
+
if (!senderUserId) {
|
|
900
|
+
return { shouldProcess: false, reason: "missing_sender" };
|
|
901
|
+
}
|
|
902
|
+
if (senderUserId.toLowerCase() === "sys") {
|
|
903
|
+
return { shouldProcess: false, reason: "system_sender" };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const chatType = String(msg.chattype ?? "").trim().toLowerCase();
|
|
907
|
+
if (chatType === "group") {
|
|
908
|
+
const chatId = msg.chatid?.trim();
|
|
909
|
+
if (!chatId) {
|
|
910
|
+
return { shouldProcess: false, reason: "missing_chatid", senderUserId };
|
|
911
|
+
}
|
|
912
|
+
return { shouldProcess: true, reason: "user_message", senderUserId, chatId };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return { shouldProcess: true, reason: "user_message", senderUserId, chatId: senderUserId };
|
|
916
|
+
}
|
|
917
|
+
|
|
504
918
|
function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
505
919
|
const parsed = JSON.parse(raw) as unknown;
|
|
506
920
|
if (!parsed || typeof parsed !== "object") {
|
|
@@ -541,13 +955,21 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
541
955
|
const url = String((msg as any).image?.url ?? "").trim();
|
|
542
956
|
if (url && aesKey) {
|
|
543
957
|
try {
|
|
544
|
-
const
|
|
958
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
959
|
+
const inferred = inferInboundMediaMeta({
|
|
960
|
+
kind: "image",
|
|
961
|
+
buffer: decrypted.buffer,
|
|
962
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
963
|
+
sourceContentType: decrypted.sourceContentType,
|
|
964
|
+
sourceFilename: decrypted.sourceFilename,
|
|
965
|
+
explicitFilename: pickBotFileName(msg),
|
|
966
|
+
});
|
|
545
967
|
return {
|
|
546
968
|
body: "[image]",
|
|
547
969
|
media: {
|
|
548
|
-
buffer:
|
|
549
|
-
contentType:
|
|
550
|
-
filename:
|
|
970
|
+
buffer: decrypted.buffer,
|
|
971
|
+
contentType: inferred.contentType,
|
|
972
|
+
filename: inferred.filename,
|
|
551
973
|
}
|
|
552
974
|
};
|
|
553
975
|
} catch (err) {
|
|
@@ -555,7 +977,10 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
555
977
|
target.runtime.error?.(
|
|
556
978
|
`图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
557
979
|
);
|
|
558
|
-
|
|
980
|
+
const errorMessage = typeof err === 'object' && err
|
|
981
|
+
? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
|
|
982
|
+
: String(err);
|
|
983
|
+
return { body: `[image] (decryption failed: ${errorMessage})` };
|
|
559
984
|
}
|
|
560
985
|
}
|
|
561
986
|
}
|
|
@@ -564,20 +989,31 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
564
989
|
const url = String((msg as any).file?.url ?? "").trim();
|
|
565
990
|
if (url && aesKey) {
|
|
566
991
|
try {
|
|
567
|
-
const
|
|
992
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
993
|
+
const inferred = inferInboundMediaMeta({
|
|
994
|
+
kind: "file",
|
|
995
|
+
buffer: decrypted.buffer,
|
|
996
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
997
|
+
sourceContentType: decrypted.sourceContentType,
|
|
998
|
+
sourceFilename: decrypted.sourceFilename,
|
|
999
|
+
explicitFilename: pickBotFileName(msg),
|
|
1000
|
+
});
|
|
568
1001
|
return {
|
|
569
1002
|
body: "[file]",
|
|
570
1003
|
media: {
|
|
571
|
-
buffer:
|
|
572
|
-
contentType:
|
|
573
|
-
filename:
|
|
1004
|
+
buffer: decrypted.buffer,
|
|
1005
|
+
contentType: inferred.contentType,
|
|
1006
|
+
filename: inferred.filename,
|
|
574
1007
|
}
|
|
575
1008
|
};
|
|
576
1009
|
} catch (err) {
|
|
577
1010
|
target.runtime.error?.(
|
|
578
1011
|
`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
579
1012
|
);
|
|
580
|
-
|
|
1013
|
+
const errorMessage = typeof err === 'object' && err
|
|
1014
|
+
? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
|
|
1015
|
+
: String(err);
|
|
1016
|
+
return { body: `[file] (decryption failed: ${errorMessage})` };
|
|
581
1017
|
}
|
|
582
1018
|
}
|
|
583
1019
|
}
|
|
@@ -599,18 +1035,29 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
599
1035
|
const url = String(item[t]?.url ?? "").trim();
|
|
600
1036
|
if (url) {
|
|
601
1037
|
try {
|
|
602
|
-
const
|
|
1038
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
1039
|
+
const inferred = inferInboundMediaMeta({
|
|
1040
|
+
kind: t,
|
|
1041
|
+
buffer: decrypted.buffer,
|
|
1042
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
1043
|
+
sourceContentType: decrypted.sourceContentType,
|
|
1044
|
+
sourceFilename: decrypted.sourceFilename,
|
|
1045
|
+
explicitFilename: pickBotFileName(msg, item?.[t]),
|
|
1046
|
+
});
|
|
603
1047
|
foundMedia = {
|
|
604
|
-
buffer:
|
|
605
|
-
contentType:
|
|
606
|
-
filename:
|
|
1048
|
+
buffer: decrypted.buffer,
|
|
1049
|
+
contentType: inferred.contentType,
|
|
1050
|
+
filename: inferred.filename,
|
|
607
1051
|
};
|
|
608
1052
|
bodyParts.push(`[${t}]`);
|
|
609
1053
|
} catch (err) {
|
|
610
1054
|
target.runtime.error?.(
|
|
611
1055
|
`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
612
1056
|
);
|
|
613
|
-
|
|
1057
|
+
const errorMessage = typeof err === 'object' && err
|
|
1058
|
+
? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
|
|
1059
|
+
: String(err);
|
|
1060
|
+
bodyParts.push(`[${t}] (decryption failed: ${errorMessage})`);
|
|
614
1061
|
}
|
|
615
1062
|
} else {
|
|
616
1063
|
bodyParts.push(`[${t}]`);
|
|
@@ -836,11 +1283,65 @@ async function startAgentForStream(params: {
|
|
|
836
1283
|
streamStore.onStreamFinished(streamId);
|
|
837
1284
|
return;
|
|
838
1285
|
}
|
|
1286
|
+
|
|
1287
|
+
// 图片路径都读取失败时,切换到 Agent 私信兜底,并主动结束 Bot 流。
|
|
1288
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
1289
|
+
const agentOk = Boolean(agentCfg);
|
|
1290
|
+
const fallbackName = imagePaths.length === 1
|
|
1291
|
+
? (imagePaths[0]!.split("/").pop() || "image")
|
|
1292
|
+
: `${imagePaths.length} 张图片`;
|
|
1293
|
+
const prompt = buildFallbackPrompt({
|
|
1294
|
+
kind: "media",
|
|
1295
|
+
agentConfigured: agentOk,
|
|
1296
|
+
userId: userid,
|
|
1297
|
+
filename: fallbackName,
|
|
1298
|
+
chatType,
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1302
|
+
s.fallbackMode = "error";
|
|
1303
|
+
s.finished = true;
|
|
1304
|
+
s.content = prompt;
|
|
1305
|
+
s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
await sendBotFallbackPromptNow({ streamId, text: prompt });
|
|
1310
|
+
logVerbose(target, `local-path: 图片读取失败后已推送兜底提示`);
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
target.runtime.error?.(`local-path: 图片读取失败后的兜底提示推送失败: ${String(err)}`);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (agentCfg && userid && userid !== "unknown") {
|
|
1316
|
+
for (const p of imagePaths) {
|
|
1317
|
+
const guessedType = guessContentTypeFromPath(p);
|
|
1318
|
+
try {
|
|
1319
|
+
await sendAgentDmMedia({
|
|
1320
|
+
agent: agentCfg,
|
|
1321
|
+
userId: userid,
|
|
1322
|
+
mediaUrlOrPath: p,
|
|
1323
|
+
contentType: guessedType,
|
|
1324
|
+
filename: p.split("/").pop() || "image",
|
|
1325
|
+
});
|
|
1326
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1327
|
+
s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
|
|
1328
|
+
});
|
|
1329
|
+
logVerbose(
|
|
1330
|
+
target,
|
|
1331
|
+
`local-path: 图片已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
|
|
1332
|
+
);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
target.runtime.error?.(`local-path: 图片 Agent 私信兜底失败 path=${p}: ${String(err)}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
streamStore.onStreamFinished(streamId);
|
|
1339
|
+
return;
|
|
839
1340
|
}
|
|
840
1341
|
|
|
841
1342
|
// 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
|
|
842
1343
|
if (otherPaths.length > 0) {
|
|
843
|
-
const agentCfg = resolveAgentAccountOrUndefined(config);
|
|
1344
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
844
1345
|
const agentOk = Boolean(agentCfg);
|
|
845
1346
|
|
|
846
1347
|
const filename = otherPaths.length === 1 ? otherPaths[0]!.split("/").pop()! : `${otherPaths.length} 个文件`;
|
|
@@ -879,18 +1380,22 @@ async function startAgentForStream(params: {
|
|
|
879
1380
|
for (const p of otherPaths) {
|
|
880
1381
|
const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
|
|
881
1382
|
if (alreadySent) continue;
|
|
1383
|
+
const guessedType = guessContentTypeFromPath(p);
|
|
882
1384
|
try {
|
|
883
1385
|
await sendAgentDmMedia({
|
|
884
1386
|
agent: agentCfg,
|
|
885
1387
|
userId: userid,
|
|
886
1388
|
mediaUrlOrPath: p,
|
|
887
|
-
contentType:
|
|
1389
|
+
contentType: guessedType,
|
|
888
1390
|
filename: p.split("/").pop() || "file",
|
|
889
1391
|
});
|
|
890
1392
|
streamStore.updateStream(streamId, (s) => {
|
|
891
1393
|
s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
|
|
892
1394
|
});
|
|
893
|
-
logVerbose(
|
|
1395
|
+
logVerbose(
|
|
1396
|
+
target,
|
|
1397
|
+
`local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
|
|
1398
|
+
);
|
|
894
1399
|
} catch (err) {
|
|
895
1400
|
target.runtime.error?.(`local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
|
|
896
1401
|
}
|
|
@@ -925,23 +1430,45 @@ async function startAgentForStream(params: {
|
|
|
925
1430
|
cfg: config,
|
|
926
1431
|
channel: "wecom",
|
|
927
1432
|
accountId: account.accountId,
|
|
928
|
-
peer: { kind: chatType === "group" ? "group" : "
|
|
1433
|
+
peer: { kind: chatType === "group" ? "group" : "direct", id: chatId },
|
|
929
1434
|
});
|
|
930
1435
|
|
|
931
|
-
// ===== 动态 Agent 路由注入 =====
|
|
932
1436
|
const useDynamicAgent = shouldUseDynamicAgent({
|
|
933
1437
|
chatType: chatType === "group" ? "group" : "dm",
|
|
934
1438
|
senderId: userid,
|
|
935
1439
|
config,
|
|
936
1440
|
});
|
|
937
1441
|
|
|
1442
|
+
if (shouldRejectWecomDefaultRoute({ cfg: config, matchedBy: route.matchedBy, useDynamicAgent })) {
|
|
1443
|
+
const prompt =
|
|
1444
|
+
`当前账号(${account.accountId})未绑定 OpenClaw Agent,已拒绝回退到默认主智能体。` +
|
|
1445
|
+
`请在 bindings 中添加:{"agentId":"你的Agent","match":{"channel":"wecom","accountId":"${account.accountId}"}}`;
|
|
1446
|
+
target.runtime.error?.(
|
|
1447
|
+
`[wecom] routing guard: blocked default fallback accountId=${account.accountId} matchedBy=${route.matchedBy} streamId=${streamId}`,
|
|
1448
|
+
);
|
|
1449
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1450
|
+
s.finished = true;
|
|
1451
|
+
s.content = prompt;
|
|
1452
|
+
});
|
|
1453
|
+
try {
|
|
1454
|
+
await sendBotFallbackPromptNow({ streamId, text: prompt });
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
target.runtime.error?.(`routing guard prompt push failed streamId=${streamId}: ${String(err)}`);
|
|
1457
|
+
}
|
|
1458
|
+
streamStore.onStreamFinished(streamId);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ===== 动态 Agent 路由注入 =====
|
|
1463
|
+
|
|
938
1464
|
if (useDynamicAgent) {
|
|
939
1465
|
const targetAgentId = generateAgentId(
|
|
940
1466
|
chatType === "group" ? "group" : "dm",
|
|
941
|
-
chatId
|
|
1467
|
+
chatId,
|
|
1468
|
+
account.accountId,
|
|
942
1469
|
);
|
|
943
1470
|
route.agentId = targetAgentId;
|
|
944
|
-
route.sessionKey = `agent:${targetAgentId}:${chatType === "group" ? "group" : "dm"}:${chatId}`;
|
|
1471
|
+
route.sessionKey = `agent:${targetAgentId}:wecom:${account.accountId}:${chatType === "group" ? "group" : "dm"}:${chatId}`;
|
|
945
1472
|
// 异步添加到 agents.list(不阻塞)
|
|
946
1473
|
ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
|
|
947
1474
|
logVerbose(target, `dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
|
|
@@ -1022,7 +1549,7 @@ async function startAgentForStream(params: {
|
|
|
1022
1549
|
SenderName: userid,
|
|
1023
1550
|
SenderId: userid,
|
|
1024
1551
|
Provider: "wecom",
|
|
1025
|
-
Surface: "
|
|
1552
|
+
Surface: "webchat",
|
|
1026
1553
|
MessageSid: msg.msgid,
|
|
1027
1554
|
CommandAuthorized: commandAuthorized,
|
|
1028
1555
|
OriginatingChannel: "wecom",
|
|
@@ -1054,34 +1581,65 @@ async function startAgentForStream(params: {
|
|
|
1054
1581
|
// 重要:message 工具不是 sandbox 工具,必须通过 cfg.tools.deny 禁用。
|
|
1055
1582
|
// 否则 Agent 可能直接通过 message 工具私信/发群,绕过 Bot 交付链路,导致群里“没有任何提示”。
|
|
1056
1583
|
const cfgForDispatch = (() => {
|
|
1584
|
+
const baseAgents = (config as any)?.agents ?? {};
|
|
1585
|
+
const baseAgentDefaults = (baseAgents as any)?.defaults ?? {};
|
|
1586
|
+
const baseBlockChunk = (baseAgentDefaults as any)?.blockStreamingChunk ?? {};
|
|
1587
|
+
const baseBlockCoalesce = (baseAgentDefaults as any)?.blockStreamingCoalesce ?? {};
|
|
1057
1588
|
const baseTools = (config as any)?.tools ?? {};
|
|
1058
1589
|
const baseSandbox = (baseTools as any)?.sandbox ?? {};
|
|
1059
1590
|
const baseSandboxTools = (baseSandbox as any)?.tools ?? {};
|
|
1060
|
-
const
|
|
1061
|
-
const
|
|
1591
|
+
const existingTopLevelDeny = Array.isArray((baseTools as any).deny) ? ((baseTools as any).deny as string[]) : [];
|
|
1592
|
+
const existingSandboxDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
|
|
1593
|
+
const topLevelDeny = Array.from(new Set([...existingTopLevelDeny, "message"]));
|
|
1594
|
+
const sandboxDeny = Array.from(new Set([...existingSandboxDeny, "message"]));
|
|
1062
1595
|
return {
|
|
1063
1596
|
...(config as any),
|
|
1597
|
+
agents: {
|
|
1598
|
+
...baseAgents,
|
|
1599
|
+
defaults: {
|
|
1600
|
+
...baseAgentDefaults,
|
|
1601
|
+
// Bot 通道使用企业微信被动流式刷新,需要更小的块阈值,避免只在结束时一次性输出。
|
|
1602
|
+
blockStreamingChunk: {
|
|
1603
|
+
...baseBlockChunk,
|
|
1604
|
+
minChars: baseBlockChunk.minChars ?? 120,
|
|
1605
|
+
maxChars: baseBlockChunk.maxChars ?? 360,
|
|
1606
|
+
breakPreference: baseBlockChunk.breakPreference ?? "sentence",
|
|
1607
|
+
},
|
|
1608
|
+
blockStreamingCoalesce: {
|
|
1609
|
+
...baseBlockCoalesce,
|
|
1610
|
+
minChars: baseBlockCoalesce.minChars ?? 120,
|
|
1611
|
+
maxChars: baseBlockCoalesce.maxChars ?? 360,
|
|
1612
|
+
idleMs: baseBlockCoalesce.idleMs ?? 250,
|
|
1613
|
+
},
|
|
1614
|
+
},
|
|
1615
|
+
},
|
|
1064
1616
|
tools: {
|
|
1065
1617
|
...baseTools,
|
|
1618
|
+
deny: topLevelDeny,
|
|
1066
1619
|
sandbox: {
|
|
1067
1620
|
...baseSandbox,
|
|
1068
1621
|
tools: {
|
|
1069
1622
|
...baseSandboxTools,
|
|
1070
|
-
deny,
|
|
1623
|
+
deny: sandboxDeny,
|
|
1071
1624
|
},
|
|
1072
1625
|
},
|
|
1073
1626
|
},
|
|
1074
1627
|
} as OpenClawConfig;
|
|
1075
1628
|
})();
|
|
1076
|
-
logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(tools.sandbox.tools.deny
|
|
1629
|
+
logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(tools.deny += message;并同步到 tools.sandbox.tools.deny,防止绕过 Bot 交付)`);
|
|
1077
1630
|
|
|
1078
1631
|
// 调度 Agent 回复
|
|
1079
1632
|
// 使用 dispatchReplyWithBufferedBlockDispatcher 可以处理流式输出 buffer
|
|
1080
1633
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1081
1634
|
ctx: ctxPayload,
|
|
1082
1635
|
cfg: cfgForDispatch,
|
|
1636
|
+
// WeCom Bot relies on passive stream-refresh callbacks; force block streaming on
|
|
1637
|
+
// so the dispatcher emits incremental blocks instead of only a final message.
|
|
1638
|
+
replyOptions: {
|
|
1639
|
+
disableBlockStreaming: false,
|
|
1640
|
+
},
|
|
1083
1641
|
dispatcherOptions: {
|
|
1084
|
-
deliver: async (payload) => {
|
|
1642
|
+
deliver: async (payload, info) => {
|
|
1085
1643
|
let text = payload.text ?? "";
|
|
1086
1644
|
|
|
1087
1645
|
// 保护 <think> 标签不被 markdown 表格转换破坏
|
|
@@ -1152,9 +1710,10 @@ async function startAgentForStream(params: {
|
|
|
1152
1710
|
if (!current.images) current.images = [];
|
|
1153
1711
|
if (!current.agentMediaKeys) current.agentMediaKeys = [];
|
|
1154
1712
|
|
|
1713
|
+
const deliverKind = info?.kind ?? "block";
|
|
1155
1714
|
logVerbose(
|
|
1156
1715
|
target,
|
|
1157
|
-
`deliver: chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
|
|
1716
|
+
`deliver: kind=${deliverKind} chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
|
|
1158
1717
|
);
|
|
1159
1718
|
|
|
1160
1719
|
// If the model referenced a local image path in its reply but did not emit mediaUrl(s),
|
|
@@ -1200,13 +1759,13 @@ async function startAgentForStream(params: {
|
|
|
1200
1759
|
});
|
|
1201
1760
|
}
|
|
1202
1761
|
|
|
1203
|
-
// Timeout fallback
|
|
1762
|
+
// Timeout fallback: near 6min window, stop bot stream and switch to Agent DM.
|
|
1204
1763
|
const now = Date.now();
|
|
1205
1764
|
const deadline = current.createdAt + BOT_WINDOW_MS;
|
|
1206
1765
|
const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
|
|
1207
1766
|
const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
|
|
1208
1767
|
if (nearTimeout) {
|
|
1209
|
-
const agentCfg = resolveAgentAccountOrUndefined(config);
|
|
1768
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
1210
1769
|
const agentOk = Boolean(agentCfg);
|
|
1211
1770
|
const prompt = buildFallbackPrompt({
|
|
1212
1771
|
kind: "timeout",
|
|
@@ -1235,10 +1794,10 @@ async function startAgentForStream(params: {
|
|
|
1235
1794
|
|
|
1236
1795
|
const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
1237
1796
|
for (const mediaPath of mediaUrls) {
|
|
1797
|
+
let contentType: string | undefined;
|
|
1798
|
+
let filename = mediaPath.split("/").pop() || "attachment";
|
|
1238
1799
|
try {
|
|
1239
1800
|
let buf: Buffer;
|
|
1240
|
-
let contentType: string | undefined;
|
|
1241
|
-
let filename: string;
|
|
1242
1801
|
|
|
1243
1802
|
const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
|
|
1244
1803
|
|
|
@@ -1264,7 +1823,7 @@ async function startAgentForStream(params: {
|
|
|
1264
1823
|
logVerbose(target, `media: 识别为图片 contentType=${contentType} filename=${filename}`);
|
|
1265
1824
|
} else {
|
|
1266
1825
|
// Non-image media: Bot 不支持原样发送(尤其群聊),统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
|
|
1267
|
-
const agentCfg = resolveAgentAccountOrUndefined(config);
|
|
1826
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
1268
1827
|
const agentOk = Boolean(agentCfg);
|
|
1269
1828
|
const alreadySent = current.agentMediaKeys.includes(mediaPath);
|
|
1270
1829
|
logVerbose(
|
|
@@ -1315,6 +1874,48 @@ async function startAgentForStream(params: {
|
|
|
1315
1874
|
}
|
|
1316
1875
|
} catch (err) {
|
|
1317
1876
|
target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
|
|
1877
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
1878
|
+
const agentOk = Boolean(agentCfg);
|
|
1879
|
+
const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
|
|
1880
|
+
if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
|
|
1881
|
+
try {
|
|
1882
|
+
await sendAgentDmMedia({
|
|
1883
|
+
agent: agentCfg,
|
|
1884
|
+
userId: current.userId,
|
|
1885
|
+
mediaUrlOrPath: mediaPath,
|
|
1886
|
+
contentType,
|
|
1887
|
+
filename: fallbackFilename,
|
|
1888
|
+
});
|
|
1889
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1890
|
+
s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
|
|
1891
|
+
});
|
|
1892
|
+
logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
|
|
1893
|
+
} catch (sendErr) {
|
|
1894
|
+
target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (!current.fallbackMode) {
|
|
1898
|
+
const prompt = buildFallbackPrompt({
|
|
1899
|
+
kind: "error",
|
|
1900
|
+
agentConfigured: agentOk,
|
|
1901
|
+
userId: current.userId,
|
|
1902
|
+
filename: fallbackFilename,
|
|
1903
|
+
chatType: current.chatType,
|
|
1904
|
+
});
|
|
1905
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1906
|
+
s.fallbackMode = "error";
|
|
1907
|
+
s.finished = true;
|
|
1908
|
+
s.content = prompt;
|
|
1909
|
+
s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
|
|
1910
|
+
});
|
|
1911
|
+
try {
|
|
1912
|
+
await sendBotFallbackPromptNow({ streamId, text: prompt });
|
|
1913
|
+
logVerbose(target, `fallback(error): 群内提示已推送`);
|
|
1914
|
+
} catch (pushErr) {
|
|
1915
|
+
target.runtime.error?.(`wecom bot fallback prompt push failed (error) streamId=${streamId}: ${String(pushErr)}`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return;
|
|
1318
1919
|
}
|
|
1319
1920
|
}
|
|
1320
1921
|
|
|
@@ -1353,12 +1954,18 @@ async function startAgentForStream(params: {
|
|
|
1353
1954
|
}
|
|
1354
1955
|
}
|
|
1355
1956
|
|
|
1957
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1958
|
+
if (!s.content.trim() && !(s.images?.length ?? 0)) {
|
|
1959
|
+
s.content = "✅ 已处理完成。";
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1356
1963
|
streamStore.markFinished(streamId);
|
|
1357
1964
|
|
|
1358
1965
|
// Timeout fallback final delivery (Agent DM): send once after the agent run completes.
|
|
1359
1966
|
const finishedState = streamStore.getStream(streamId);
|
|
1360
1967
|
if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
|
|
1361
|
-
const agentCfg = resolveAgentAccountOrUndefined(config);
|
|
1968
|
+
const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
|
|
1362
1969
|
if (!agentCfg) {
|
|
1363
1970
|
// Agent not configured - group prompt already explains the situation.
|
|
1364
1971
|
streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
|
|
@@ -1377,35 +1984,19 @@ async function startAgentForStream(params: {
|
|
|
1377
1984
|
}
|
|
1378
1985
|
}
|
|
1379
1986
|
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
responseUrl,
|
|
1394
|
-
{
|
|
1395
|
-
method: "POST",
|
|
1396
|
-
headers: { "Content-Type": "application/json" },
|
|
1397
|
-
body: JSON.stringify(finalReply),
|
|
1398
|
-
},
|
|
1399
|
-
{ proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
|
|
1400
|
-
);
|
|
1401
|
-
if (!res.ok) {
|
|
1402
|
-
throw new Error(`final stream push failed: ${res.status}`);
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
logVerbose(target, `final stream pushed via response_url (group) streamId=${streamId}, images=${state.images?.length ?? 0}`);
|
|
1406
|
-
} catch (err) {
|
|
1407
|
-
target.runtime.error?.(`final stream push via response_url failed (group) streamId=${streamId}: ${String(err)}`);
|
|
1408
|
-
}
|
|
1987
|
+
// 统一终结:只要 response_url 可用,尽量主动推一次最终流帧,确保“思考中”能及时收口。
|
|
1988
|
+
// 失败仅记录日志,不影响 stream_refresh 被动拉取链路。
|
|
1989
|
+
const stateAfterFinish = streamStore.getStream(streamId);
|
|
1990
|
+
const responseUrl = getActiveReplyUrl(streamId);
|
|
1991
|
+
if (stateAfterFinish && responseUrl) {
|
|
1992
|
+
try {
|
|
1993
|
+
await pushFinalStreamReplyNow(streamId);
|
|
1994
|
+
logVerbose(
|
|
1995
|
+
target,
|
|
1996
|
+
`final stream pushed via response_url streamId=${streamId}, chatType=${chatType}, images=${stateAfterFinish.images?.length ?? 0}`,
|
|
1997
|
+
);
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
target.runtime.error?.(`final stream push via response_url failed streamId=${streamId}: ${String(err)}`);
|
|
1409
2000
|
}
|
|
1410
2001
|
}
|
|
1411
2002
|
|
|
@@ -1501,11 +2092,15 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
|
|
|
1501
2092
|
* 注册 Agent 模式 Webhook Target
|
|
1502
2093
|
*/
|
|
1503
2094
|
export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => void {
|
|
1504
|
-
const key =
|
|
1505
|
-
|
|
2095
|
+
const key = normalizeWebhookPath(target.path);
|
|
2096
|
+
const normalizedTarget = { ...target, path: key };
|
|
2097
|
+
const existing = agentTargets.get(key) ?? [];
|
|
2098
|
+
agentTargets.set(key, [...existing, normalizedTarget]);
|
|
1506
2099
|
ensurePruneTimer();
|
|
1507
2100
|
return () => {
|
|
1508
|
-
agentTargets.
|
|
2101
|
+
const updated = (agentTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
|
2102
|
+
if (updated.length > 0) agentTargets.set(key, updated);
|
|
2103
|
+
else agentTargets.delete(key);
|
|
1509
2104
|
checkPruneTimer();
|
|
1510
2105
|
};
|
|
1511
2106
|
}
|
|
@@ -1515,7 +2110,7 @@ export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => vo
|
|
|
1515
2110
|
*
|
|
1516
2111
|
* 处理来自企业微信的所有 Webhook 请求。
|
|
1517
2112
|
* 职责:
|
|
1518
|
-
* 1.
|
|
2113
|
+
* 1. 路由分发:按 Matrix/Legacy 路径分流 Bot 与 Agent 回调。
|
|
1519
2114
|
* 2. 安全校验:验证企业微信签名 (Signature)。
|
|
1520
2115
|
* 3. 消息解密:处理企业微信的加密包。
|
|
1521
2116
|
* 4. 响应处理:
|
|
@@ -1539,27 +2134,191 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
|
|
|
1539
2134
|
`[wecom] inbound(http): reqId=${reqId} path=${path} method=${req.method ?? "UNKNOWN"} remote=${remote} ua=${ua ? `"${ua}"` : "N/A"} contentLength=${cl || "N/A"} query={timestamp:${hasTimestamp},nonce:${hasNonce},echostr:${hasEchostr},msg_signature:${hasMsgSig},signature:${hasSignature}}`,
|
|
1540
2135
|
);
|
|
1541
2136
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
2137
|
+
if (hasMatrixExplicitRoutesRegistered() && isLegacyWecomPath(path)) {
|
|
2138
|
+
logRouteFailure({
|
|
2139
|
+
reqId,
|
|
2140
|
+
path,
|
|
2141
|
+
method: req.method ?? "UNKNOWN",
|
|
2142
|
+
reason: "wecom_matrix_path_required",
|
|
2143
|
+
candidateAccountIds: [],
|
|
2144
|
+
});
|
|
2145
|
+
writeRouteFailure(
|
|
2146
|
+
res,
|
|
2147
|
+
"wecom_matrix_path_required",
|
|
2148
|
+
"Matrix mode requires explicit account path. Use /wecom/bot/{accountId} or /wecom/agent/{accountId}.",
|
|
2149
|
+
);
|
|
2150
|
+
return true;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const isAgentPath = path === WEBHOOK_PATHS.AGENT || path.startsWith(`${WEBHOOK_PATHS.AGENT}/`);
|
|
2154
|
+
if (isAgentPath) {
|
|
2155
|
+
const targets = agentTargets.get(path) ?? [];
|
|
2156
|
+
if (targets.length > 0) {
|
|
1547
2157
|
const query = resolveQueryParams(req);
|
|
1548
2158
|
const timestamp = query.get("timestamp") ?? "";
|
|
1549
2159
|
const nonce = query.get("nonce") ?? "";
|
|
1550
|
-
const
|
|
2160
|
+
const signature = resolveSignatureParam(query);
|
|
2161
|
+
const hasSig = Boolean(signature);
|
|
1551
2162
|
const remote = req.socket?.remoteAddress ?? "unknown";
|
|
1552
|
-
|
|
1553
|
-
|
|
2163
|
+
|
|
2164
|
+
if (req.method === "GET") {
|
|
2165
|
+
const echostr = query.get("echostr") ?? "";
|
|
2166
|
+
const signatureMatches = targets.filter((target) =>
|
|
2167
|
+
verifyWecomSignature({
|
|
2168
|
+
token: target.agent.token,
|
|
2169
|
+
timestamp,
|
|
2170
|
+
nonce,
|
|
2171
|
+
encrypt: echostr,
|
|
2172
|
+
signature,
|
|
2173
|
+
}),
|
|
2174
|
+
);
|
|
2175
|
+
if (signatureMatches.length !== 1) {
|
|
2176
|
+
const reason: RouteFailureReason =
|
|
2177
|
+
signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
|
|
2178
|
+
const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
|
|
2179
|
+
(target) => target.agent.accountId,
|
|
2180
|
+
);
|
|
2181
|
+
logRouteFailure({
|
|
2182
|
+
reqId,
|
|
2183
|
+
path,
|
|
2184
|
+
method: "GET",
|
|
2185
|
+
reason,
|
|
2186
|
+
candidateAccountIds: candidateIds,
|
|
2187
|
+
});
|
|
2188
|
+
writeRouteFailure(
|
|
2189
|
+
res,
|
|
2190
|
+
reason,
|
|
2191
|
+
reason === "wecom_account_conflict"
|
|
2192
|
+
? "Agent callback account conflict: multiple accounts matched signature."
|
|
2193
|
+
: "Agent callback account not found: signature verification failed.",
|
|
2194
|
+
);
|
|
2195
|
+
return true;
|
|
2196
|
+
}
|
|
2197
|
+
const selected = signatureMatches[0]!;
|
|
2198
|
+
try {
|
|
2199
|
+
const plain = decryptWecomEncrypted({
|
|
2200
|
+
encodingAESKey: selected.agent.encodingAESKey,
|
|
2201
|
+
receiveId: selected.agent.corpId,
|
|
2202
|
+
encrypt: echostr,
|
|
2203
|
+
});
|
|
2204
|
+
res.statusCode = 200;
|
|
2205
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2206
|
+
res.end(plain);
|
|
2207
|
+
return true;
|
|
2208
|
+
} catch {
|
|
2209
|
+
res.statusCode = 400;
|
|
2210
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2211
|
+
res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
|
|
2212
|
+
return true;
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
if (req.method !== "POST") return false;
|
|
2217
|
+
|
|
2218
|
+
const rawBody = await readTextBody(req, WECOM_LIMITS.MAX_REQUEST_BODY_SIZE);
|
|
2219
|
+
if (!rawBody.ok) {
|
|
2220
|
+
res.statusCode = 400;
|
|
2221
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2222
|
+
res.end(rawBody.error || "invalid payload");
|
|
2223
|
+
return true;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
let encrypted = "";
|
|
2227
|
+
try {
|
|
2228
|
+
encrypted = extractEncryptFromXml(rawBody.value);
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
res.statusCode = 400;
|
|
2231
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2232
|
+
res.end(`invalid xml - 缺少 Encrypt 字段${ERROR_HELP}`);
|
|
2233
|
+
return true;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
const signatureMatches = targets.filter((target) =>
|
|
2237
|
+
verifyWecomSignature({
|
|
2238
|
+
token: target.agent.token,
|
|
2239
|
+
timestamp,
|
|
2240
|
+
nonce,
|
|
2241
|
+
encrypt: encrypted,
|
|
2242
|
+
signature,
|
|
2243
|
+
}),
|
|
2244
|
+
);
|
|
2245
|
+
if (signatureMatches.length !== 1) {
|
|
2246
|
+
const reason: RouteFailureReason =
|
|
2247
|
+
signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
|
|
2248
|
+
const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
|
|
2249
|
+
(target) => target.agent.accountId,
|
|
2250
|
+
);
|
|
2251
|
+
logRouteFailure({
|
|
2252
|
+
reqId,
|
|
2253
|
+
path,
|
|
2254
|
+
method: "POST",
|
|
2255
|
+
reason,
|
|
2256
|
+
candidateAccountIds: candidateIds,
|
|
2257
|
+
});
|
|
2258
|
+
writeRouteFailure(
|
|
2259
|
+
res,
|
|
2260
|
+
reason,
|
|
2261
|
+
reason === "wecom_account_conflict"
|
|
2262
|
+
? "Agent callback account conflict: multiple accounts matched signature."
|
|
2263
|
+
: "Agent callback account not found: signature verification failed.",
|
|
2264
|
+
);
|
|
2265
|
+
return true;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const selected = signatureMatches[0]!;
|
|
2269
|
+
let decrypted = "";
|
|
2270
|
+
let parsed: ReturnType<typeof parseXml> | null = null;
|
|
2271
|
+
try {
|
|
2272
|
+
decrypted = decryptWecomEncrypted({
|
|
2273
|
+
encodingAESKey: selected.agent.encodingAESKey,
|
|
2274
|
+
receiveId: selected.agent.corpId,
|
|
2275
|
+
encrypt: encrypted,
|
|
2276
|
+
});
|
|
2277
|
+
parsed = parseXml(decrypted);
|
|
2278
|
+
} catch {
|
|
2279
|
+
res.statusCode = 400;
|
|
2280
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2281
|
+
res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
|
|
2282
|
+
return true;
|
|
2283
|
+
}
|
|
2284
|
+
if (!parsed) {
|
|
2285
|
+
res.statusCode = 400;
|
|
2286
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2287
|
+
res.end(`invalid xml - XML 解析失败${ERROR_HELP}`);
|
|
2288
|
+
return true;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const inboundAgentId = normalizeAgentIdValue(extractAgentId(parsed));
|
|
2292
|
+
if (
|
|
2293
|
+
inboundAgentId !== undefined &&
|
|
2294
|
+
selected.agent.agentId !== undefined &&
|
|
2295
|
+
inboundAgentId !== selected.agent.agentId
|
|
2296
|
+
) {
|
|
2297
|
+
selected.runtime.error?.(
|
|
2298
|
+
`[wecom] inbound(agent): reqId=${reqId} accountId=${selected.agent.accountId} agentId_mismatch expected=${selected.agent.agentId} actual=${inboundAgentId}`,
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const core = getWecomRuntime();
|
|
2303
|
+
selected.runtime.log?.(
|
|
2304
|
+
`[wecom] inbound(agent): reqId=${reqId} method=${req.method ?? "UNKNOWN"} remote=${remote} timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${hasSig ? "yes" : "no"} accountId=${selected.agent.accountId}`,
|
|
1554
2305
|
);
|
|
1555
2306
|
return handleAgentWebhook({
|
|
1556
2307
|
req,
|
|
1557
2308
|
res,
|
|
1558
|
-
|
|
1559
|
-
|
|
2309
|
+
verifiedPost: {
|
|
2310
|
+
timestamp,
|
|
2311
|
+
nonce,
|
|
2312
|
+
signature,
|
|
2313
|
+
encrypted,
|
|
2314
|
+
decrypted,
|
|
2315
|
+
parsed,
|
|
2316
|
+
},
|
|
2317
|
+
agent: selected.agent,
|
|
2318
|
+
config: selected.config,
|
|
1560
2319
|
core,
|
|
1561
|
-
log:
|
|
1562
|
-
error:
|
|
2320
|
+
log: selected.runtime.log,
|
|
2321
|
+
error: selected.runtime.error,
|
|
1563
2322
|
});
|
|
1564
2323
|
}
|
|
1565
2324
|
// 未注册 Agent,返回 404
|
|
@@ -1580,13 +2339,33 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
|
|
|
1580
2339
|
|
|
1581
2340
|
if (req.method === "GET") {
|
|
1582
2341
|
const echostr = query.get("echostr") ?? "";
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2342
|
+
const signatureMatches = targets.filter((target) =>
|
|
2343
|
+
target.account.token &&
|
|
2344
|
+
verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt: echostr, signature }),
|
|
2345
|
+
);
|
|
2346
|
+
if (signatureMatches.length !== 1) {
|
|
2347
|
+
const reason: RouteFailureReason =
|
|
2348
|
+
signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
|
|
2349
|
+
const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
|
|
2350
|
+
(target) => target.account.accountId,
|
|
2351
|
+
);
|
|
2352
|
+
logRouteFailure({
|
|
2353
|
+
reqId,
|
|
2354
|
+
path,
|
|
2355
|
+
method: "GET",
|
|
2356
|
+
reason,
|
|
2357
|
+
candidateAccountIds: candidateIds,
|
|
2358
|
+
});
|
|
2359
|
+
writeRouteFailure(
|
|
2360
|
+
res,
|
|
2361
|
+
reason,
|
|
2362
|
+
reason === "wecom_account_conflict"
|
|
2363
|
+
? "Bot callback account conflict: multiple accounts matched signature."
|
|
2364
|
+
: "Bot callback account not found: signature verification failed.",
|
|
2365
|
+
);
|
|
1588
2366
|
return true;
|
|
1589
2367
|
}
|
|
2368
|
+
const target = signatureMatches[0]!;
|
|
1590
2369
|
try {
|
|
1591
2370
|
const plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt: echostr });
|
|
1592
2371
|
res.statusCode = 200;
|
|
@@ -1615,28 +2394,59 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
|
|
|
1615
2394
|
console.log(
|
|
1616
2395
|
`[wecom] inbound(bot): reqId=${reqId} rawJsonBytes=${Buffer.byteLength(JSON.stringify(record), "utf8")} hasEncrypt=${Boolean(encrypt)} encryptLen=${encrypt.length}`,
|
|
1617
2396
|
);
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2397
|
+
const signatureMatches = targets.filter((target) =>
|
|
2398
|
+
target.account.token &&
|
|
2399
|
+
verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt, signature }),
|
|
2400
|
+
);
|
|
2401
|
+
if (signatureMatches.length !== 1) {
|
|
2402
|
+
const reason: RouteFailureReason =
|
|
2403
|
+
signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
|
|
2404
|
+
const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
|
|
2405
|
+
(target) => target.account.accountId,
|
|
2406
|
+
);
|
|
2407
|
+
logRouteFailure({
|
|
2408
|
+
reqId,
|
|
2409
|
+
path,
|
|
2410
|
+
method: "POST",
|
|
2411
|
+
reason,
|
|
2412
|
+
candidateAccountIds: candidateIds,
|
|
2413
|
+
});
|
|
2414
|
+
writeRouteFailure(
|
|
2415
|
+
res,
|
|
2416
|
+
reason,
|
|
2417
|
+
reason === "wecom_account_conflict"
|
|
2418
|
+
? "Bot callback account conflict: multiple accounts matched signature."
|
|
2419
|
+
: "Bot callback account not found: signature verification failed.",
|
|
2420
|
+
);
|
|
1623
2421
|
return true;
|
|
1624
2422
|
}
|
|
1625
2423
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
let plain: string;
|
|
2424
|
+
const target = signatureMatches[0]!;
|
|
2425
|
+
let msg: WecomInboundMessage;
|
|
1630
2426
|
try {
|
|
1631
|
-
plain = decryptWecomEncrypted({
|
|
1632
|
-
|
|
2427
|
+
const plain = decryptWecomEncrypted({
|
|
2428
|
+
encodingAESKey: target.account.encodingAESKey,
|
|
2429
|
+
receiveId: target.account.receiveId,
|
|
2430
|
+
encrypt,
|
|
2431
|
+
});
|
|
2432
|
+
msg = parseWecomPlainMessage(plain);
|
|
2433
|
+
} catch {
|
|
1633
2434
|
res.statusCode = 400;
|
|
1634
2435
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1635
|
-
res.end(`decrypt failed -
|
|
2436
|
+
res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
|
|
1636
2437
|
return true;
|
|
1637
2438
|
}
|
|
2439
|
+
const expected = resolveBotIdentitySet(target);
|
|
2440
|
+
if (expected.size > 0) {
|
|
2441
|
+
const inboundAibotId = String((msg as any).aibotid ?? "").trim();
|
|
2442
|
+
if (!inboundAibotId || !expected.has(inboundAibotId)) {
|
|
2443
|
+
target.runtime.error?.(
|
|
2444
|
+
`[wecom] inbound(bot): reqId=${reqId} accountId=${target.account.accountId} aibotid_mismatch expected=${Array.from(expected).join(",")} actual=${inboundAibotId || "N/A"}`,
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
1638
2448
|
|
|
1639
|
-
|
|
2449
|
+
logInfo(target, `inbound(bot): reqId=${reqId} selectedAccount=${target.account.accountId} path=${path}`);
|
|
1640
2450
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
1641
2451
|
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
1642
2452
|
|
|
@@ -1698,8 +2508,18 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
|
|
|
1698
2508
|
|
|
1699
2509
|
// Handle Message (with Debounce)
|
|
1700
2510
|
try {
|
|
1701
|
-
const
|
|
1702
|
-
|
|
2511
|
+
const decision = shouldProcessBotInboundMessage(msg);
|
|
2512
|
+
if (!decision.shouldProcess) {
|
|
2513
|
+
logInfo(
|
|
2514
|
+
target,
|
|
2515
|
+
`inbound: skipped msgtype=${msgtype} reason=${decision.reason} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${resolveWecomSenderUserId(msg) || "N/A"}`,
|
|
2516
|
+
);
|
|
2517
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
|
|
2518
|
+
return true;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
const userid = decision.senderUserId!;
|
|
2522
|
+
const chatId = decision.chatId ?? userid;
|
|
1703
2523
|
const conversationKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
|
|
1704
2524
|
const msgContent = buildInboundBody(msg);
|
|
1705
2525
|
|