@marshulll/openclaw-wecom 0.1.8 → 0.1.10
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/package.json +1 -1
- package/wecom/src/wecom-bot.ts +301 -15
package/package.json
CHANGED
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
|
|
4
7
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
5
8
|
|
|
@@ -15,6 +18,8 @@ const STREAM_MAX_ENTRIES = 500;
|
|
|
15
18
|
const DEDUPE_TTL_MS = 2 * 60 * 1000;
|
|
16
19
|
const DEDUPE_MAX_ENTRIES = 2_000;
|
|
17
20
|
|
|
21
|
+
const cleanupExecuted = new Set<string>();
|
|
22
|
+
|
|
18
23
|
type StreamState = {
|
|
19
24
|
streamId: string;
|
|
20
25
|
msgid?: string;
|
|
@@ -26,6 +31,17 @@ type StreamState = {
|
|
|
26
31
|
content: string;
|
|
27
32
|
};
|
|
28
33
|
|
|
34
|
+
type InboundMedia = {
|
|
35
|
+
path: string;
|
|
36
|
+
type: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type InboundBody = {
|
|
41
|
+
text: string;
|
|
42
|
+
media?: InboundMedia;
|
|
43
|
+
};
|
|
44
|
+
|
|
29
45
|
const streams = new Map<string, StreamState>();
|
|
30
46
|
const msgidToStreamId = new Map<string, string>();
|
|
31
47
|
const recentEncrypts = new Map<string, { ts: number; streamId?: string }>();
|
|
@@ -75,6 +91,67 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
|
|
75
91
|
return slice.toString("utf8");
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
95
|
+
if (!contentType) return fallback;
|
|
96
|
+
if (contentType.includes("png")) return "png";
|
|
97
|
+
if (contentType.includes("gif")) return "gif";
|
|
98
|
+
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
99
|
+
if (contentType.includes("mp4")) return "mp4";
|
|
100
|
+
if (contentType.includes("amr")) return "amr";
|
|
101
|
+
if (contentType.includes("wav")) return "wav";
|
|
102
|
+
if (contentType.includes("mp3")) return "mp3";
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function cleanupMediaDir(
|
|
107
|
+
dir: string,
|
|
108
|
+
retentionHours?: number,
|
|
109
|
+
cleanupOnStart?: boolean,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
if (cleanupOnStart === false) return;
|
|
112
|
+
if (!retentionHours || retentionHours <= 0) return;
|
|
113
|
+
if (cleanupExecuted.has(dir)) return;
|
|
114
|
+
cleanupExecuted.add(dir);
|
|
115
|
+
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
116
|
+
try {
|
|
117
|
+
const entries = await readdir(dir);
|
|
118
|
+
await Promise.all(entries.map(async (entry) => {
|
|
119
|
+
const full = join(dir, entry);
|
|
120
|
+
try {
|
|
121
|
+
const info = await stat(full);
|
|
122
|
+
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
123
|
+
await rm(full, { force: true });
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
}));
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
135
|
+
return target.account.config.media?.tempDir?.trim()
|
|
136
|
+
|| join(tmpdir(), "openclaw-wecom");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
140
|
+
const maxBytes = target.account.config.media?.maxBytes;
|
|
141
|
+
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sanitizeFilename(name: string, fallback: string): string {
|
|
145
|
+
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
146
|
+
const trimmed = base.trim();
|
|
147
|
+
const safe = trimmed
|
|
148
|
+
.replace(/[^\w.\-() ]+/g, "_")
|
|
149
|
+
.replace(/\s+/g, " ")
|
|
150
|
+
.trim();
|
|
151
|
+
const finalName = safe.slice(0, 120);
|
|
152
|
+
return finalName || fallback;
|
|
153
|
+
}
|
|
154
|
+
|
|
78
155
|
function jsonOk(res: ServerResponse, body: unknown): void {
|
|
79
156
|
res.statusCode = 200;
|
|
80
157
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
@@ -254,7 +331,8 @@ async function startAgentForStream(params: {
|
|
|
254
331
|
const userid = msg.from?.userid?.trim() || "unknown";
|
|
255
332
|
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
256
333
|
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
257
|
-
const
|
|
334
|
+
const inbound = await buildInboundBody({ target, msg });
|
|
335
|
+
const rawBody = inbound.text;
|
|
258
336
|
|
|
259
337
|
const route = core.channel.routing.resolveAgentRoute({
|
|
260
338
|
cfg: config,
|
|
@@ -301,6 +379,14 @@ async function startAgentForStream(params: {
|
|
|
301
379
|
OriginatingTo: `wecom:${chatId}`,
|
|
302
380
|
});
|
|
303
381
|
|
|
382
|
+
if (inbound.media) {
|
|
383
|
+
ctxPayload.MediaPath = inbound.media.path;
|
|
384
|
+
ctxPayload.MediaType = inbound.media.type;
|
|
385
|
+
if (inbound.media.url) {
|
|
386
|
+
ctxPayload.MediaUrl = inbound.media.url;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
304
390
|
await core.channel.session.recordInboundSession({
|
|
305
391
|
storePath,
|
|
306
392
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
@@ -392,20 +478,215 @@ async function startAgentForStream(params: {
|
|
|
392
478
|
}
|
|
393
479
|
}
|
|
394
480
|
|
|
395
|
-
function
|
|
481
|
+
function pickString(...values: unknown[]): string {
|
|
482
|
+
for (const value of values) {
|
|
483
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
484
|
+
}
|
|
485
|
+
return "";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
489
|
+
if (!msg || typeof msg !== "object") return "";
|
|
490
|
+
const block = msg[msgtype] ?? {};
|
|
491
|
+
if (msgtype === "image") {
|
|
492
|
+
return pickString(
|
|
493
|
+
block.url,
|
|
494
|
+
block.picurl,
|
|
495
|
+
block.picUrl,
|
|
496
|
+
block.pic_url,
|
|
497
|
+
msg.picurl,
|
|
498
|
+
msg.picUrl,
|
|
499
|
+
msg.pic_url,
|
|
500
|
+
msg.url,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (msgtype === "voice") {
|
|
504
|
+
return pickString(
|
|
505
|
+
block.url,
|
|
506
|
+
block.fileurl,
|
|
507
|
+
block.fileUrl,
|
|
508
|
+
block.file_url,
|
|
509
|
+
block.mediaUrl,
|
|
510
|
+
block.media_url,
|
|
511
|
+
msg.voiceUrl,
|
|
512
|
+
msg.voice_url,
|
|
513
|
+
msg.url,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (msgtype === "video") {
|
|
517
|
+
return pickString(
|
|
518
|
+
block.url,
|
|
519
|
+
block.fileurl,
|
|
520
|
+
block.fileUrl,
|
|
521
|
+
block.file_url,
|
|
522
|
+
block.mediaUrl,
|
|
523
|
+
block.media_url,
|
|
524
|
+
msg.videoUrl,
|
|
525
|
+
msg.video_url,
|
|
526
|
+
msg.url,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return pickString(
|
|
530
|
+
block.url,
|
|
531
|
+
block.fileurl,
|
|
532
|
+
block.fileUrl,
|
|
533
|
+
block.file_url,
|
|
534
|
+
block.mediaUrl,
|
|
535
|
+
block.media_url,
|
|
536
|
+
msg.fileUrl,
|
|
537
|
+
msg.file_url,
|
|
538
|
+
msg.url,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function resolveBotMediaBase64(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
543
|
+
if (!msg || typeof msg !== "object") return "";
|
|
544
|
+
const block = msg[msgtype] ?? {};
|
|
545
|
+
return pickString(
|
|
546
|
+
block.base64,
|
|
547
|
+
block.data,
|
|
548
|
+
msg.base64,
|
|
549
|
+
msg.data,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function resolveBotMediaFilename(msg: any): string {
|
|
554
|
+
if (!msg || typeof msg !== "object") return "";
|
|
555
|
+
const block = msg.file ?? {};
|
|
556
|
+
return pickString(
|
|
557
|
+
block.filename,
|
|
558
|
+
block.fileName,
|
|
559
|
+
block.name,
|
|
560
|
+
block.file_name,
|
|
561
|
+
msg.filename,
|
|
562
|
+
msg.fileName,
|
|
563
|
+
msg.name,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function buildBotMediaMessage(params: {
|
|
568
|
+
target: WecomWebhookTarget;
|
|
569
|
+
msgtype: "image" | "voice" | "video" | "file";
|
|
570
|
+
url?: string;
|
|
571
|
+
base64?: string;
|
|
572
|
+
filename?: string;
|
|
573
|
+
}): Promise<InboundBody> {
|
|
574
|
+
const { target, msgtype, url, base64, filename } = params;
|
|
575
|
+
|
|
576
|
+
const fallbackLabel = msgtype === "image"
|
|
577
|
+
? "[image]"
|
|
578
|
+
: msgtype === "voice"
|
|
579
|
+
? "[voice]"
|
|
580
|
+
: msgtype === "video"
|
|
581
|
+
? "[video]"
|
|
582
|
+
: "[file]";
|
|
583
|
+
|
|
584
|
+
if (!url && !base64) return { text: fallbackLabel };
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
let buffer: Buffer | null = null;
|
|
588
|
+
let contentType = "";
|
|
589
|
+
if (base64) {
|
|
590
|
+
buffer = Buffer.from(base64, "base64");
|
|
591
|
+
if (msgtype === "image") contentType = "image/jpeg";
|
|
592
|
+
else if (msgtype === "voice") contentType = "audio/amr";
|
|
593
|
+
else if (msgtype === "video") contentType = "video/mp4";
|
|
594
|
+
else contentType = "application/octet-stream";
|
|
595
|
+
} else if (url) {
|
|
596
|
+
const media = await fetchMediaFromUrl(url, target.account);
|
|
597
|
+
buffer = media.buffer;
|
|
598
|
+
contentType = media.contentType;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!buffer) return { text: fallbackLabel };
|
|
602
|
+
|
|
603
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
604
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
605
|
+
if (msgtype === "image") return { text: "[图片过大,未处理]\n\n请发送更小的图片。" };
|
|
606
|
+
if (msgtype === "voice") return { text: "[语音消息过大,未处理]\n\n请发送更短的语音消息。" };
|
|
607
|
+
if (msgtype === "video") return { text: "[视频过大,未处理]\n\n请发送更小的视频。" };
|
|
608
|
+
return { text: "[文件过大,未处理]\n\n请发送更小的文件。" };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const tempDir = resolveMediaTempDir(target);
|
|
612
|
+
await mkdir(tempDir, { recursive: true });
|
|
613
|
+
await cleanupMediaDir(
|
|
614
|
+
tempDir,
|
|
615
|
+
target.account.config.media?.retentionHours,
|
|
616
|
+
target.account.config.media?.cleanupOnStart,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const fallbackExt = msgtype === "image"
|
|
620
|
+
? "jpg"
|
|
621
|
+
: msgtype === "voice"
|
|
622
|
+
? "amr"
|
|
623
|
+
: msgtype === "video"
|
|
624
|
+
? "mp4"
|
|
625
|
+
: "bin";
|
|
626
|
+
const ext = resolveExtFromContentType(contentType, fallbackExt);
|
|
627
|
+
|
|
628
|
+
if (msgtype === "file") {
|
|
629
|
+
const safeName = sanitizeFilename(filename || "", `file-${Date.now()}.${ext}`);
|
|
630
|
+
const tempFilePath = join(tempDir, safeName);
|
|
631
|
+
await writeFile(tempFilePath, buffer);
|
|
632
|
+
return {
|
|
633
|
+
text: `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请根据文件内容回复用户。`,
|
|
634
|
+
media: { path: tempFilePath, type: contentType || "application/octet-stream", url },
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const tempPath = join(
|
|
639
|
+
tempDir,
|
|
640
|
+
`${msgtype}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`,
|
|
641
|
+
);
|
|
642
|
+
await writeFile(tempPath, buffer);
|
|
643
|
+
|
|
644
|
+
if (msgtype === "image") {
|
|
645
|
+
return {
|
|
646
|
+
text: `[用户发送了一张图片,已保存到: ${tempPath}]\n\n请使用 Read 工具查看这张图片并描述内容。`,
|
|
647
|
+
media: { path: tempPath, type: contentType || "image/jpeg", url },
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (msgtype === "voice") {
|
|
651
|
+
return {
|
|
652
|
+
text: `[用户发送了一条语音消息,已保存到: ${tempPath}]\n\n请根据语音内容回复用户。`,
|
|
653
|
+
media: { path: tempPath, type: contentType || "audio/amr", url },
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (msgtype === "video") {
|
|
657
|
+
return {
|
|
658
|
+
text: `[用户发送了一个视频文件,已保存到: ${tempPath}]\n\n请根据视频内容回复用户。`,
|
|
659
|
+
media: { path: tempPath, type: contentType || "video/mp4", url },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
return { text: fallbackLabel };
|
|
663
|
+
} catch (err) {
|
|
664
|
+
target.runtime.error?.(`wecom bot ${msgtype} download failed: ${String(err)}`);
|
|
665
|
+
if (msgtype === "image") return { text: "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。" };
|
|
666
|
+
if (msgtype === "voice") return { text: "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。" };
|
|
667
|
+
if (msgtype === "video") return { text: "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。" };
|
|
668
|
+
return { text: "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。" };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function buildInboundBody(params: { target: WecomWebhookTarget; msg: WecomInboundMessage }): Promise<InboundBody> {
|
|
673
|
+
const { target, msg } = params;
|
|
396
674
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
397
675
|
if (msgtype === "text") {
|
|
398
676
|
const content = (msg as any).text?.content;
|
|
399
|
-
return typeof content === "string" ? content : "";
|
|
677
|
+
return { text: typeof content === "string" ? content : "" };
|
|
400
678
|
}
|
|
401
679
|
if (msgtype === "voice") {
|
|
402
680
|
const content = (msg as any).voice?.content;
|
|
403
|
-
|
|
681
|
+
if (typeof content === "string" && content.trim()) return { text: content.trim() };
|
|
682
|
+
const url = resolveBotMediaUrl(msg as any, "voice");
|
|
683
|
+
const base64 = resolveBotMediaBase64(msg as any, "voice");
|
|
684
|
+
return await buildBotMediaMessage({ target, msgtype: "voice", url, base64 });
|
|
404
685
|
}
|
|
405
686
|
if (msgtype === "mixed") {
|
|
406
687
|
const items = (msg as any).mixed?.msg_item;
|
|
407
688
|
if (Array.isArray(items)) {
|
|
408
|
-
|
|
689
|
+
const text = items
|
|
409
690
|
.map((item: any) => {
|
|
410
691
|
const t = String(item?.msgtype ?? "").toLowerCase();
|
|
411
692
|
if (t === "text") return String(item?.text?.content ?? "");
|
|
@@ -414,30 +695,35 @@ function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
414
695
|
})
|
|
415
696
|
.filter((part: string) => Boolean(part && part.trim()))
|
|
416
697
|
.join("\n");
|
|
698
|
+
return { text };
|
|
417
699
|
}
|
|
418
|
-
return "[mixed]";
|
|
700
|
+
return { text: "[mixed]" };
|
|
419
701
|
}
|
|
420
702
|
if (msgtype === "image") {
|
|
421
|
-
const url =
|
|
422
|
-
|
|
703
|
+
const url = resolveBotMediaUrl(msg as any, "image");
|
|
704
|
+
const base64 = resolveBotMediaBase64(msg as any, "image");
|
|
705
|
+
return await buildBotMediaMessage({ target, msgtype: "image", url, base64 });
|
|
423
706
|
}
|
|
424
707
|
if (msgtype === "file") {
|
|
425
|
-
const url =
|
|
426
|
-
|
|
708
|
+
const url = resolveBotMediaUrl(msg as any, "file");
|
|
709
|
+
const base64 = resolveBotMediaBase64(msg as any, "file");
|
|
710
|
+
const filename = resolveBotMediaFilename(msg as any);
|
|
711
|
+
return await buildBotMediaMessage({ target, msgtype: "file", url, base64, filename });
|
|
427
712
|
}
|
|
428
713
|
if (msgtype === "video") {
|
|
429
|
-
const url =
|
|
430
|
-
|
|
714
|
+
const url = resolveBotMediaUrl(msg as any, "video");
|
|
715
|
+
const base64 = resolveBotMediaBase64(msg as any, "video");
|
|
716
|
+
return await buildBotMediaMessage({ target, msgtype: "video", url, base64 });
|
|
431
717
|
}
|
|
432
718
|
if (msgtype === "event") {
|
|
433
719
|
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|
|
434
|
-
return eventtype ? `[event] ${eventtype}` : "[event]";
|
|
720
|
+
return { text: eventtype ? `[event] ${eventtype}` : "[event]" };
|
|
435
721
|
}
|
|
436
722
|
if (msgtype === "stream") {
|
|
437
723
|
const id = String((msg as any).stream?.id ?? "").trim();
|
|
438
|
-
return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
|
|
724
|
+
return { text: id ? `[stream_refresh] ${id}` : "[stream_refresh]" };
|
|
439
725
|
}
|
|
440
|
-
return msgtype ? `[${msgtype}]` : "";
|
|
726
|
+
return { text: msgtype ? `[${msgtype}]` : "" };
|
|
441
727
|
}
|
|
442
728
|
|
|
443
729
|
function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
|