@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/README.md +7 -7
- package/assets/register.png +0 -0
- package/changelog/v2.3.2.md +70 -0
- package/package.json +1 -1
- package/src/agent/api-client.ts +76 -34
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.ts +4 -4
- package/src/config/network.ts +9 -5
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor.active.test.ts +7 -4
- package/src/monitor.ts +486 -74
- package/src/outbound.ts +6 -0
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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:
|
|
678
|
-
contentType:
|
|
679
|
-
filename:
|
|
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
|
-
|
|
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
|
|
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:
|
|
701
|
-
contentType:
|
|
702
|
-
filename:
|
|
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
|
-
|
|
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
|
|
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:
|
|
734
|
-
contentType:
|
|
735
|
-
filename:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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: "
|
|
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
|
|
1216
|
-
const
|
|
1591
|
+
const existingTopLevelDeny = Array.isArray((baseTools as any).deny) ? ((baseTools as any).deny as string[]) : [];
|
|
1592
|
+
const existingSandboxDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
|
|
1593
|
+
const topLevelDeny = Array.from(new Set([...existingTopLevelDeny, "message"]));
|
|
1594
|
+
const sandboxDeny = Array.from(new Set([...existingSandboxDeny, "message"]));
|
|
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
|
|
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=${
|
|
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
|
|
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
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
|