@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.
Files changed (54) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/CLAUDE.md +1 -1
  3. package/GOVERNANCE.md +26 -0
  4. package/LICENSE +7 -0
  5. package/README.md +275 -91
  6. package/assets/01.bot-add.png +0 -0
  7. package/assets/01.bot-setp2.png +0 -0
  8. package/assets/02.agent.add.png +0 -0
  9. package/assets/02.agent.api-set.png +0 -0
  10. package/assets/register.png +0 -0
  11. package/changelog/v2.2.28.md +70 -0
  12. package/changelog/v2.3.2.md +70 -0
  13. package/compat-single-account.md +118 -0
  14. package/package.json +10 -2
  15. package/src/accounts.ts +17 -55
  16. package/src/agent/api-client.ts +84 -37
  17. package/src/agent/api-client.upload.test.ts +110 -0
  18. package/src/agent/handler.event-filter.test.ts +50 -0
  19. package/src/agent/handler.ts +147 -145
  20. package/src/channel.config.test.ts +147 -0
  21. package/src/channel.lifecycle.test.ts +234 -0
  22. package/src/channel.ts +90 -140
  23. package/src/config/accounts.resolve.test.ts +38 -0
  24. package/src/config/accounts.ts +257 -22
  25. package/src/config/index.ts +6 -0
  26. package/src/config/network.ts +9 -5
  27. package/src/config/routing.test.ts +88 -0
  28. package/src/config/routing.ts +26 -0
  29. package/src/config/schema.ts +35 -4
  30. package/src/config-schema.ts +5 -41
  31. package/src/dynamic-agent.account-scope.test.ts +17 -0
  32. package/src/dynamic-agent.ts +13 -13
  33. package/src/gateway-monitor.ts +200 -0
  34. package/src/http.ts +16 -2
  35. package/src/media.test.ts +28 -1
  36. package/src/media.ts +59 -1
  37. package/src/monitor/state.queue.test.ts +1 -1
  38. package/src/monitor/state.ts +1 -1
  39. package/src/monitor/types.ts +1 -1
  40. package/src/monitor.active.test.ts +13 -7
  41. package/src/monitor.inbound-filter.test.ts +63 -0
  42. package/src/monitor.ts +948 -128
  43. package/src/monitor.webhook.test.ts +288 -3
  44. package/src/outbound.test.ts +130 -0
  45. package/src/outbound.ts +44 -9
  46. package/src/shared/command-auth.ts +4 -2
  47. package/src/shared/xml-parser.test.ts +21 -1
  48. package/src/shared/xml-parser.ts +18 -0
  49. package/src/types/account.ts +43 -14
  50. package/src/types/config.ts +37 -2
  51. package/src/types/index.ts +3 -0
  52. package/src/types.ts +29 -147
  53. package/GEMINI.md +0 -76
  54. 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 { decryptWecomMedia, decryptWecomMediaWithHttp } from "./media.js";
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 { resolveWecomAccounts, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes } from "./config/index.js";
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 axios from "axios";
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 = resolveWecomAccounts(cfg).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 macOS absolute paths for images.
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 macOS absolute paths.
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
- const map: Record<string, string> = {
443
- png: "image/png",
444
- jpg: "image/jpeg",
445
- jpeg: "image/jpeg",
446
- gif: "image/gif",
447
- webp: "image/webp",
448
- bmp: "image/bmp",
449
- pdf: "application/pdf",
450
- txt: "text/plain",
451
- md: "text/markdown",
452
- json: "application/json",
453
- zip: "application/zip",
454
- };
455
- return map[ext];
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 buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
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: buf,
549
- contentType: "image/jpeg", // WeCom images are usually generic; safest assumption or could act as generic
550
- filename: "image.jpg",
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
- return { body: `[image] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
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 buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
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: buf,
572
- contentType: "application/octet-stream",
573
- filename: "file.bin", // WeCom doesn't guarantee filename in webhook payload always, defaulting
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
- return { body: `[file] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
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 buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
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: buf,
605
- contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
606
- filename: t === "image" ? "image.jpg" : "file.bin"
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
- bodyParts.push(`[${t}] (decryption failed)`);
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: guessContentTypeFromPath(p),
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(target, `local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p}`);
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" : "dm", id: chatId },
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: "wecom",
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 existingDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
1061
- const deny = Array.from(new Set([...existingDeny, "message"]));
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 += message,防止绕过 Bot 交付)`);
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 (group only): near 6min window, stop bot stream and switch to Agent DM.
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
- // Bot 群聊图片兜底:
1381
- // 依赖企业微信的“流式消息刷新”回调来拉取最终消息有时会出现客户端未能及时拉取到最后一帧的情况,
1382
- // 导致最终的图片(msg_item)没有展示。若存在 response_url,则在流结束后主动推送一次最终 stream 回复。
1383
- // 注:该行为以 response_url 是否可用为准;失败则仅记录日志,不影响原有刷新链路。
1384
- if (chatType === "group") {
1385
- const state = streamStore.getStream(streamId);
1386
- const hasImages = Boolean(state?.images?.length);
1387
- const responseUrl = getActiveReplyUrl(streamId);
1388
- if (state && hasImages && responseUrl) {
1389
- const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
1390
- try {
1391
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1392
- const res = await wecomFetch(
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 = WEBHOOK_PATHS.AGENT;
1505
- agentTargets.set(key, target);
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.delete(key);
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. 路由分发:区分 Agent 模式 (`/wecom/agent`) Bot 模式 (其他路径)。
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
- // Agent 模式路由: /wecom/agent
1543
- if (path === WEBHOOK_PATHS.AGENT) {
1544
- const agentTarget = agentTargets.get(WEBHOOK_PATHS.AGENT);
1545
- if (agentTarget) {
1546
- const core = getWecomRuntime();
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 hasSig = Boolean(query.get("msg_signature"));
2160
+ const signature = resolveSignatureParam(query);
2161
+ const hasSig = Boolean(signature);
1551
2162
  const remote = req.socket?.remoteAddress ?? "unknown";
1552
- agentTarget.runtime.log?.(
1553
- `[wecom] inbound(agent): reqId=${reqId} method=${req.method ?? "UNKNOWN"} remote=${remote} timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${hasSig ? "yes" : "no"}`,
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
- agent: agentTarget.agent,
1559
- config: agentTarget.config,
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: agentTarget.runtime.log,
1562
- error: agentTarget.runtime.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 target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt: echostr, signature }));
1584
- if (!target || !target.account.encodingAESKey) {
1585
- res.statusCode = 401;
1586
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
1587
- res.end(`unauthorized - Bot 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
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 target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt, signature }));
1619
- if (!target || !target.account.configured || !target.account.encodingAESKey) {
1620
- res.statusCode = 401;
1621
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
1622
- res.end(`unauthorized - Bot 签名验证失败${ERROR_HELP}`);
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
- // 选定 target 后,把 reqId 带入结构化日志,方便串联排查
1627
- logInfo(target, `inbound(bot): reqId=${reqId} selectedAccount=${target.account.accountId} path=${path}`);
1628
-
1629
- let plain: string;
2424
+ const target = signatureMatches[0]!;
2425
+ let msg: WecomInboundMessage;
1630
2426
  try {
1631
- plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt });
1632
- } catch (err) {
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 - 解密失败${ERROR_HELP}`);
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
- const msg = parseWecomPlainMessage(plain);
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 userid = resolveWecomSenderUserId(msg) || "unknown";
1702
- const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
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