@marshulll/openclaw-wecom 0.1.7 → 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/README.en.md +3 -0
- package/README.md +3 -0
- package/README.zh.md +3 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/wecom/src/wecom-bot.ts +259 -9
package/README.en.md
CHANGED
|
@@ -21,6 +21,7 @@ openclaw plugins install @marshulll/openclaw-wecom
|
|
|
21
21
|
openclaw plugins enable openclaw-wecom
|
|
22
22
|
openclaw gateway restart
|
|
23
23
|
```
|
|
24
|
+
> The npm package **bundles dependencies** (no extra `npm install` on the server).
|
|
24
25
|
|
|
25
26
|
### Local path
|
|
26
27
|
```bash
|
|
@@ -28,6 +29,7 @@ openclaw plugins install --link /path/to/openclaw-wecom
|
|
|
28
29
|
openclaw plugins enable openclaw-wecom
|
|
29
30
|
openclaw gateway restart
|
|
30
31
|
```
|
|
32
|
+
> For local path installs, run `npm install` in the project directory first.
|
|
31
33
|
|
|
32
34
|
## Configuration
|
|
33
35
|
Write config to `~/.openclaw/openclaw.json`.
|
|
@@ -91,6 +93,7 @@ Install guide: `docs/INSTALL.md`
|
|
|
91
93
|
- No reply: ensure plugin enabled and gateway restarted
|
|
92
94
|
- Media too large: adjust `media.maxBytes` or send smaller files
|
|
93
95
|
- invalid access_token: verify `corpId/corpSecret/agentId`
|
|
96
|
+
- Plugin failed to load due to missing deps: upgrade to latest and install via npm
|
|
94
97
|
|
|
95
98
|
## Docs
|
|
96
99
|
- Dev doc: `docs/TECHNICAL.md`
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ openclaw plugins install @marshulll/openclaw-wecom
|
|
|
21
21
|
openclaw plugins enable openclaw-wecom
|
|
22
22
|
openclaw gateway restart
|
|
23
23
|
```
|
|
24
|
+
> npm 安装包已**内置依赖**(无需再在服务器执行 `npm install`)。
|
|
24
25
|
|
|
25
26
|
### 本地路径加载
|
|
26
27
|
```bash
|
|
@@ -28,6 +29,7 @@ openclaw plugins install --link /path/to/openclaw-wecom
|
|
|
28
29
|
openclaw plugins enable openclaw-wecom
|
|
29
30
|
openclaw gateway restart
|
|
30
31
|
```
|
|
32
|
+
> 本地路径加载需要先在项目目录执行 `npm install`。
|
|
31
33
|
|
|
32
34
|
## 配置
|
|
33
35
|
主配置写入:`~/.openclaw/openclaw.json`
|
|
@@ -91,6 +93,7 @@ openclaw gateway restart
|
|
|
91
93
|
- 没有回复:确认已启用插件并重启 gateway
|
|
92
94
|
- 媒体过大:调整 `media.maxBytes` 或发送更小文件
|
|
93
95
|
- invalid access_token:检查 `corpId/corpSecret/agentId`
|
|
96
|
+
- 依赖缺失导致插件未加载:请升级到最新版本并通过 npm 安装
|
|
94
97
|
|
|
95
98
|
## 资料入口
|
|
96
99
|
- 开发文档:`docs/TECHNICAL.md`
|
package/README.zh.md
CHANGED
|
@@ -21,6 +21,7 @@ openclaw plugins install @marshulll/openclaw-wecom
|
|
|
21
21
|
openclaw plugins enable openclaw-wecom
|
|
22
22
|
openclaw gateway restart
|
|
23
23
|
```
|
|
24
|
+
> npm 安装包已**内置依赖**(无需再在服务器执行 `npm install`)。
|
|
24
25
|
|
|
25
26
|
### 本地路径加载
|
|
26
27
|
```bash
|
|
@@ -28,6 +29,7 @@ openclaw plugins install --link /path/to/openclaw-wecom
|
|
|
28
29
|
openclaw plugins enable openclaw-wecom
|
|
29
30
|
openclaw gateway restart
|
|
30
31
|
```
|
|
32
|
+
> 本地路径加载需要先在项目目录执行 `npm install`。
|
|
31
33
|
|
|
32
34
|
## 配置
|
|
33
35
|
主配置写入:`~/.openclaw/openclaw.json`
|
|
@@ -91,6 +93,7 @@ openclaw gateway restart
|
|
|
91
93
|
- 没有回复:确认已启用插件并重启 gateway
|
|
92
94
|
- 媒体过大:调整 `media.maxBytes` 或发送更小文件
|
|
93
95
|
- invalid access_token:检查 `corpId/corpSecret/agentId`
|
|
96
|
+
- 依赖缺失导致插件未加载:请升级到最新版本并通过 npm 安装
|
|
94
97
|
|
|
95
98
|
## 资料入口
|
|
96
99
|
- 开发文档:`docs/TECHNICAL.md`
|
package/docs/INSTALL.md
CHANGED
|
@@ -8,6 +8,7 @@ openclaw plugins install @marshulll/openclaw-wecom
|
|
|
8
8
|
openclaw plugins enable openclaw-wecom
|
|
9
9
|
openclaw gateway restart
|
|
10
10
|
```
|
|
11
|
+
> npm 包已内置依赖(无需在服务器额外执行 `npm install`)。
|
|
11
12
|
|
|
12
13
|
### 方式二:本地路径加载
|
|
13
14
|
```bash
|
|
@@ -15,6 +16,7 @@ openclaw plugins install --link /path/to/openclaw-wecom
|
|
|
15
16
|
openclaw plugins enable openclaw-wecom
|
|
16
17
|
openclaw gateway restart
|
|
17
18
|
```
|
|
19
|
+
> 本地路径加载前请先在项目目录执行 `npm install`。
|
|
18
20
|
|
|
19
21
|
## 配置
|
|
20
22
|
|
|
@@ -101,3 +103,4 @@ openclaw gateway restart
|
|
|
101
103
|
## 常见问题
|
|
102
104
|
- 回调验证失败:检查 Token / AESKey / URL 是否一致
|
|
103
105
|
- 没有回复:检查 OpenClaw 是否已启用插件并重启 gateway
|
|
106
|
+
- 插件加载失败(缺依赖):升级到最新版本并用 npm 安装
|
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();
|