@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -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 buildInboundBody(msg: WecomInboundMessage): string {
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
- return typeof content === "string" ? content : "[voice]";
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 = String((msg as any).image?.url ?? "").trim();
422
- return url ? `[image] ${url}` : "[image]";
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 = String((msg as any).file?.url ?? "").trim();
426
- return url ? `[file] ${url}` : "[file]";
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 = String((msg as any).video?.url ?? "").trim();
430
- return url ? `[video] ${url}` : "[video]";
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();