@marshulll/openclaw-wecom 0.1.8 → 0.1.9
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 +259 -9
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;
|
|
@@ -75,6 +80,67 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
|
|
75
80
|
return slice.toString("utf8");
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
84
|
+
if (!contentType) return fallback;
|
|
85
|
+
if (contentType.includes("png")) return "png";
|
|
86
|
+
if (contentType.includes("gif")) return "gif";
|
|
87
|
+
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
88
|
+
if (contentType.includes("mp4")) return "mp4";
|
|
89
|
+
if (contentType.includes("amr")) return "amr";
|
|
90
|
+
if (contentType.includes("wav")) return "wav";
|
|
91
|
+
if (contentType.includes("mp3")) return "mp3";
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function cleanupMediaDir(
|
|
96
|
+
dir: string,
|
|
97
|
+
retentionHours?: number,
|
|
98
|
+
cleanupOnStart?: boolean,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
if (cleanupOnStart === false) return;
|
|
101
|
+
if (!retentionHours || retentionHours <= 0) return;
|
|
102
|
+
if (cleanupExecuted.has(dir)) return;
|
|
103
|
+
cleanupExecuted.add(dir);
|
|
104
|
+
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
105
|
+
try {
|
|
106
|
+
const entries = await readdir(dir);
|
|
107
|
+
await Promise.all(entries.map(async (entry) => {
|
|
108
|
+
const full = join(dir, entry);
|
|
109
|
+
try {
|
|
110
|
+
const info = await stat(full);
|
|
111
|
+
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
112
|
+
await rm(full, { force: true });
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
124
|
+
return target.account.config.media?.tempDir?.trim()
|
|
125
|
+
|| join(tmpdir(), "openclaw-wecom");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
129
|
+
const maxBytes = target.account.config.media?.maxBytes;
|
|
130
|
+
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sanitizeFilename(name: string, fallback: string): string {
|
|
134
|
+
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
135
|
+
const trimmed = base.trim();
|
|
136
|
+
const safe = trimmed
|
|
137
|
+
.replace(/[^\w.\-() ]+/g, "_")
|
|
138
|
+
.replace(/\s+/g, " ")
|
|
139
|
+
.trim();
|
|
140
|
+
const finalName = safe.slice(0, 120);
|
|
141
|
+
return finalName || fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
78
144
|
function jsonOk(res: ServerResponse, body: unknown): void {
|
|
79
145
|
res.statusCode = 200;
|
|
80
146
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
@@ -254,7 +320,7 @@ async function startAgentForStream(params: {
|
|
|
254
320
|
const userid = msg.from?.userid?.trim() || "unknown";
|
|
255
321
|
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
256
322
|
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
257
|
-
const rawBody = buildInboundBody(msg);
|
|
323
|
+
const rawBody = await buildInboundBody({ target, msg });
|
|
258
324
|
|
|
259
325
|
const route = core.channel.routing.resolveAgentRoute({
|
|
260
326
|
cfg: config,
|
|
@@ -392,7 +458,184 @@ async function startAgentForStream(params: {
|
|
|
392
458
|
}
|
|
393
459
|
}
|
|
394
460
|
|
|
395
|
-
function
|
|
461
|
+
function pickString(...values: unknown[]): string {
|
|
462
|
+
for (const value of values) {
|
|
463
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
464
|
+
}
|
|
465
|
+
return "";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
469
|
+
if (!msg || typeof msg !== "object") return "";
|
|
470
|
+
const block = msg[msgtype] ?? {};
|
|
471
|
+
if (msgtype === "image") {
|
|
472
|
+
return pickString(
|
|
473
|
+
block.url,
|
|
474
|
+
block.picurl,
|
|
475
|
+
block.picUrl,
|
|
476
|
+
block.pic_url,
|
|
477
|
+
msg.picurl,
|
|
478
|
+
msg.picUrl,
|
|
479
|
+
msg.pic_url,
|
|
480
|
+
msg.url,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (msgtype === "voice") {
|
|
484
|
+
return pickString(
|
|
485
|
+
block.url,
|
|
486
|
+
block.fileurl,
|
|
487
|
+
block.fileUrl,
|
|
488
|
+
block.file_url,
|
|
489
|
+
block.mediaUrl,
|
|
490
|
+
block.media_url,
|
|
491
|
+
msg.voiceUrl,
|
|
492
|
+
msg.voice_url,
|
|
493
|
+
msg.url,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (msgtype === "video") {
|
|
497
|
+
return pickString(
|
|
498
|
+
block.url,
|
|
499
|
+
block.fileurl,
|
|
500
|
+
block.fileUrl,
|
|
501
|
+
block.file_url,
|
|
502
|
+
block.mediaUrl,
|
|
503
|
+
block.media_url,
|
|
504
|
+
msg.videoUrl,
|
|
505
|
+
msg.video_url,
|
|
506
|
+
msg.url,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return pickString(
|
|
510
|
+
block.url,
|
|
511
|
+
block.fileurl,
|
|
512
|
+
block.fileUrl,
|
|
513
|
+
block.file_url,
|
|
514
|
+
block.mediaUrl,
|
|
515
|
+
block.media_url,
|
|
516
|
+
msg.fileUrl,
|
|
517
|
+
msg.file_url,
|
|
518
|
+
msg.url,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function resolveBotMediaBase64(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
523
|
+
if (!msg || typeof msg !== "object") return "";
|
|
524
|
+
const block = msg[msgtype] ?? {};
|
|
525
|
+
return pickString(
|
|
526
|
+
block.base64,
|
|
527
|
+
block.data,
|
|
528
|
+
msg.base64,
|
|
529
|
+
msg.data,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function resolveBotMediaFilename(msg: any): string {
|
|
534
|
+
if (!msg || typeof msg !== "object") return "";
|
|
535
|
+
const block = msg.file ?? {};
|
|
536
|
+
return pickString(
|
|
537
|
+
block.filename,
|
|
538
|
+
block.fileName,
|
|
539
|
+
block.name,
|
|
540
|
+
block.file_name,
|
|
541
|
+
msg.filename,
|
|
542
|
+
msg.fileName,
|
|
543
|
+
msg.name,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function buildBotMediaMessage(params: {
|
|
548
|
+
target: WecomWebhookTarget;
|
|
549
|
+
msgtype: "image" | "voice" | "video" | "file";
|
|
550
|
+
url?: string;
|
|
551
|
+
base64?: string;
|
|
552
|
+
filename?: string;
|
|
553
|
+
}): Promise<string> {
|
|
554
|
+
const { target, msgtype, url, base64, filename } = params;
|
|
555
|
+
|
|
556
|
+
const fallbackLabel = msgtype === "image"
|
|
557
|
+
? "[image]"
|
|
558
|
+
: msgtype === "voice"
|
|
559
|
+
? "[voice]"
|
|
560
|
+
: msgtype === "video"
|
|
561
|
+
? "[video]"
|
|
562
|
+
: "[file]";
|
|
563
|
+
|
|
564
|
+
if (!url && !base64) return fallbackLabel;
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
let buffer: Buffer | null = null;
|
|
568
|
+
let contentType = "";
|
|
569
|
+
if (base64) {
|
|
570
|
+
buffer = Buffer.from(base64, "base64");
|
|
571
|
+
contentType = msgtype === "image" ? "image/jpeg" : "application/octet-stream";
|
|
572
|
+
} else if (url) {
|
|
573
|
+
const media = await fetchMediaFromUrl(url, target.account);
|
|
574
|
+
buffer = media.buffer;
|
|
575
|
+
contentType = media.contentType;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!buffer) return fallbackLabel;
|
|
579
|
+
|
|
580
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
581
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
582
|
+
if (msgtype === "image") return "[图片过大,未处理]\n\n请发送更小的图片。";
|
|
583
|
+
if (msgtype === "voice") return "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
|
|
584
|
+
if (msgtype === "video") return "[视频过大,未处理]\n\n请发送更小的视频。";
|
|
585
|
+
return "[文件过大,未处理]\n\n请发送更小的文件。";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const tempDir = resolveMediaTempDir(target);
|
|
589
|
+
await mkdir(tempDir, { recursive: true });
|
|
590
|
+
await cleanupMediaDir(
|
|
591
|
+
tempDir,
|
|
592
|
+
target.account.config.media?.retentionHours,
|
|
593
|
+
target.account.config.media?.cleanupOnStart,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const fallbackExt = msgtype === "image"
|
|
597
|
+
? "jpg"
|
|
598
|
+
: msgtype === "voice"
|
|
599
|
+
? "amr"
|
|
600
|
+
: msgtype === "video"
|
|
601
|
+
? "mp4"
|
|
602
|
+
: "bin";
|
|
603
|
+
const ext = resolveExtFromContentType(contentType, fallbackExt);
|
|
604
|
+
|
|
605
|
+
if (msgtype === "file") {
|
|
606
|
+
const safeName = sanitizeFilename(filename || "", `file-${Date.now()}.${ext}`);
|
|
607
|
+
const tempFilePath = join(tempDir, safeName);
|
|
608
|
+
await writeFile(tempFilePath, buffer);
|
|
609
|
+
return `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请根据文件内容回复用户。`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const tempPath = join(
|
|
613
|
+
tempDir,
|
|
614
|
+
`${msgtype}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`,
|
|
615
|
+
);
|
|
616
|
+
await writeFile(tempPath, buffer);
|
|
617
|
+
|
|
618
|
+
if (msgtype === "image") {
|
|
619
|
+
return `[用户发送了一张图片,已保存到: ${tempPath}]\n\n请使用 Read 工具查看这张图片并描述内容。`;
|
|
620
|
+
}
|
|
621
|
+
if (msgtype === "voice") {
|
|
622
|
+
return `[用户发送了一条语音消息,已保存到: ${tempPath}]\n\n请根据语音内容回复用户。`;
|
|
623
|
+
}
|
|
624
|
+
if (msgtype === "video") {
|
|
625
|
+
return `[用户发送了一个视频文件,已保存到: ${tempPath}]\n\n请根据视频内容回复用户。`;
|
|
626
|
+
}
|
|
627
|
+
return fallbackLabel;
|
|
628
|
+
} catch (err) {
|
|
629
|
+
target.runtime.error?.(`wecom bot ${msgtype} download failed: ${String(err)}`);
|
|
630
|
+
if (msgtype === "image") return "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
631
|
+
if (msgtype === "voice") return "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。";
|
|
632
|
+
if (msgtype === "video") return "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
|
|
633
|
+
return "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function buildInboundBody(params: { target: WecomWebhookTarget; msg: WecomInboundMessage }): Promise<string> {
|
|
638
|
+
const { target, msg } = params;
|
|
396
639
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
397
640
|
if (msgtype === "text") {
|
|
398
641
|
const content = (msg as any).text?.content;
|
|
@@ -400,7 +643,10 @@ function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
400
643
|
}
|
|
401
644
|
if (msgtype === "voice") {
|
|
402
645
|
const content = (msg as any).voice?.content;
|
|
403
|
-
|
|
646
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
647
|
+
const url = resolveBotMediaUrl(msg as any, "voice");
|
|
648
|
+
const base64 = resolveBotMediaBase64(msg as any, "voice");
|
|
649
|
+
return await buildBotMediaMessage({ target, msgtype: "voice", url, base64 });
|
|
404
650
|
}
|
|
405
651
|
if (msgtype === "mixed") {
|
|
406
652
|
const items = (msg as any).mixed?.msg_item;
|
|
@@ -418,16 +664,20 @@ function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
418
664
|
return "[mixed]";
|
|
419
665
|
}
|
|
420
666
|
if (msgtype === "image") {
|
|
421
|
-
const url =
|
|
422
|
-
|
|
667
|
+
const url = resolveBotMediaUrl(msg as any, "image");
|
|
668
|
+
const base64 = resolveBotMediaBase64(msg as any, "image");
|
|
669
|
+
return await buildBotMediaMessage({ target, msgtype: "image", url, base64 });
|
|
423
670
|
}
|
|
424
671
|
if (msgtype === "file") {
|
|
425
|
-
const url =
|
|
426
|
-
|
|
672
|
+
const url = resolveBotMediaUrl(msg as any, "file");
|
|
673
|
+
const base64 = resolveBotMediaBase64(msg as any, "file");
|
|
674
|
+
const filename = resolveBotMediaFilename(msg as any);
|
|
675
|
+
return await buildBotMediaMessage({ target, msgtype: "file", url, base64, filename });
|
|
427
676
|
}
|
|
428
677
|
if (msgtype === "video") {
|
|
429
|
-
const url =
|
|
430
|
-
|
|
678
|
+
const url = resolveBotMediaUrl(msg as any, "video");
|
|
679
|
+
const base64 = resolveBotMediaBase64(msg as any, "video");
|
|
680
|
+
return await buildBotMediaMessage({ target, msgtype: "video", url, base64 });
|
|
431
681
|
}
|
|
432
682
|
if (msgtype === "event") {
|
|
433
683
|
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|