@yanhaidao/wecom 2.2.28 → 2.3.3

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/src/monitor.ts CHANGED
@@ -11,7 +11,7 @@ import type { WecomBotInboundMessage as WecomInboundMessage, WecomInboundQuote }
11
11
  import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
12
12
  import { extractEncryptFromXml } from "./crypto/xml.js";
13
13
  import { getWecomRuntime } from "./runtime.js";
14
- import { decryptWecomMediaWithHttp } from "./media.js";
14
+ import { decryptWecomMediaWithMeta } from "./media.js";
15
15
  import { WEBHOOK_PATHS, LIMITS as WECOM_LIMITS } from "./types/constants.js";
16
16
  import { handleAgentWebhook } from "./agent/index.js";
17
17
  import { resolveWecomAccount, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "./config/index.js";
@@ -220,16 +220,24 @@ type RouteFailureReason =
220
220
  | "wecom_identity_mismatch"
221
221
  | "wecom_matrix_path_required";
222
222
 
223
- function isLegacyWecomPath(path: string): boolean {
224
- return path === WEBHOOK_PATHS.BOT || path === WEBHOOK_PATHS.BOT_ALT || path === WEBHOOK_PATHS.AGENT;
223
+ function isNonMatrixWecomBasePath(path: string): boolean {
224
+ return (
225
+ path === WEBHOOK_PATHS.BOT ||
226
+ path === WEBHOOK_PATHS.BOT_ALT ||
227
+ path === WEBHOOK_PATHS.AGENT ||
228
+ path === WEBHOOK_PATHS.BOT_PLUGIN ||
229
+ path === WEBHOOK_PATHS.AGENT_PLUGIN
230
+ );
225
231
  }
226
232
 
227
233
  function hasMatrixExplicitRoutesRegistered(): boolean {
228
234
  for (const key of webhookTargets.keys()) {
229
235
  if (key.startsWith(`${WEBHOOK_PATHS.BOT_ALT}/`)) return true;
236
+ if (key.startsWith(`${WEBHOOK_PATHS.BOT_PLUGIN}/`)) return true;
230
237
  }
231
238
  for (const key of agentTargets.keys()) {
232
239
  if (key.startsWith(`${WEBHOOK_PATHS.AGENT}/`)) return true;
240
+ if (key.startsWith(`${WEBHOOK_PATHS.AGENT_PLUGIN}/`)) return true;
233
241
  }
234
242
  return false;
235
243
  }
@@ -437,6 +445,27 @@ async function sendBotFallbackPromptNow(params: { streamId: string; text: string
437
445
  });
438
446
  }
439
447
 
448
+ async function pushFinalStreamReplyNow(streamId: string): Promise<void> {
449
+ const state = streamStore.getStream(streamId);
450
+ const responseUrl = getActiveReplyUrl(streamId);
451
+ if (!state || !responseUrl) return;
452
+ const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
453
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
454
+ const res = await wecomFetch(
455
+ responseUrl,
456
+ {
457
+ method: "POST",
458
+ headers: { "Content-Type": "application/json" },
459
+ body: JSON.stringify(finalReply),
460
+ },
461
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
462
+ );
463
+ if (!res.ok) {
464
+ throw new Error(`final stream push failed: ${res.status}`);
465
+ }
466
+ });
467
+ }
468
+
440
469
  async function sendAgentDmText(params: {
441
470
  agent: ResolvedAgentAccount;
442
471
  userId: string;
@@ -500,10 +529,10 @@ function extractLocalImagePathsFromText(params: {
500
529
  const mustAlsoAppearIn = params.mustAlsoAppearIn;
501
530
  if (!text.trim()) return [];
502
531
 
503
- // Conservative: only accept common macOS absolute paths for images.
532
+ // Conservative: only accept common absolute paths for macOS/Linux hosts.
504
533
  // Also require that the exact path appeared in the user's original message to prevent exfil.
505
534
  const exts = "(png|jpg|jpeg|gif|webp|bmp)";
506
- const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+?\.${exts})`, "gi");
535
+ const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+?\.${exts})`, "gi");
507
536
  const found = new Set<string>();
508
537
  let m: RegExpExecArray | null;
509
538
  while ((m = re.exec(text))) {
@@ -518,9 +547,9 @@ function extractLocalImagePathsFromText(params: {
518
547
  function extractLocalFilePathsFromText(text: string): string[] {
519
548
  if (!text.trim()) return [];
520
549
 
521
- // Conservative: only accept common macOS absolute paths.
550
+ // Conservative: only accept common absolute paths for macOS/Linux hosts.
522
551
  // This is primarily for “send local file” style requests (operator/debug usage).
523
- const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+)`, "g");
552
+ const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+)`, "g");
524
553
  const found = new Set<string>();
525
554
  let m: RegExpExecArray | null;
526
555
  while ((m = re.exec(text))) {
@@ -531,23 +560,287 @@ function extractLocalFilePathsFromText(text: string): string[] {
531
560
  return Array.from(found);
532
561
  }
533
562
 
563
+ const MIME_BY_EXT: Record<string, string> = {
564
+ png: "image/png",
565
+ jpg: "image/jpeg",
566
+ jpeg: "image/jpeg",
567
+ gif: "image/gif",
568
+ webp: "image/webp",
569
+ bmp: "image/bmp",
570
+ pdf: "application/pdf",
571
+ txt: "text/plain",
572
+ csv: "text/csv",
573
+ tsv: "text/tab-separated-values",
574
+ md: "text/markdown",
575
+ json: "application/json",
576
+ xml: "application/xml",
577
+ yaml: "application/yaml",
578
+ yml: "application/yaml",
579
+ zip: "application/zip",
580
+ rar: "application/vnd.rar",
581
+ "7z": "application/x-7z-compressed",
582
+ tar: "application/x-tar",
583
+ gz: "application/gzip",
584
+ tgz: "application/gzip",
585
+ doc: "application/msword",
586
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
587
+ xls: "application/vnd.ms-excel",
588
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
589
+ ppt: "application/vnd.ms-powerpoint",
590
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
591
+ rtf: "application/rtf",
592
+ odt: "application/vnd.oasis.opendocument.text",
593
+ mp3: "audio/mpeg",
594
+ wav: "audio/wav",
595
+ ogg: "audio/ogg",
596
+ amr: "voice/amr",
597
+ m4a: "audio/mp4",
598
+ mp4: "video/mp4",
599
+ mov: "video/quicktime",
600
+ };
601
+
602
+ const EXT_BY_MIME: Record<string, string> = {
603
+ ...Object.fromEntries(Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext])),
604
+ "application/octet-stream": "bin",
605
+ };
606
+
607
+ const GENERIC_CONTENT_TYPES = new Set([
608
+ "application/octet-stream",
609
+ "binary/octet-stream",
610
+ "application/download",
611
+ ]);
612
+
613
+ function normalizeContentType(raw?: string | null): string | undefined {
614
+ const normalized = String(raw ?? "").trim().split(";")[0]?.trim().toLowerCase();
615
+ return normalized || undefined;
616
+ }
617
+
618
+ function isGenericContentType(raw?: string | null): boolean {
619
+ const normalized = normalizeContentType(raw);
620
+ if (!normalized) return true;
621
+ return GENERIC_CONTENT_TYPES.has(normalized);
622
+ }
623
+
534
624
  function guessContentTypeFromPath(filePath: string): string | undefined {
535
625
  const ext = filePath.split(".").pop()?.toLowerCase();
536
626
  if (!ext) return undefined;
537
- const map: Record<string, string> = {
538
- png: "image/png",
539
- jpg: "image/jpeg",
540
- jpeg: "image/jpeg",
541
- gif: "image/gif",
542
- webp: "image/webp",
543
- bmp: "image/bmp",
544
- pdf: "application/pdf",
545
- txt: "text/plain",
546
- md: "text/markdown",
547
- json: "application/json",
548
- zip: "application/zip",
549
- };
550
- return map[ext];
627
+ return MIME_BY_EXT[ext];
628
+ }
629
+
630
+ function guessExtensionFromContentType(contentType?: string): string | undefined {
631
+ const normalized = normalizeContentType(contentType);
632
+ if (!normalized) return undefined;
633
+ if (normalized === "image/jpeg") return "jpg";
634
+ return EXT_BY_MIME[normalized];
635
+ }
636
+
637
+ function extractFileNameFromUrl(rawUrl?: string): string | undefined {
638
+ const s = String(rawUrl ?? "").trim();
639
+ if (!s) return undefined;
640
+ try {
641
+ const u = new URL(s);
642
+ const name = decodeURIComponent(u.pathname.split("/").pop() ?? "").trim();
643
+ return name || undefined;
644
+ } catch {
645
+ return undefined;
646
+ }
647
+ }
648
+
649
+ function sanitizeInboundFilename(raw?: string): string | undefined {
650
+ const s = String(raw ?? "").trim();
651
+ if (!s) return undefined;
652
+ const base = s.split(/[\\/]/).pop()?.trim() ?? "";
653
+ if (!base) return undefined;
654
+ const sanitized = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
655
+ return sanitized || undefined;
656
+ }
657
+
658
+ function hasLikelyExtension(name?: string): boolean {
659
+ if (!name) return false;
660
+ return /\.[a-z0-9]{1,16}$/i.test(name);
661
+ }
662
+
663
+ function detectMimeFromBuffer(buffer: Buffer): string | undefined {
664
+ if (!buffer || buffer.length < 4) return undefined;
665
+
666
+ // PNG
667
+ if (
668
+ buffer.length >= 8 &&
669
+ buffer[0] === 0x89 &&
670
+ buffer[1] === 0x50 &&
671
+ buffer[2] === 0x4e &&
672
+ buffer[3] === 0x47 &&
673
+ buffer[4] === 0x0d &&
674
+ buffer[5] === 0x0a &&
675
+ buffer[6] === 0x1a &&
676
+ buffer[7] === 0x0a
677
+ ) {
678
+ return "image/png";
679
+ }
680
+
681
+ // JPEG
682
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
683
+ return "image/jpeg";
684
+ }
685
+
686
+ // GIF
687
+ if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
688
+ return "image/gif";
689
+ }
690
+
691
+ // WEBP
692
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
693
+ return "image/webp";
694
+ }
695
+
696
+ // BMP
697
+ if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
698
+ return "image/bmp";
699
+ }
700
+
701
+ // PDF
702
+ if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
703
+ return "application/pdf";
704
+ }
705
+
706
+ // OGG
707
+ if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
708
+ return "audio/ogg";
709
+ }
710
+
711
+ // WAV
712
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
713
+ return "audio/wav";
714
+ }
715
+
716
+ // MP3
717
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)) {
718
+ return "audio/mpeg";
719
+ }
720
+
721
+ // MP4/MOV family
722
+ if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
723
+ return "video/mp4";
724
+ }
725
+
726
+ // Legacy Office (OLE Compound File)
727
+ if (
728
+ buffer.length >= 8 &&
729
+ buffer[0] === 0xd0 &&
730
+ buffer[1] === 0xcf &&
731
+ buffer[2] === 0x11 &&
732
+ buffer[3] === 0xe0 &&
733
+ buffer[4] === 0xa1 &&
734
+ buffer[5] === 0xb1 &&
735
+ buffer[6] === 0x1a &&
736
+ buffer[7] === 0xe1
737
+ ) {
738
+ return "application/msword";
739
+ }
740
+
741
+ // ZIP / OOXML
742
+ const zipMagic =
743
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) ||
744
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x05 && buffer[3] === 0x06) ||
745
+ (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x07 && buffer[3] === 0x08);
746
+ if (zipMagic) {
747
+ const probe = buffer.subarray(0, Math.min(buffer.length, 512 * 1024));
748
+ if (probe.includes(Buffer.from("word/"))) {
749
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
750
+ }
751
+ if (probe.includes(Buffer.from("xl/"))) {
752
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
753
+ }
754
+ if (probe.includes(Buffer.from("ppt/"))) {
755
+ return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
756
+ }
757
+ return "application/zip";
758
+ }
759
+
760
+ // Plain text heuristic
761
+ const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
762
+ let printable = 0;
763
+ for (const b of sample) {
764
+ if (b === 0x00) return undefined;
765
+ if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
766
+ printable += 1;
767
+ }
768
+ }
769
+ if (sample.length > 0 && printable / sample.length > 0.95) {
770
+ return "text/plain";
771
+ }
772
+
773
+ return undefined;
774
+ }
775
+
776
+ function resolveInlineFileName(input: unknown): string | undefined {
777
+ const raw = String(input ?? "").trim();
778
+ return sanitizeInboundFilename(raw);
779
+ }
780
+
781
+ function pickBotFileName(msg: WecomInboundMessage, item?: Record<string, any>): string | undefined {
782
+ const fromItem = item
783
+ ? resolveInlineFileName(
784
+ item?.filename ??
785
+ item?.file_name ??
786
+ item?.fileName ??
787
+ item?.name ??
788
+ item?.title,
789
+ )
790
+ : undefined;
791
+ if (fromItem) return fromItem;
792
+
793
+ const fromFile = resolveInlineFileName(
794
+ (msg as any)?.file?.filename ??
795
+ (msg as any)?.file?.file_name ??
796
+ (msg as any)?.file?.fileName ??
797
+ (msg as any)?.file?.name ??
798
+ (msg as any)?.file?.title ??
799
+ (msg as any)?.filename ??
800
+ (msg as any)?.fileName ??
801
+ (msg as any)?.FileName,
802
+ );
803
+ return fromFile;
804
+ }
805
+
806
+ function inferInboundMediaMeta(params: {
807
+ kind: "image" | "file";
808
+ buffer: Buffer;
809
+ sourceUrl?: string;
810
+ sourceContentType?: string;
811
+ sourceFilename?: string;
812
+ explicitFilename?: string;
813
+ }): { contentType: string; filename: string } {
814
+ const headerType = normalizeContentType(params.sourceContentType);
815
+ const magicType = detectMimeFromBuffer(params.buffer);
816
+ const rawUrlName = sanitizeInboundFilename(extractFileNameFromUrl(params.sourceUrl));
817
+ const guessedByUrl = hasLikelyExtension(rawUrlName) ? rawUrlName : undefined;
818
+ const explicitName = sanitizeInboundFilename(params.explicitFilename);
819
+ const sourceName = sanitizeInboundFilename(params.sourceFilename);
820
+ const chosenName = explicitName || sourceName || guessedByUrl;
821
+ const typeByName = chosenName ? guessContentTypeFromPath(chosenName) : undefined;
822
+
823
+ let contentType: string;
824
+ if (params.kind === "image") {
825
+ if (magicType?.startsWith("image/")) contentType = magicType;
826
+ else if (headerType?.startsWith("image/")) contentType = headerType;
827
+ else if (typeByName?.startsWith("image/")) contentType = typeByName;
828
+ else contentType = "image/jpeg";
829
+ } else {
830
+ contentType =
831
+ magicType ||
832
+ (!isGenericContentType(headerType) ? headerType! : undefined) ||
833
+ typeByName ||
834
+ "application/octet-stream";
835
+ }
836
+
837
+ const hasExt = Boolean(chosenName && /\.[a-z0-9]{1,16}$/i.test(chosenName));
838
+ const ext = guessExtensionFromContentType(contentType) || (params.kind === "image" ? "jpg" : "bin");
839
+ const filename = chosenName
840
+ ? (hasExt ? chosenName : `${chosenName}.${ext}`)
841
+ : `${params.kind}.${ext}`;
842
+
843
+ return { contentType, filename };
551
844
  }
552
845
 
553
846
  function looksLikeSendLocalFileIntent(rawBody: string): boolean {
@@ -670,13 +963,21 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
670
963
  const url = String((msg as any).image?.url ?? "").trim();
671
964
  if (url && aesKey) {
672
965
  try {
673
- const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
966
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
967
+ const inferred = inferInboundMediaMeta({
968
+ kind: "image",
969
+ buffer: decrypted.buffer,
970
+ sourceUrl: decrypted.sourceUrl || url,
971
+ sourceContentType: decrypted.sourceContentType,
972
+ sourceFilename: decrypted.sourceFilename,
973
+ explicitFilename: pickBotFileName(msg),
974
+ });
674
975
  return {
675
976
  body: "[image]",
676
977
  media: {
677
- buffer: buf,
678
- contentType: "image/jpeg", // WeCom images are usually generic; safest assumption or could act as generic
679
- filename: "image.jpg",
978
+ buffer: decrypted.buffer,
979
+ contentType: inferred.contentType,
980
+ filename: inferred.filename,
680
981
  }
681
982
  };
682
983
  } catch (err) {
@@ -684,7 +985,10 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
684
985
  target.runtime.error?.(
685
986
  `图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
686
987
  );
687
- return { body: `[image] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
988
+ const errorMessage = typeof err === 'object' && err
989
+ ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
990
+ : String(err);
991
+ return { body: `[image] (decryption failed: ${errorMessage})` };
688
992
  }
689
993
  }
690
994
  }
@@ -693,20 +997,31 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
693
997
  const url = String((msg as any).file?.url ?? "").trim();
694
998
  if (url && aesKey) {
695
999
  try {
696
- const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
1000
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1001
+ const inferred = inferInboundMediaMeta({
1002
+ kind: "file",
1003
+ buffer: decrypted.buffer,
1004
+ sourceUrl: decrypted.sourceUrl || url,
1005
+ sourceContentType: decrypted.sourceContentType,
1006
+ sourceFilename: decrypted.sourceFilename,
1007
+ explicitFilename: pickBotFileName(msg),
1008
+ });
697
1009
  return {
698
1010
  body: "[file]",
699
1011
  media: {
700
- buffer: buf,
701
- contentType: "application/octet-stream",
702
- filename: "file.bin", // WeCom doesn't guarantee filename in webhook payload always, defaulting
1012
+ buffer: decrypted.buffer,
1013
+ contentType: inferred.contentType,
1014
+ filename: inferred.filename,
703
1015
  }
704
1016
  };
705
1017
  } catch (err) {
706
1018
  target.runtime.error?.(
707
1019
  `Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
708
1020
  );
709
- return { body: `[file] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
1021
+ const errorMessage = typeof err === 'object' && err
1022
+ ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
1023
+ : String(err);
1024
+ return { body: `[file] (decryption failed: ${errorMessage})` };
710
1025
  }
711
1026
  }
712
1027
  }
@@ -728,18 +1043,29 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
728
1043
  const url = String(item[t]?.url ?? "").trim();
729
1044
  if (url) {
730
1045
  try {
731
- const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
1046
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1047
+ const inferred = inferInboundMediaMeta({
1048
+ kind: t,
1049
+ buffer: decrypted.buffer,
1050
+ sourceUrl: decrypted.sourceUrl || url,
1051
+ sourceContentType: decrypted.sourceContentType,
1052
+ sourceFilename: decrypted.sourceFilename,
1053
+ explicitFilename: pickBotFileName(msg, item?.[t]),
1054
+ });
732
1055
  foundMedia = {
733
- buffer: buf,
734
- contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
735
- filename: t === "image" ? "image.jpg" : "file.bin"
1056
+ buffer: decrypted.buffer,
1057
+ contentType: inferred.contentType,
1058
+ filename: inferred.filename,
736
1059
  };
737
1060
  bodyParts.push(`[${t}]`);
738
1061
  } catch (err) {
739
1062
  target.runtime.error?.(
740
1063
  `Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
741
1064
  );
742
- bodyParts.push(`[${t}] (decryption failed)`);
1065
+ const errorMessage = typeof err === 'object' && err
1066
+ ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
1067
+ : String(err);
1068
+ bodyParts.push(`[${t}] (decryption failed: ${errorMessage})`);
743
1069
  }
744
1070
  } else {
745
1071
  bodyParts.push(`[${t}]`);
@@ -965,6 +1291,60 @@ async function startAgentForStream(params: {
965
1291
  streamStore.onStreamFinished(streamId);
966
1292
  return;
967
1293
  }
1294
+
1295
+ // 图片路径都读取失败时,切换到 Agent 私信兜底,并主动结束 Bot 流。
1296
+ const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1297
+ const agentOk = Boolean(agentCfg);
1298
+ const fallbackName = imagePaths.length === 1
1299
+ ? (imagePaths[0]!.split("/").pop() || "image")
1300
+ : `${imagePaths.length} 张图片`;
1301
+ const prompt = buildFallbackPrompt({
1302
+ kind: "media",
1303
+ agentConfigured: agentOk,
1304
+ userId: userid,
1305
+ filename: fallbackName,
1306
+ chatType,
1307
+ });
1308
+
1309
+ streamStore.updateStream(streamId, (s) => {
1310
+ s.fallbackMode = "error";
1311
+ s.finished = true;
1312
+ s.content = prompt;
1313
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1314
+ });
1315
+
1316
+ try {
1317
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
1318
+ logVerbose(target, `local-path: 图片读取失败后已推送兜底提示`);
1319
+ } catch (err) {
1320
+ target.runtime.error?.(`local-path: 图片读取失败后的兜底提示推送失败: ${String(err)}`);
1321
+ }
1322
+
1323
+ if (agentCfg && userid && userid !== "unknown") {
1324
+ for (const p of imagePaths) {
1325
+ const guessedType = guessContentTypeFromPath(p);
1326
+ try {
1327
+ await sendAgentDmMedia({
1328
+ agent: agentCfg,
1329
+ userId: userid,
1330
+ mediaUrlOrPath: p,
1331
+ contentType: guessedType,
1332
+ filename: p.split("/").pop() || "image",
1333
+ });
1334
+ streamStore.updateStream(streamId, (s) => {
1335
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
1336
+ });
1337
+ logVerbose(
1338
+ target,
1339
+ `local-path: 图片已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
1340
+ );
1341
+ } catch (err) {
1342
+ target.runtime.error?.(`local-path: 图片 Agent 私信兜底失败 path=${p}: ${String(err)}`);
1343
+ }
1344
+ }
1345
+ }
1346
+ streamStore.onStreamFinished(streamId);
1347
+ return;
968
1348
  }
969
1349
 
970
1350
  // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
@@ -1008,18 +1388,22 @@ async function startAgentForStream(params: {
1008
1388
  for (const p of otherPaths) {
1009
1389
  const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
1010
1390
  if (alreadySent) continue;
1391
+ const guessedType = guessContentTypeFromPath(p);
1011
1392
  try {
1012
1393
  await sendAgentDmMedia({
1013
1394
  agent: agentCfg,
1014
1395
  userId: userid,
1015
1396
  mediaUrlOrPath: p,
1016
- contentType: guessContentTypeFromPath(p),
1397
+ contentType: guessedType,
1017
1398
  filename: p.split("/").pop() || "file",
1018
1399
  });
1019
1400
  streamStore.updateStream(streamId, (s) => {
1020
1401
  s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
1021
1402
  });
1022
- logVerbose(target, `local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p}`);
1403
+ logVerbose(
1404
+ target,
1405
+ `local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
1406
+ );
1023
1407
  } catch (err) {
1024
1408
  target.runtime.error?.(`local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
1025
1409
  }
@@ -1173,6 +1557,10 @@ async function startAgentForStream(params: {
1173
1557
  SenderName: userid,
1174
1558
  SenderId: userid,
1175
1559
  Provider: "wecom",
1560
+ // Keep Surface aligned with OriginatingChannel for Bot-mode delivery.
1561
+ // If Surface is "webchat", core dispatch treats this as cross-channel
1562
+ // and routes replies via routeReply -> wecom outbound (Agent API),
1563
+ // bypassing the Bot stream deliver path.
1176
1564
  Surface: "wecom",
1177
1565
  MessageSid: msg.msgid,
1178
1566
  CommandAuthorized: commandAuthorized,
@@ -1212,8 +1600,10 @@ async function startAgentForStream(params: {
1212
1600
  const baseTools = (config as any)?.tools ?? {};
1213
1601
  const baseSandbox = (baseTools as any)?.sandbox ?? {};
1214
1602
  const baseSandboxTools = (baseSandbox as any)?.tools ?? {};
1215
- const existingDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
1216
- const deny = Array.from(new Set([...existingDeny, "message"]));
1603
+ const existingTopLevelDeny = Array.isArray((baseTools as any).deny) ? ((baseTools as any).deny as string[]) : [];
1604
+ const existingSandboxDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
1605
+ const topLevelDeny = Array.from(new Set([...existingTopLevelDeny, "message"]));
1606
+ const sandboxDeny = Array.from(new Set([...existingSandboxDeny, "message"]));
1217
1607
  return {
1218
1608
  ...(config as any),
1219
1609
  agents: {
@@ -1237,17 +1627,18 @@ async function startAgentForStream(params: {
1237
1627
  },
1238
1628
  tools: {
1239
1629
  ...baseTools,
1630
+ deny: topLevelDeny,
1240
1631
  sandbox: {
1241
1632
  ...baseSandbox,
1242
1633
  tools: {
1243
1634
  ...baseSandboxTools,
1244
- deny,
1635
+ deny: sandboxDeny,
1245
1636
  },
1246
1637
  },
1247
1638
  },
1248
1639
  } as OpenClawConfig;
1249
1640
  })();
1250
- logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(tools.sandbox.tools.deny += message,防止绕过 Bot 交付)`);
1641
+ logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(tools.deny += message;并同步到 tools.sandbox.tools.deny,防止绕过 Bot 交付)`);
1251
1642
 
1252
1643
  // 调度 Agent 回复
1253
1644
  // 使用 dispatchReplyWithBufferedBlockDispatcher 可以处理流式输出 buffer
@@ -1331,9 +1722,10 @@ async function startAgentForStream(params: {
1331
1722
  if (!current.images) current.images = [];
1332
1723
  if (!current.agentMediaKeys) current.agentMediaKeys = [];
1333
1724
 
1725
+ const deliverKind = info?.kind ?? "block";
1334
1726
  logVerbose(
1335
1727
  target,
1336
- `deliver: kind=${info.kind} chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
1728
+ `deliver: kind=${deliverKind} chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
1337
1729
  );
1338
1730
 
1339
1731
  // If the model referenced a local image path in its reply but did not emit mediaUrl(s),
@@ -1379,7 +1771,7 @@ async function startAgentForStream(params: {
1379
1771
  });
1380
1772
  }
1381
1773
 
1382
- // Timeout fallback (group only): near 6min window, stop bot stream and switch to Agent DM.
1774
+ // Timeout fallback: near 6min window, stop bot stream and switch to Agent DM.
1383
1775
  const now = Date.now();
1384
1776
  const deadline = current.createdAt + BOT_WINDOW_MS;
1385
1777
  const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
@@ -1414,10 +1806,10 @@ async function startAgentForStream(params: {
1414
1806
 
1415
1807
  const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
1416
1808
  for (const mediaPath of mediaUrls) {
1809
+ let contentType: string | undefined;
1810
+ let filename = mediaPath.split("/").pop() || "attachment";
1417
1811
  try {
1418
1812
  let buf: Buffer;
1419
- let contentType: string | undefined;
1420
- let filename: string;
1421
1813
 
1422
1814
  const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
1423
1815
 
@@ -1494,6 +1886,48 @@ async function startAgentForStream(params: {
1494
1886
  }
1495
1887
  } catch (err) {
1496
1888
  target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
1889
+ const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1890
+ const agentOk = Boolean(agentCfg);
1891
+ const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
1892
+ if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
1893
+ try {
1894
+ await sendAgentDmMedia({
1895
+ agent: agentCfg,
1896
+ userId: current.userId,
1897
+ mediaUrlOrPath: mediaPath,
1898
+ contentType,
1899
+ filename: fallbackFilename,
1900
+ });
1901
+ streamStore.updateStream(streamId, (s) => {
1902
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1903
+ });
1904
+ logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
1905
+ } catch (sendErr) {
1906
+ target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
1907
+ }
1908
+ }
1909
+ if (!current.fallbackMode) {
1910
+ const prompt = buildFallbackPrompt({
1911
+ kind: "error",
1912
+ agentConfigured: agentOk,
1913
+ userId: current.userId,
1914
+ filename: fallbackFilename,
1915
+ chatType: current.chatType,
1916
+ });
1917
+ streamStore.updateStream(streamId, (s) => {
1918
+ s.fallbackMode = "error";
1919
+ s.finished = true;
1920
+ s.content = prompt;
1921
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1922
+ });
1923
+ try {
1924
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
1925
+ logVerbose(target, `fallback(error): 群内提示已推送`);
1926
+ } catch (pushErr) {
1927
+ target.runtime.error?.(`wecom bot fallback prompt push failed (error) streamId=${streamId}: ${String(pushErr)}`);
1928
+ }
1929
+ }
1930
+ return;
1497
1931
  }
1498
1932
  }
1499
1933
 
@@ -1532,6 +1966,12 @@ async function startAgentForStream(params: {
1532
1966
  }
1533
1967
  }
1534
1968
 
1969
+ streamStore.updateStream(streamId, (s) => {
1970
+ if (!s.content.trim() && !(s.images?.length ?? 0)) {
1971
+ s.content = "✅ 已处理完成。";
1972
+ }
1973
+ });
1974
+
1535
1975
  streamStore.markFinished(streamId);
1536
1976
 
1537
1977
  // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
@@ -1556,35 +1996,19 @@ async function startAgentForStream(params: {
1556
1996
  }
1557
1997
  }
1558
1998
 
1559
- // Bot 群聊图片兜底:
1560
- // 依赖企业微信的“流式消息刷新”回调来拉取最终消息有时会出现客户端未能及时拉取到最后一帧的情况,
1561
- // 导致最终的图片(msg_item)没有展示。若存在 response_url,则在流结束后主动推送一次最终 stream 回复。
1562
- // 注:该行为以 response_url 是否可用为准;失败则仅记录日志,不影响原有刷新链路。
1563
- if (chatType === "group") {
1564
- const state = streamStore.getStream(streamId);
1565
- const hasImages = Boolean(state?.images?.length);
1566
- const responseUrl = getActiveReplyUrl(streamId);
1567
- if (state && hasImages && responseUrl) {
1568
- const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
1569
- try {
1570
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1571
- const res = await wecomFetch(
1572
- responseUrl,
1573
- {
1574
- method: "POST",
1575
- headers: { "Content-Type": "application/json" },
1576
- body: JSON.stringify(finalReply),
1577
- },
1578
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1579
- );
1580
- if (!res.ok) {
1581
- throw new Error(`final stream push failed: ${res.status}`);
1582
- }
1583
- });
1584
- logVerbose(target, `final stream pushed via response_url (group) streamId=${streamId}, images=${state.images?.length ?? 0}`);
1585
- } catch (err) {
1586
- target.runtime.error?.(`final stream push via response_url failed (group) streamId=${streamId}: ${String(err)}`);
1587
- }
1999
+ // 统一终结:只要 response_url 可用,尽量主动推一次最终流帧,确保“思考中”能及时收口。
2000
+ // 失败仅记录日志,不影响 stream_refresh 被动拉取链路。
2001
+ const stateAfterFinish = streamStore.getStream(streamId);
2002
+ const responseUrl = getActiveReplyUrl(streamId);
2003
+ if (stateAfterFinish && responseUrl) {
2004
+ try {
2005
+ await pushFinalStreamReplyNow(streamId);
2006
+ logVerbose(
2007
+ target,
2008
+ `final stream pushed via response_url streamId=${streamId}, chatType=${chatType}, images=${stateAfterFinish.images?.length ?? 0}`,
2009
+ );
2010
+ } catch (err) {
2011
+ target.runtime.error?.(`final stream push via response_url failed streamId=${streamId}: ${String(err)}`);
1588
2012
  }
1589
2013
  }
1590
2014
 
@@ -1698,7 +2122,7 @@ export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => vo
1698
2122
  *
1699
2123
  * 处理来自企业微信的所有 Webhook 请求。
1700
2124
  * 职责:
1701
- * 1. 路由分发:按 Matrix/Legacy 路径分流 Bot 与 Agent 回调。
2125
+ * 1. 路由分发:优先按 `/plugins/wecom/{bot|agent}/{accountId}` 分流,并兼容历史 `/wecom/*` 路径。
1702
2126
  * 2. 安全校验:验证企业微信签名 (Signature)。
1703
2127
  * 3. 消息解密:处理企业微信的加密包。
1704
2128
  * 4. 响应处理:
@@ -1722,7 +2146,7 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
1722
2146
  `[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}}`,
1723
2147
  );
1724
2148
 
1725
- if (hasMatrixExplicitRoutesRegistered() && isLegacyWecomPath(path)) {
2149
+ if (hasMatrixExplicitRoutesRegistered() && isNonMatrixWecomBasePath(path)) {
1726
2150
  logRouteFailure({
1727
2151
  reqId,
1728
2152
  path,
@@ -1733,14 +2157,19 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
1733
2157
  writeRouteFailure(
1734
2158
  res,
1735
2159
  "wecom_matrix_path_required",
1736
- "Matrix mode requires explicit account path. Use /wecom/bot/{accountId} or /wecom/agent/{accountId}.",
2160
+ "Matrix mode requires explicit account path. Use /plugins/wecom/bot/{accountId} or /plugins/wecom/agent/{accountId}.",
1737
2161
  );
1738
2162
  return true;
1739
2163
  }
1740
2164
 
1741
- const isAgentPath = path === WEBHOOK_PATHS.AGENT || path.startsWith(`${WEBHOOK_PATHS.AGENT}/`);
1742
- if (isAgentPath) {
1743
- const targets = agentTargets.get(path) ?? [];
2165
+ const isAgentPathCandidate =
2166
+ path === WEBHOOK_PATHS.AGENT ||
2167
+ path === WEBHOOK_PATHS.AGENT_PLUGIN ||
2168
+ path.startsWith(`${WEBHOOK_PATHS.AGENT}/`) ||
2169
+ path.startsWith(`${WEBHOOK_PATHS.AGENT_PLUGIN}/`);
2170
+ const matchedAgentTargets = agentTargets.get(path) ?? [];
2171
+ if (matchedAgentTargets.length > 0 || isAgentPathCandidate) {
2172
+ const targets = matchedAgentTargets;
1744
2173
  if (targets.length > 0) {
1745
2174
  const query = resolveQueryParams(req);
1746
2175
  const timestamp = query.get("timestamp") ?? "";
@@ -1916,7 +2345,7 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
1916
2345
  return true;
1917
2346
  }
1918
2347
 
1919
- // Bot 模式路由: /wecom, /wecom/bot
2348
+ // Bot 模式路由: /plugins/wecom/bot(推荐)以及 /wecom、/wecom/bot(兼容)
1920
2349
  const targets = webhookTargets.get(path);
1921
2350
  if (!targets || targets.length === 0) return false;
1922
2351