@marshulll/openclaw-wecom 0.1.9 → 0.1.11
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 +2 -0
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/docs/INSTALL.md +32 -0
- package/docs/wecom.config.full.example.json +10 -24
- package/package.json +1 -1
- package/wecom/src/wecom-bot.ts +62 -26
package/README.en.md
CHANGED
|
@@ -99,3 +99,5 @@ Install guide: `docs/INSTALL.md`
|
|
|
99
99
|
- Dev doc: `docs/TECHNICAL.md`
|
|
100
100
|
- Install: `docs/INSTALL.md`
|
|
101
101
|
- Examples: `docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
|
|
102
|
+
|
|
103
|
+
Recommendation: use **separate webhookPath** for Bot and App (e.g. `/wecom/bot` and `/wecom/app`) for clearer debugging and fewer callback mix-ups.
|
package/README.md
CHANGED
package/README.zh.md
CHANGED
package/docs/INSTALL.md
CHANGED
|
@@ -50,6 +50,37 @@ openclaw gateway restart
|
|
|
50
50
|
}
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
推荐示例(Bot/App 独立路径):
|
|
54
|
+
```json5
|
|
55
|
+
{
|
|
56
|
+
"channels": {
|
|
57
|
+
"wecom": {
|
|
58
|
+
"enabled": true,
|
|
59
|
+
"mode": "both",
|
|
60
|
+
"defaultAccount": "bot",
|
|
61
|
+
"accounts": {
|
|
62
|
+
"bot": {
|
|
63
|
+
"mode": "bot",
|
|
64
|
+
"webhookPath": "/wecom/bot",
|
|
65
|
+
"token": "BOT_TOKEN",
|
|
66
|
+
"encodingAESKey": "BOT_AES",
|
|
67
|
+
"receiveId": "BOT_ID"
|
|
68
|
+
},
|
|
69
|
+
"app": {
|
|
70
|
+
"mode": "app",
|
|
71
|
+
"webhookPath": "/wecom/app",
|
|
72
|
+
"corpId": "CORP_ID",
|
|
73
|
+
"corpSecret": "CORP_SECRET",
|
|
74
|
+
"agentId": 1000001,
|
|
75
|
+
"callbackToken": "CALLBACK_TOKEN",
|
|
76
|
+
"callbackAesKey": "CALLBACK_AES"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
53
84
|
## 环境变量示例(可选)
|
|
54
85
|
|
|
55
86
|
如果你更希望用环境变量,也可以写在 `~/.openclaw/openclaw.json` 的 `env.vars` 中(优先级低于 `channels.wecom` 配置):
|
|
@@ -99,6 +130,7 @@ openclaw gateway restart
|
|
|
99
130
|
## Webhook 验证
|
|
100
131
|
- Bot 模式与 App 模式都要求公网 HTTPS。
|
|
101
132
|
- 在企业微信后台配置回调 URL。
|
|
133
|
+
- 建议 Bot 与 App 使用不同 `webhookPath`,便于排障与避免回调混淆。
|
|
102
134
|
|
|
103
135
|
## 常见问题
|
|
104
136
|
- 回调验证失败:检查 Token / AESKey / URL 是否一致
|
|
@@ -3,17 +3,6 @@
|
|
|
3
3
|
"wecom": {
|
|
4
4
|
"enabled": true,
|
|
5
5
|
"mode": "both",
|
|
6
|
-
"webhookPath": "/wecom",
|
|
7
|
-
|
|
8
|
-
"token": "BOT_TOKEN",
|
|
9
|
-
"encodingAESKey": "BOT_AES",
|
|
10
|
-
"receiveId": "BOT_ID",
|
|
11
|
-
|
|
12
|
-
"corpId": "CORP_ID",
|
|
13
|
-
"corpSecret": "CORP_SECRET",
|
|
14
|
-
"agentId": 1000001,
|
|
15
|
-
"callbackToken": "CALLBACK_TOKEN",
|
|
16
|
-
"callbackAesKey": "CALLBACK_AES",
|
|
17
6
|
|
|
18
7
|
"media": {
|
|
19
8
|
"tempDir": "/tmp/openclaw-wecom",
|
|
@@ -28,26 +17,23 @@
|
|
|
28
17
|
"allowFrom": []
|
|
29
18
|
},
|
|
30
19
|
|
|
31
|
-
"defaultAccount": "
|
|
20
|
+
"defaultAccount": "bot",
|
|
32
21
|
"accounts": {
|
|
33
|
-
"
|
|
34
|
-
"mode": "
|
|
35
|
-
"webhookPath": "/wecom",
|
|
22
|
+
"bot": {
|
|
23
|
+
"mode": "bot",
|
|
24
|
+
"webhookPath": "/wecom/bot",
|
|
36
25
|
"token": "BOT_TOKEN",
|
|
37
26
|
"encodingAESKey": "BOT_AES",
|
|
38
|
-
"receiveId": "BOT_ID"
|
|
27
|
+
"receiveId": "BOT_ID"
|
|
28
|
+
},
|
|
29
|
+
"app": {
|
|
30
|
+
"mode": "app",
|
|
31
|
+
"webhookPath": "/wecom/app",
|
|
39
32
|
"corpId": "CORP_ID",
|
|
40
33
|
"corpSecret": "CORP_SECRET",
|
|
41
34
|
"agentId": 1000001,
|
|
42
35
|
"callbackToken": "CALLBACK_TOKEN",
|
|
43
|
-
"callbackAesKey": "CALLBACK_AES"
|
|
44
|
-
"media": {
|
|
45
|
-
"tempDir": "/tmp/openclaw-wecom",
|
|
46
|
-
"retentionHours": 72,
|
|
47
|
-
"cleanupOnStart": true,
|
|
48
|
-
"maxBytes": 10485760
|
|
49
|
-
},
|
|
50
|
-
"botMediaBridge": true
|
|
36
|
+
"callbackAesKey": "CALLBACK_AES"
|
|
51
37
|
}
|
|
52
38
|
}
|
|
53
39
|
}
|
package/package.json
CHANGED
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -31,6 +31,17 @@ type StreamState = {
|
|
|
31
31
|
content: string;
|
|
32
32
|
};
|
|
33
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
|
+
|
|
34
45
|
const streams = new Map<string, StreamState>();
|
|
35
46
|
const msgidToStreamId = new Map<string, string>();
|
|
36
47
|
const recentEncrypts = new Map<string, { ts: number; streamId?: string }>();
|
|
@@ -320,7 +331,8 @@ async function startAgentForStream(params: {
|
|
|
320
331
|
const userid = msg.from?.userid?.trim() || "unknown";
|
|
321
332
|
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
322
333
|
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
323
|
-
const
|
|
334
|
+
const inbound = await buildInboundBody({ target, msg });
|
|
335
|
+
const rawBody = inbound.text;
|
|
324
336
|
|
|
325
337
|
const route = core.channel.routing.resolveAgentRoute({
|
|
326
338
|
cfg: config,
|
|
@@ -367,6 +379,14 @@ async function startAgentForStream(params: {
|
|
|
367
379
|
OriginatingTo: `wecom:${chatId}`,
|
|
368
380
|
});
|
|
369
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
|
+
|
|
370
390
|
await core.channel.session.recordInboundSession({
|
|
371
391
|
storePath,
|
|
372
392
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
@@ -550,7 +570,7 @@ async function buildBotMediaMessage(params: {
|
|
|
550
570
|
url?: string;
|
|
551
571
|
base64?: string;
|
|
552
572
|
filename?: string;
|
|
553
|
-
}): Promise<
|
|
573
|
+
}): Promise<InboundBody> {
|
|
554
574
|
const { target, msgtype, url, base64, filename } = params;
|
|
555
575
|
|
|
556
576
|
const fallbackLabel = msgtype === "image"
|
|
@@ -561,28 +581,31 @@ async function buildBotMediaMessage(params: {
|
|
|
561
581
|
? "[video]"
|
|
562
582
|
: "[file]";
|
|
563
583
|
|
|
564
|
-
if (!url && !base64) return fallbackLabel;
|
|
584
|
+
if (!url && !base64) return { text: fallbackLabel };
|
|
565
585
|
|
|
566
586
|
try {
|
|
567
587
|
let buffer: Buffer | null = null;
|
|
568
588
|
let contentType = "";
|
|
569
589
|
if (base64) {
|
|
570
590
|
buffer = Buffer.from(base64, "base64");
|
|
571
|
-
|
|
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";
|
|
572
595
|
} else if (url) {
|
|
573
596
|
const media = await fetchMediaFromUrl(url, target.account);
|
|
574
597
|
buffer = media.buffer;
|
|
575
598
|
contentType = media.contentType;
|
|
576
599
|
}
|
|
577
600
|
|
|
578
|
-
if (!buffer) return fallbackLabel;
|
|
601
|
+
if (!buffer) return { text: fallbackLabel };
|
|
579
602
|
|
|
580
603
|
const maxBytes = resolveMediaMaxBytes(target);
|
|
581
604
|
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请发送更小的文件。";
|
|
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请发送更小的文件。" };
|
|
586
609
|
}
|
|
587
610
|
|
|
588
611
|
const tempDir = resolveMediaTempDir(target);
|
|
@@ -606,7 +629,10 @@ async function buildBotMediaMessage(params: {
|
|
|
606
629
|
const safeName = sanitizeFilename(filename || "", `file-${Date.now()}.${ext}`);
|
|
607
630
|
const tempFilePath = join(tempDir, safeName);
|
|
608
631
|
await writeFile(tempFilePath, buffer);
|
|
609
|
-
return
|
|
632
|
+
return {
|
|
633
|
+
text: `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请根据文件内容回复用户。`,
|
|
634
|
+
media: { path: tempFilePath, type: contentType || "application/octet-stream", url },
|
|
635
|
+
};
|
|
610
636
|
}
|
|
611
637
|
|
|
612
638
|
const tempPath = join(
|
|
@@ -616,34 +642,43 @@ async function buildBotMediaMessage(params: {
|
|
|
616
642
|
await writeFile(tempPath, buffer);
|
|
617
643
|
|
|
618
644
|
if (msgtype === "image") {
|
|
619
|
-
return
|
|
645
|
+
return {
|
|
646
|
+
text: `[用户发送了一张图片,已保存到: ${tempPath}]\n\n请使用 Read 工具查看这张图片并描述内容。`,
|
|
647
|
+
media: { path: tempPath, type: contentType || "image/jpeg", url },
|
|
648
|
+
};
|
|
620
649
|
}
|
|
621
650
|
if (msgtype === "voice") {
|
|
622
|
-
return
|
|
651
|
+
return {
|
|
652
|
+
text: `[用户发送了一条语音消息,已保存到: ${tempPath}]\n\n请根据语音内容回复用户。`,
|
|
653
|
+
media: { path: tempPath, type: contentType || "audio/amr", url },
|
|
654
|
+
};
|
|
623
655
|
}
|
|
624
656
|
if (msgtype === "video") {
|
|
625
|
-
return
|
|
657
|
+
return {
|
|
658
|
+
text: `[用户发送了一个视频文件,已保存到: ${tempPath}]\n\n请根据视频内容回复用户。`,
|
|
659
|
+
media: { path: tempPath, type: contentType || "video/mp4", url },
|
|
660
|
+
};
|
|
626
661
|
}
|
|
627
|
-
return fallbackLabel;
|
|
662
|
+
return { text: fallbackLabel };
|
|
628
663
|
} catch (err) {
|
|
629
664
|
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请告诉用户文件处理暂时不可用。";
|
|
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请告诉用户文件处理暂时不可用。" };
|
|
634
669
|
}
|
|
635
670
|
}
|
|
636
671
|
|
|
637
|
-
async function buildInboundBody(params: { target: WecomWebhookTarget; msg: WecomInboundMessage }): Promise<
|
|
672
|
+
async function buildInboundBody(params: { target: WecomWebhookTarget; msg: WecomInboundMessage }): Promise<InboundBody> {
|
|
638
673
|
const { target, msg } = params;
|
|
639
674
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
640
675
|
if (msgtype === "text") {
|
|
641
676
|
const content = (msg as any).text?.content;
|
|
642
|
-
return typeof content === "string" ? content : "";
|
|
677
|
+
return { text: typeof content === "string" ? content : "" };
|
|
643
678
|
}
|
|
644
679
|
if (msgtype === "voice") {
|
|
645
680
|
const content = (msg as any).voice?.content;
|
|
646
|
-
if (typeof content === "string" && content.trim()) return content;
|
|
681
|
+
if (typeof content === "string" && content.trim()) return { text: content.trim() };
|
|
647
682
|
const url = resolveBotMediaUrl(msg as any, "voice");
|
|
648
683
|
const base64 = resolveBotMediaBase64(msg as any, "voice");
|
|
649
684
|
return await buildBotMediaMessage({ target, msgtype: "voice", url, base64 });
|
|
@@ -651,7 +686,7 @@ async function buildInboundBody(params: { target: WecomWebhookTarget; msg: Wecom
|
|
|
651
686
|
if (msgtype === "mixed") {
|
|
652
687
|
const items = (msg as any).mixed?.msg_item;
|
|
653
688
|
if (Array.isArray(items)) {
|
|
654
|
-
|
|
689
|
+
const text = items
|
|
655
690
|
.map((item: any) => {
|
|
656
691
|
const t = String(item?.msgtype ?? "").toLowerCase();
|
|
657
692
|
if (t === "text") return String(item?.text?.content ?? "");
|
|
@@ -660,8 +695,9 @@ async function buildInboundBody(params: { target: WecomWebhookTarget; msg: Wecom
|
|
|
660
695
|
})
|
|
661
696
|
.filter((part: string) => Boolean(part && part.trim()))
|
|
662
697
|
.join("\n");
|
|
698
|
+
return { text };
|
|
663
699
|
}
|
|
664
|
-
return "[mixed]";
|
|
700
|
+
return { text: "[mixed]" };
|
|
665
701
|
}
|
|
666
702
|
if (msgtype === "image") {
|
|
667
703
|
const url = resolveBotMediaUrl(msg as any, "image");
|
|
@@ -681,13 +717,13 @@ async function buildInboundBody(params: { target: WecomWebhookTarget; msg: Wecom
|
|
|
681
717
|
}
|
|
682
718
|
if (msgtype === "event") {
|
|
683
719
|
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|
|
684
|
-
return eventtype ? `[event] ${eventtype}` : "[event]";
|
|
720
|
+
return { text: eventtype ? `[event] ${eventtype}` : "[event]" };
|
|
685
721
|
}
|
|
686
722
|
if (msgtype === "stream") {
|
|
687
723
|
const id = String((msg as any).stream?.id ?? "").trim();
|
|
688
|
-
return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
|
|
724
|
+
return { text: id ? `[stream_refresh] ${id}` : "[stream_refresh]" };
|
|
689
725
|
}
|
|
690
|
-
return msgtype ? `[${msgtype}]` : "";
|
|
726
|
+
return { text: msgtype ? `[${msgtype}]` : "" };
|
|
691
727
|
}
|
|
692
728
|
|
|
693
729
|
function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
|