@yanhaidao/wecom 2.2.28 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -437,6 +437,27 @@ async function sendBotFallbackPromptNow(params: { streamId: string; text: string
437
437
  });
438
438
  }
439
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
+
440
461
  async function sendAgentDmText(params: {
441
462
  agent: ResolvedAgentAccount;
442
463
  userId: string;
@@ -500,10 +521,10 @@ function extractLocalImagePathsFromText(params: {
500
521
  const mustAlsoAppearIn = params.mustAlsoAppearIn;
501
522
  if (!text.trim()) return [];
502
523
 
503
- // Conservative: only accept common macOS absolute paths for images.
524
+ // Conservative: only accept common absolute paths for macOS/Linux hosts.
504
525
  // Also require that the exact path appeared in the user's original message to prevent exfil.
505
526
  const exts = "(png|jpg|jpeg|gif|webp|bmp)";
506
- 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");
507
528
  const found = new Set<string>();
508
529
  let m: RegExpExecArray | null;
509
530
  while ((m = re.exec(text))) {
@@ -518,9 +539,9 @@ function extractLocalImagePathsFromText(params: {
518
539
  function extractLocalFilePathsFromText(text: string): string[] {
519
540
  if (!text.trim()) return [];
520
541
 
521
- // Conservative: only accept common macOS absolute paths.
542
+ // Conservative: only accept common absolute paths for macOS/Linux hosts.
522
543
  // This is primarily for “send local file” style requests (operator/debug usage).
523
- const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+)`, "g");
544
+ const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+)`, "g");
524
545
  const found = new Set<string>();
525
546
  let m: RegExpExecArray | null;
526
547
  while ((m = re.exec(text))) {
@@ -531,23 +552,287 @@ function extractLocalFilePathsFromText(text: string): string[] {
531
552
  return Array.from(found);
532
553
  }
533
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
+
534
616
  function guessContentTypeFromPath(filePath: string): string | undefined {
535
617
  const ext = filePath.split(".").pop()?.toLowerCase();
536
618
  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];
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 };
551
836
  }
552
837
 
553
838
  function looksLikeSendLocalFileIntent(rawBody: string): boolean {
@@ -670,13 +955,21 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
670
955
  const url = String((msg as any).image?.url ?? "").trim();
671
956
  if (url && aesKey) {
672
957
  try {
673
- 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
+ });
674
967
  return {
675
968
  body: "[image]",
676
969
  media: {
677
- buffer: buf,
678
- contentType: "image/jpeg", // WeCom images are usually generic; safest assumption or could act as generic
679
- filename: "image.jpg",
970
+ buffer: decrypted.buffer,
971
+ contentType: inferred.contentType,
972
+ filename: inferred.filename,
680
973
  }
681
974
  };
682
975
  } catch (err) {
@@ -684,7 +977,10 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
684
977
  target.runtime.error?.(
685
978
  `图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
686
979
  );
687
- 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})` };
688
984
  }
689
985
  }
690
986
  }
@@ -693,20 +989,31 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
693
989
  const url = String((msg as any).file?.url ?? "").trim();
694
990
  if (url && aesKey) {
695
991
  try {
696
- 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
+ });
697
1001
  return {
698
1002
  body: "[file]",
699
1003
  media: {
700
- buffer: buf,
701
- contentType: "application/octet-stream",
702
- 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,
703
1007
  }
704
1008
  };
705
1009
  } catch (err) {
706
1010
  target.runtime.error?.(
707
1011
  `Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
708
1012
  );
709
- 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})` };
710
1017
  }
711
1018
  }
712
1019
  }
@@ -728,18 +1035,29 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
728
1035
  const url = String(item[t]?.url ?? "").trim();
729
1036
  if (url) {
730
1037
  try {
731
- 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
+ });
732
1047
  foundMedia = {
733
- buffer: buf,
734
- contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
735
- filename: t === "image" ? "image.jpg" : "file.bin"
1048
+ buffer: decrypted.buffer,
1049
+ contentType: inferred.contentType,
1050
+ filename: inferred.filename,
736
1051
  };
737
1052
  bodyParts.push(`[${t}]`);
738
1053
  } catch (err) {
739
1054
  target.runtime.error?.(
740
1055
  `Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
741
1056
  );
742
- 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})`);
743
1061
  }
744
1062
  } else {
745
1063
  bodyParts.push(`[${t}]`);
@@ -965,6 +1283,60 @@ async function startAgentForStream(params: {
965
1283
  streamStore.onStreamFinished(streamId);
966
1284
  return;
967
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;
968
1340
  }
969
1341
 
970
1342
  // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
@@ -1008,18 +1380,22 @@ async function startAgentForStream(params: {
1008
1380
  for (const p of otherPaths) {
1009
1381
  const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
1010
1382
  if (alreadySent) continue;
1383
+ const guessedType = guessContentTypeFromPath(p);
1011
1384
  try {
1012
1385
  await sendAgentDmMedia({
1013
1386
  agent: agentCfg,
1014
1387
  userId: userid,
1015
1388
  mediaUrlOrPath: p,
1016
- contentType: guessContentTypeFromPath(p),
1389
+ contentType: guessedType,
1017
1390
  filename: p.split("/").pop() || "file",
1018
1391
  });
1019
1392
  streamStore.updateStream(streamId, (s) => {
1020
1393
  s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
1021
1394
  });
1022
- 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
+ );
1023
1399
  } catch (err) {
1024
1400
  target.runtime.error?.(`local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
1025
1401
  }
@@ -1173,7 +1549,7 @@ async function startAgentForStream(params: {
1173
1549
  SenderName: userid,
1174
1550
  SenderId: userid,
1175
1551
  Provider: "wecom",
1176
- Surface: "wecom",
1552
+ Surface: "webchat",
1177
1553
  MessageSid: msg.msgid,
1178
1554
  CommandAuthorized: commandAuthorized,
1179
1555
  OriginatingChannel: "wecom",
@@ -1212,8 +1588,10 @@ async function startAgentForStream(params: {
1212
1588
  const baseTools = (config as any)?.tools ?? {};
1213
1589
  const baseSandbox = (baseTools as any)?.sandbox ?? {};
1214
1590
  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"]));
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"]));
1217
1595
  return {
1218
1596
  ...(config as any),
1219
1597
  agents: {
@@ -1237,17 +1615,18 @@ async function startAgentForStream(params: {
1237
1615
  },
1238
1616
  tools: {
1239
1617
  ...baseTools,
1618
+ deny: topLevelDeny,
1240
1619
  sandbox: {
1241
1620
  ...baseSandbox,
1242
1621
  tools: {
1243
1622
  ...baseSandboxTools,
1244
- deny,
1623
+ deny: sandboxDeny,
1245
1624
  },
1246
1625
  },
1247
1626
  },
1248
1627
  } as OpenClawConfig;
1249
1628
  })();
1250
- 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 交付)`);
1251
1630
 
1252
1631
  // 调度 Agent 回复
1253
1632
  // 使用 dispatchReplyWithBufferedBlockDispatcher 可以处理流式输出 buffer
@@ -1331,9 +1710,10 @@ async function startAgentForStream(params: {
1331
1710
  if (!current.images) current.images = [];
1332
1711
  if (!current.agentMediaKeys) current.agentMediaKeys = [];
1333
1712
 
1713
+ const deliverKind = info?.kind ?? "block";
1334
1714
  logVerbose(
1335
1715
  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)}`,
1716
+ `deliver: kind=${deliverKind} chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
1337
1717
  );
1338
1718
 
1339
1719
  // If the model referenced a local image path in its reply but did not emit mediaUrl(s),
@@ -1379,7 +1759,7 @@ async function startAgentForStream(params: {
1379
1759
  });
1380
1760
  }
1381
1761
 
1382
- // 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.
1383
1763
  const now = Date.now();
1384
1764
  const deadline = current.createdAt + BOT_WINDOW_MS;
1385
1765
  const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
@@ -1414,10 +1794,10 @@ async function startAgentForStream(params: {
1414
1794
 
1415
1795
  const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
1416
1796
  for (const mediaPath of mediaUrls) {
1797
+ let contentType: string | undefined;
1798
+ let filename = mediaPath.split("/").pop() || "attachment";
1417
1799
  try {
1418
1800
  let buf: Buffer;
1419
- let contentType: string | undefined;
1420
- let filename: string;
1421
1801
 
1422
1802
  const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
1423
1803
 
@@ -1494,6 +1874,48 @@ async function startAgentForStream(params: {
1494
1874
  }
1495
1875
  } catch (err) {
1496
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;
1497
1919
  }
1498
1920
  }
1499
1921
 
@@ -1532,6 +1954,12 @@ async function startAgentForStream(params: {
1532
1954
  }
1533
1955
  }
1534
1956
 
1957
+ streamStore.updateStream(streamId, (s) => {
1958
+ if (!s.content.trim() && !(s.images?.length ?? 0)) {
1959
+ s.content = "✅ 已处理完成。";
1960
+ }
1961
+ });
1962
+
1535
1963
  streamStore.markFinished(streamId);
1536
1964
 
1537
1965
  // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
@@ -1556,35 +1984,19 @@ async function startAgentForStream(params: {
1556
1984
  }
1557
1985
  }
1558
1986
 
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
- }
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)}`);
1588
2000
  }
1589
2001
  }
1590
2002