@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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;
@@ -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 rawBody = buildInboundBody(msg);
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 buildInboundBody(msg: WecomInboundMessage): string {
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
- return typeof content === "string" ? content : "[voice]";
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
- return items
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 = String((msg as any).image?.url ?? "").trim();
422
- return url ? `[image] ${url}` : "[image]";
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 = String((msg as any).file?.url ?? "").trim();
426
- return url ? `[file] ${url}` : "[file]";
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 = String((msg as any).video?.url ?? "").trim();
430
- return url ? `[video] ${url}` : "[video]";
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 {