@marshulll/openclaw-wecom 0.1.24 → 0.1.26
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 +23 -3
- package/README.md +23 -3
- package/README.zh.md +23 -3
- package/docs/INSTALL.md +27 -0
- package/package.json +1 -1
- package/wecom/src/wecom-app.ts +160 -36
- package/wecom/src/wecom-bot.ts +67 -12
package/README.en.md
CHANGED
|
@@ -10,7 +10,7 @@ OpenClaw WeCom plugin supporting **Bot API mode** and **Internal App mode** with
|
|
|
10
10
|
- Dual mode: Bot API (JSON callback + stream) / App (XML callback + ACK + proactive send)
|
|
11
11
|
- Multi-account: `channels.wecom.accounts`
|
|
12
12
|
- Message types: text / image / voice / video / file (send & receive)
|
|
13
|
-
- Commands: `/help`, `/status`, `/clear`, `/sendfile`
|
|
13
|
+
- Commands (App mode): `/help`, `/status`, `/clear`, `/sendfile`
|
|
14
14
|
- Stability: signature verification, AES decrypt, token cache, rate limit & retries
|
|
15
15
|
- Group chat: uses `appchat/send` when `chatId` is present
|
|
16
16
|
- Advanced: folder zip sending, send queue, operation logs, media auto recognition
|
|
@@ -89,16 +89,36 @@ Install guide: `docs/INSTALL.md`
|
|
|
89
89
|
- Bot mode media bridge: if reply payload includes `mediaUrl + mediaType`,
|
|
90
90
|
and App credentials are present, media will be uploaded and sent
|
|
91
91
|
|
|
92
|
-
## Extra commands
|
|
92
|
+
## Extra commands (App mode)
|
|
93
93
|
- `/sendfile`: send files from server (multiple absolute paths)
|
|
94
94
|
- Directories are zipped automatically
|
|
95
95
|
- Example: `/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
96
96
|
- Natural language also works: "send me this file image-xxx.jpg" (default match in `media.tempDir`)
|
|
97
|
+
- Search scope keywords: `桌面` → `~/Desktop`, `下载` → `~/Downloads`, `临时` → `media.tempDir`
|
|
98
|
+
- If multiple matches are found, a list will be returned for confirmation
|
|
99
|
+
|
|
100
|
+
## Proactive send (App mode)
|
|
101
|
+
Push endpoint path: `{webhookPath}/push` (e.g. `/wecom/app/push`).
|
|
102
|
+
|
|
103
|
+
- Method: `POST`
|
|
104
|
+
- Auth: `pushToken` (optional but recommended)
|
|
105
|
+
- Accepts `Authorization: Bearer <token>`, `x-openclaw-token`, query/body `token`
|
|
106
|
+
- Target: `toUser` (DM) or `chatId` (group)
|
|
107
|
+
|
|
108
|
+
Minimal example (text):
|
|
109
|
+
```bash
|
|
110
|
+
curl -X POST "https://your-domain/wecom/app/push" \
|
|
111
|
+
-H "Content-Type: application/json" \
|
|
112
|
+
-H "Authorization: Bearer PUSH_TOKEN" \
|
|
113
|
+
-d '{"toUser":"WenShuJun","text":"Hello"}'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Media (file/image/voice/video): use `mediaUrl` or `mediaBase64`. You can also send text together.
|
|
97
117
|
|
|
98
118
|
## Media auto recognition (optional)
|
|
99
119
|
- **Voice send/receive does NOT require API**; only auto transcription needs an OpenAI-compatible API
|
|
100
120
|
- **Video recognition requires ffmpeg** (install on server, then set `media.auto.video.enabled = true`)
|
|
101
|
-
- Video recognition supports **light / full** modes (default: light)
|
|
121
|
+
- Video recognition supports **light / full** modes (default: light) via `media.auto.video.mode`
|
|
102
122
|
- Small text files can be previewed automatically
|
|
103
123
|
|
|
104
124
|
## Send queue & operation logs (optional)
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ OpenClaw WeCom 插件,支持 **智能机器人 API 模式** 与 **自建应用
|
|
|
10
10
|
- 双模式:Bot API(JSON 回调 + stream)/ App(XML 回调 + ACK + 主动发送)
|
|
11
11
|
- 多账户:`channels.wecom.accounts`
|
|
12
12
|
- 消息类型:文本 / 图片 / 语音 / 视频 / 文件(收发均支持)
|
|
13
|
-
-
|
|
13
|
+
- 机器人命令(App 模式):`/help`、`/status`、`/clear`、`/sendfile`
|
|
14
14
|
- 稳定性:签名校验、AES 解密、token 缓存、限流与重试
|
|
15
15
|
- 群聊:自动识别 `chatId` 并使用 `appchat/send`
|
|
16
16
|
- 进阶:文件夹打包发送、发送队列、操作日志、多媒体自动识别
|
|
@@ -91,16 +91,36 @@ openclaw gateway restart
|
|
|
91
91
|
- Bot 模式媒体桥接:当 reply payload 含 `mediaUrl + mediaType` 时,
|
|
92
92
|
若已配置 App 凭据,会自动上传并发送媒体
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## 命令补充(App 模式)
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
98
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
100
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
101
|
+
|
|
102
|
+
## 主动消息(App 模式)
|
|
103
|
+
主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
|
|
104
|
+
|
|
105
|
+
- 方法:`POST`
|
|
106
|
+
- 鉴权:`pushToken`(可选,但建议开启)
|
|
107
|
+
- 可放在 `Authorization: Bearer <token>`、`x-openclaw-token`、`token` 参数或 body 的 `token` 字段
|
|
108
|
+
- 目标:`toUser`(单人)或 `chatId`(群聊),二选一
|
|
109
|
+
|
|
110
|
+
最小示例(文本):
|
|
111
|
+
```bash
|
|
112
|
+
curl -X POST "https://你的域名/wecom/app/push" \
|
|
113
|
+
-H "Content-Type: application/json" \
|
|
114
|
+
-H "Authorization: Bearer PUSH_TOKEN" \
|
|
115
|
+
-d '{"toUser":"WenShuJun","text":"你好"}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
发送媒体(file/image/voice/video):支持 `mediaUrl` 或 `mediaBase64`,可与 `text` 同时发送。
|
|
99
119
|
|
|
100
120
|
## 多媒体自动识别(可选)
|
|
101
121
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
|
102
122
|
- **视频识别需要 ffmpeg**(服务器已安装后,将 `media.auto.video.enabled` 设为 `true`)
|
|
103
|
-
- 视频识别支持 **light / full** 两种模式(默认 light
|
|
123
|
+
- 视频识别支持 **light / full** 两种模式(默认 light),可通过 `media.auto.video.mode` 切换
|
|
104
124
|
- 文本文件可自动预览(小文件直接读入)
|
|
105
125
|
|
|
106
126
|
## 发送队列与操作日志(可选)
|
package/README.zh.md
CHANGED
|
@@ -10,7 +10,7 @@ OpenClaw WeCom 插件,支持 **智能机器人 API 模式** 与 **自建应用
|
|
|
10
10
|
- 双模式:Bot API(JSON 回调 + stream)/ App(XML 回调 + ACK + 主动发送)
|
|
11
11
|
- 多账户:`channels.wecom.accounts`
|
|
12
12
|
- 消息类型:文本 / 图片 / 语音 / 视频 / 文件(收发均支持)
|
|
13
|
-
-
|
|
13
|
+
- 机器人命令(App 模式):`/help`、`/status`、`/clear`、`/sendfile`
|
|
14
14
|
- 稳定性:签名校验、AES 解密、token 缓存、限流与重试
|
|
15
15
|
- 群聊:自动识别 `chatId` 并使用 `appchat/send`
|
|
16
16
|
- 进阶:文件夹打包发送、发送队列、操作日志、多媒体自动识别
|
|
@@ -91,16 +91,36 @@ openclaw gateway restart
|
|
|
91
91
|
- Bot 模式媒体桥接:当 reply payload 含 `mediaUrl + mediaType` 时,
|
|
92
92
|
若已配置 App 凭据,会自动上传并发送媒体
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## 命令补充(App 模式)
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
98
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
100
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
101
|
+
|
|
102
|
+
## 主动消息(App 模式)
|
|
103
|
+
主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
|
|
104
|
+
|
|
105
|
+
- 方法:`POST`
|
|
106
|
+
- 鉴权:`pushToken`(可选,但建议开启)
|
|
107
|
+
- 可放在 `Authorization: Bearer <token>`、`x-openclaw-token`、`token` 参数或 body 的 `token` 字段
|
|
108
|
+
- 目标:`toUser`(单人)或 `chatId`(群聊),二选一
|
|
109
|
+
|
|
110
|
+
最小示例(文本):
|
|
111
|
+
```bash
|
|
112
|
+
curl -X POST "https://你的域名/wecom/app/push" \
|
|
113
|
+
-H "Content-Type: application/json" \
|
|
114
|
+
-H "Authorization: Bearer PUSH_TOKEN" \
|
|
115
|
+
-d '{"toUser":"WenShuJun","text":"你好"}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
发送媒体(file/image/voice/video):支持 `mediaUrl` 或 `mediaBase64`,可与 `text` 同时发送。
|
|
99
119
|
|
|
100
120
|
## 多媒体自动识别(可选)
|
|
101
121
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
|
102
122
|
- **视频识别需要 ffmpeg**(服务器已安装后,将 `media.auto.video.enabled` 设为 `true`)
|
|
103
|
-
- 视频识别支持 **light / full** 两种模式(默认 light
|
|
123
|
+
- 视频识别支持 **light / full** 两种模式(默认 light),可通过 `media.auto.video.mode` 切换
|
|
104
124
|
- 文本文件可自动预览(小文件直接读入)
|
|
105
125
|
|
|
106
126
|
## 发送队列与操作日志(可选)
|
package/docs/INSTALL.md
CHANGED
|
@@ -129,9 +129,12 @@ openclaw gateway restart
|
|
|
129
129
|
|
|
130
130
|
## 高级能力(可选)
|
|
131
131
|
### /sendfile(文件与文件夹)
|
|
132
|
+
- 仅 **App 模式** 支持 `/sendfile`
|
|
132
133
|
- `/sendfile` 仅支持 **服务器绝对路径**
|
|
133
134
|
- 目录会自动打包为 zip 再发送
|
|
134
135
|
- 自然语言也可触发:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
136
|
+
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
137
|
+
- 如匹配多个文件,会返回列表让你确认(回复“全部”或序号)
|
|
135
138
|
|
|
136
139
|
示例:
|
|
137
140
|
```
|
|
@@ -141,8 +144,14 @@ openclaw gateway restart
|
|
|
141
144
|
### 多媒体自动识别
|
|
142
145
|
- **语音收发不需要 API**;只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
|
143
146
|
- **视频识别需要 ffmpeg**(服务器安装后将 `media.auto.video.enabled=true`)
|
|
147
|
+
- **视频识别 light/full 模式**:`media.auto.video.mode`(默认 `light`)
|
|
144
148
|
- 文本文件可自动预览(小文件直接读入)
|
|
145
149
|
|
|
150
|
+
建议安装 ffmpeg(Ubuntu):
|
|
151
|
+
```bash
|
|
152
|
+
sudo apt-get update && sudo apt-get install -y ffmpeg
|
|
153
|
+
```
|
|
154
|
+
|
|
146
155
|
### 发送队列与操作日志
|
|
147
156
|
- `sendQueue.intervalMs`:/sendfile 多文件发送间隔
|
|
148
157
|
- `operations.logPath`:JSONL 日志,记录发送文件与主动推送
|
|
@@ -152,6 +161,24 @@ openclaw gateway restart
|
|
|
152
161
|
- 在企业微信后台配置回调 URL。
|
|
153
162
|
- 建议 Bot 与 App 使用不同 `webhookPath`,便于排障与避免回调混淆。
|
|
154
163
|
|
|
164
|
+
## 主动推送(App 模式)
|
|
165
|
+
主动推送接口路径:`{webhookPath}/push`(例如 `/wecom/app/push`)。
|
|
166
|
+
|
|
167
|
+
- 方法:`POST`
|
|
168
|
+
- 鉴权:`pushToken`(可选,但建议开启)
|
|
169
|
+
- `Authorization: Bearer <token>`、`x-openclaw-token`、`token` 参数或 body `token`
|
|
170
|
+
- 目标:`toUser`(单人)或 `chatId`(群聊),二选一
|
|
171
|
+
|
|
172
|
+
最小示例(文本):
|
|
173
|
+
```bash
|
|
174
|
+
curl -X POST "https://你的域名/wecom/app/push" \
|
|
175
|
+
-H "Content-Type: application/json" \
|
|
176
|
+
-H "Authorization: Bearer PUSH_TOKEN" \
|
|
177
|
+
-d '{"toUser":"WenShuJun","text":"你好"}'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
媒体发送(file/image/voice/video):使用 `mediaUrl` 或 `mediaBase64`,可与 `text` 同时发送。
|
|
181
|
+
|
|
155
182
|
## 常见问题
|
|
156
183
|
- 回调验证失败:检查 Token / AESKey / URL 是否一致
|
|
157
184
|
- 没有回复:检查 OpenClaw 是否已启用插件并重启 gateway
|
package/package.json
CHANGED
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { XMLParser } from "fast-xml-parser";
|
|
4
4
|
import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join } from "node:path";
|
|
7
7
|
|
|
8
8
|
import type { WecomWebhookTarget } from "./monitor.js";
|
|
@@ -135,6 +135,28 @@ function resolveSendIntervalMs(target: WecomWebhookTarget): number {
|
|
|
135
135
|
return typeof interval === "number" && interval >= 0 ? interval : 400;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
type PendingSendList = {
|
|
139
|
+
items: { name: string; path: string }[];
|
|
140
|
+
dirLabel: string;
|
|
141
|
+
createdAt: number;
|
|
142
|
+
expiresAt: number;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const pendingSendLists = new Map<string, PendingSendList>();
|
|
146
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
147
|
+
const MAX_LIST_PREVIEW = 30;
|
|
148
|
+
|
|
149
|
+
function pendingKey(fromUser: string, chatId?: string): string {
|
|
150
|
+
return chatId ? `${fromUser}::${chatId}` : fromUser;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function prunePendingLists(): void {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
for (const [key, entry] of pendingSendLists.entries()) {
|
|
156
|
+
if (entry.expiresAt <= now) pendingSendLists.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
function extractFilenameCandidates(text: string): string[] {
|
|
139
161
|
const candidates = new Set<string>();
|
|
140
162
|
const normalized = text.replace(/[,,;;|]/g, " ");
|
|
@@ -146,6 +168,52 @@ function extractFilenameCandidates(text: string): string[] {
|
|
|
146
168
|
return Array.from(candidates);
|
|
147
169
|
}
|
|
148
170
|
|
|
171
|
+
function extractExtension(text: string): string | null {
|
|
172
|
+
const match = text.match(/(?:\.|格式|后缀)?\s*([A-Za-z0-9]{2,8})/i);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
const ext = match[1]?.toLowerCase();
|
|
175
|
+
if (!ext) return null;
|
|
176
|
+
const allowed = new Set([
|
|
177
|
+
"png", "jpg", "jpeg", "gif", "bmp", "webp",
|
|
178
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
|
179
|
+
"zip", "rar", "7z",
|
|
180
|
+
"txt", "log", "csv", "json", "xml", "yaml", "yml",
|
|
181
|
+
"mp3", "wav", "amr", "mp4", "mov",
|
|
182
|
+
]);
|
|
183
|
+
return allowed.has(ext) ? ext : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveSearchDir(text: string, target: WecomWebhookTarget): { path: string; label: string } {
|
|
187
|
+
const lower = text.toLowerCase();
|
|
188
|
+
if (text.includes("桌面")) return { path: join(homedir(), "Desktop"), label: "桌面" };
|
|
189
|
+
if (text.includes("下载") || lower.includes("download")) return { path: join(homedir(), "Downloads"), label: "下载" };
|
|
190
|
+
if (text.includes("临时") || lower.includes("tmp")) return { path: resolveMediaTempDir(target), label: "临时目录" };
|
|
191
|
+
return { path: resolveMediaTempDir(target), label: "临时目录" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseSelection(text: string, items: { name: string; path: string }[]): { name: string; path: string }[] | null {
|
|
195
|
+
const trimmed = text.trim();
|
|
196
|
+
if (!trimmed) return null;
|
|
197
|
+
if (/全部|都要|全都|都给我/.test(trimmed)) return items;
|
|
198
|
+
const picked: { name: string; path: string }[] = [];
|
|
199
|
+
const numbers = Array.from(trimmed.matchAll(/\d+/g)).map((m) => Number(m[0]));
|
|
200
|
+
if (numbers.length > 0) {
|
|
201
|
+
for (const idx of numbers) {
|
|
202
|
+
const item = items[idx - 1];
|
|
203
|
+
if (item) picked.push(item);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const names = extractFilenameCandidates(trimmed);
|
|
207
|
+
if (names.length > 0) {
|
|
208
|
+
const map = new Map(items.map((item) => [item.name, item]));
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const item = map.get(name);
|
|
211
|
+
if (item) picked.push(item);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return picked.length > 0 ? picked : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
149
217
|
async function tryHandleNaturalFileSend(params: {
|
|
150
218
|
target: WecomWebhookTarget;
|
|
151
219
|
text: string;
|
|
@@ -155,41 +223,69 @@ async function tryHandleNaturalFileSend(params: {
|
|
|
155
223
|
}): Promise<boolean> {
|
|
156
224
|
const { target, text, fromUser, chatId, isGroup } = params;
|
|
157
225
|
if (!text || text.trim().startsWith("/")) return false;
|
|
226
|
+
prunePendingLists();
|
|
227
|
+
const key = pendingKey(fromUser, chatId);
|
|
228
|
+
const pending = pendingSendLists.get(key);
|
|
229
|
+
if (pending) {
|
|
230
|
+
const selection = parseSelection(text, pending.items);
|
|
231
|
+
if (selection) {
|
|
232
|
+
pendingSendLists.delete(key);
|
|
233
|
+
await sendFilesByPath({ target, fromUser, chatId, isGroup, items: selection });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
158
238
|
if (!/(发给我|发送给我|发我|给我)/.test(text)) return false;
|
|
159
239
|
const names = extractFilenameCandidates(text);
|
|
160
|
-
|
|
240
|
+
const ext = extractExtension(text);
|
|
241
|
+
if (names.length === 0 && !ext) return false;
|
|
161
242
|
|
|
162
|
-
const
|
|
243
|
+
const searchDir = resolveSearchDir(text, target);
|
|
163
244
|
let dirEntries: string[] = [];
|
|
164
245
|
try {
|
|
165
|
-
dirEntries = await readdir(
|
|
246
|
+
dirEntries = await readdir(searchDir.path);
|
|
166
247
|
} catch {
|
|
167
248
|
dirEntries = [];
|
|
168
249
|
}
|
|
169
250
|
const dirSet = new Set(dirEntries);
|
|
170
251
|
|
|
171
|
-
const resolved: string[] = [];
|
|
252
|
+
const resolved: { name: string; path: string }[] = [];
|
|
172
253
|
const missing: string[] = [];
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
const info = await stat(fullPath);
|
|
186
|
-
if (info.isFile()) {
|
|
187
|
-
resolved.push(fullPath);
|
|
188
|
-
} else {
|
|
254
|
+
if (names.length > 0) {
|
|
255
|
+
for (const name of names) {
|
|
256
|
+
let fullPath = "";
|
|
257
|
+
if (name.startsWith("/")) {
|
|
258
|
+
fullPath = name;
|
|
259
|
+
} else if (dirSet.has(name)) {
|
|
260
|
+
fullPath = join(searchDir.path, name);
|
|
261
|
+
}
|
|
262
|
+
if (!fullPath) {
|
|
189
263
|
missing.push(name);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const info = await stat(fullPath);
|
|
268
|
+
if (info.isFile()) {
|
|
269
|
+
resolved.push({ name: basename(fullPath), path: fullPath });
|
|
270
|
+
} else {
|
|
271
|
+
missing.push(name);
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
missing.push(name);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (ext) {
|
|
278
|
+
for (const entry of dirEntries) {
|
|
279
|
+
if (!entry.toLowerCase().endsWith(`.${ext}`)) continue;
|
|
280
|
+
const fullPath = join(searchDir.path, entry);
|
|
281
|
+
try {
|
|
282
|
+
const info = await stat(fullPath);
|
|
283
|
+
if (info.isFile()) {
|
|
284
|
+
resolved.push({ name: entry, path: fullPath });
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// ignore
|
|
190
288
|
}
|
|
191
|
-
} catch {
|
|
192
|
-
missing.push(name);
|
|
193
289
|
}
|
|
194
290
|
}
|
|
195
291
|
|
|
@@ -204,25 +300,55 @@ async function tryHandleNaturalFileSend(params: {
|
|
|
204
300
|
return true;
|
|
205
301
|
}
|
|
206
302
|
|
|
303
|
+
if (resolved.length === 1) {
|
|
304
|
+
await sendFilesByPath({ target, fromUser, chatId, isGroup, items: resolved });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const preview = resolved.slice(0, MAX_LIST_PREVIEW)
|
|
309
|
+
.map((item, idx) => `${idx + 1}. ${item.name}`)
|
|
310
|
+
.join("\n");
|
|
311
|
+
const tail = resolved.length > MAX_LIST_PREVIEW ? `\n…共 ${resolved.length} 个文件` : "";
|
|
312
|
+
pendingSendLists.set(key, {
|
|
313
|
+
items: resolved,
|
|
314
|
+
dirLabel: searchDir.label,
|
|
315
|
+
createdAt: Date.now(),
|
|
316
|
+
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
317
|
+
});
|
|
318
|
+
await sendWecomText({
|
|
319
|
+
account: target.account,
|
|
320
|
+
toUser: fromUser,
|
|
321
|
+
chatId: isGroup ? chatId : undefined,
|
|
322
|
+
text: `在${searchDir.label}找到 ${resolved.length} 个文件:\n${preview}${tail}\n\n回复“全部”或“1 3 5”或直接发送具体文件名。`,
|
|
323
|
+
});
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function sendFilesByPath(params: {
|
|
328
|
+
target: WecomWebhookTarget;
|
|
329
|
+
fromUser: string;
|
|
330
|
+
chatId?: string;
|
|
331
|
+
isGroup: boolean;
|
|
332
|
+
items: { name: string; path: string }[];
|
|
333
|
+
}): Promise<void> {
|
|
334
|
+
const { target, fromUser, chatId, isGroup, items } = params;
|
|
207
335
|
const maxBytes = resolveMediaMaxBytes(target);
|
|
208
336
|
const intervalMs = resolveSendIntervalMs(target);
|
|
209
337
|
let sent = 0;
|
|
210
338
|
const failed: string[] = [];
|
|
211
|
-
|
|
212
|
-
for (const path of resolved) {
|
|
339
|
+
for (const item of items) {
|
|
213
340
|
try {
|
|
214
|
-
const info = await stat(path);
|
|
341
|
+
const info = await stat(item.path);
|
|
215
342
|
if (maxBytes && info.size > maxBytes) {
|
|
216
|
-
failed.push(`${
|
|
343
|
+
failed.push(`${item.name}(过大)`);
|
|
217
344
|
continue;
|
|
218
345
|
}
|
|
219
|
-
const buffer = await readFile(path);
|
|
220
|
-
const filename = basename(path) || "file.bin";
|
|
346
|
+
const buffer = await readFile(item.path);
|
|
221
347
|
const mediaId = await uploadWecomMedia({
|
|
222
348
|
account: target.account,
|
|
223
349
|
type: "file",
|
|
224
350
|
buffer,
|
|
225
|
-
filename,
|
|
351
|
+
filename: item.name,
|
|
226
352
|
});
|
|
227
353
|
await sendWecomFile({
|
|
228
354
|
account: target.account,
|
|
@@ -236,31 +362,29 @@ async function tryHandleNaturalFileSend(params: {
|
|
|
236
362
|
accountId: target.account.accountId,
|
|
237
363
|
toUser: fromUser,
|
|
238
364
|
chatId,
|
|
239
|
-
path,
|
|
365
|
+
path: item.path,
|
|
240
366
|
size: info.size,
|
|
241
367
|
});
|
|
242
368
|
if (intervalMs) await sleep(intervalMs);
|
|
243
369
|
} catch (err) {
|
|
244
|
-
failed.push(
|
|
370
|
+
failed.push(item.name);
|
|
245
371
|
await appendOperationLog(target, {
|
|
246
372
|
action: "natural-sendfile",
|
|
247
373
|
accountId: target.account.accountId,
|
|
248
374
|
toUser: fromUser,
|
|
249
375
|
chatId,
|
|
250
|
-
path,
|
|
376
|
+
path: item.path,
|
|
251
377
|
error: String(err),
|
|
252
378
|
});
|
|
253
379
|
}
|
|
254
380
|
}
|
|
255
|
-
|
|
256
|
-
const summary = `已发送 ${sent} 个文件${failed.length ? `,失败:${failed.join(", ")}` : ""}${missing.length ? `,未找到:${missing.join(", ")}` : ""}`;
|
|
381
|
+
const summary = `已发送 ${sent} 个文件${failed.length ? `,失败:${failed.join(", ")}` : ""}`;
|
|
257
382
|
await sendWecomText({
|
|
258
383
|
account: target.account,
|
|
259
384
|
toUser: fromUser,
|
|
260
385
|
chatId: isGroup ? chatId : undefined,
|
|
261
386
|
text: summary,
|
|
262
387
|
});
|
|
263
|
-
return true;
|
|
264
388
|
}
|
|
265
389
|
|
|
266
390
|
async function appendOperationLog(target: WecomWebhookTarget, entry: Record<string, unknown>): Promise<void> {
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -9,7 +9,15 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
|
9
9
|
import type { WecomWebhookTarget } from "./monitor.js";
|
|
10
10
|
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
|
|
11
11
|
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature } from "./crypto.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
downloadWecomMedia,
|
|
14
|
+
fetchMediaFromUrl,
|
|
15
|
+
sendWecomFile,
|
|
16
|
+
sendWecomImage,
|
|
17
|
+
sendWecomVideo,
|
|
18
|
+
sendWecomVoice,
|
|
19
|
+
uploadWecomMedia,
|
|
20
|
+
} from "./wecom-api.js";
|
|
13
21
|
import { getWecomRuntime } from "./runtime.js";
|
|
14
22
|
import { describeImageWithVision, resolveVisionConfig } from "./media-vision.js";
|
|
15
23
|
import {
|
|
@@ -501,9 +509,13 @@ function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "fi
|
|
|
501
509
|
if (msgtype === "image") {
|
|
502
510
|
return pickString(
|
|
503
511
|
block.url,
|
|
512
|
+
block.imageUrl,
|
|
513
|
+
block.image_url,
|
|
504
514
|
block.picurl,
|
|
505
515
|
block.picUrl,
|
|
506
516
|
block.pic_url,
|
|
517
|
+
msg.imageUrl,
|
|
518
|
+
msg.image_url,
|
|
507
519
|
msg.picurl,
|
|
508
520
|
msg.picUrl,
|
|
509
521
|
msg.pic_url,
|
|
@@ -516,6 +528,8 @@ function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "fi
|
|
|
516
528
|
block.fileurl,
|
|
517
529
|
block.fileUrl,
|
|
518
530
|
block.file_url,
|
|
531
|
+
block.downloadUrl,
|
|
532
|
+
block.download_url,
|
|
519
533
|
block.mediaUrl,
|
|
520
534
|
block.media_url,
|
|
521
535
|
msg.voiceUrl,
|
|
@@ -529,6 +543,8 @@ function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "fi
|
|
|
529
543
|
block.fileurl,
|
|
530
544
|
block.fileUrl,
|
|
531
545
|
block.file_url,
|
|
546
|
+
block.downloadUrl,
|
|
547
|
+
block.download_url,
|
|
532
548
|
block.mediaUrl,
|
|
533
549
|
block.media_url,
|
|
534
550
|
msg.videoUrl,
|
|
@@ -541,6 +557,8 @@ function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "fi
|
|
|
541
557
|
block.fileurl,
|
|
542
558
|
block.fileUrl,
|
|
543
559
|
block.file_url,
|
|
560
|
+
block.downloadUrl,
|
|
561
|
+
block.download_url,
|
|
544
562
|
block.mediaUrl,
|
|
545
563
|
block.media_url,
|
|
546
564
|
msg.fileUrl,
|
|
@@ -554,12 +572,28 @@ function resolveBotMediaBase64(msg: any, msgtype: "image" | "voice" | "video" |
|
|
|
554
572
|
const block = msg[msgtype] ?? {};
|
|
555
573
|
return pickString(
|
|
556
574
|
block.base64,
|
|
575
|
+
block.base64Data,
|
|
557
576
|
block.data,
|
|
558
577
|
msg.base64,
|
|
559
578
|
msg.data,
|
|
560
579
|
);
|
|
561
580
|
}
|
|
562
581
|
|
|
582
|
+
function resolveBotMediaId(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
583
|
+
if (!msg || typeof msg !== "object") return "";
|
|
584
|
+
const block = msg[msgtype] ?? {};
|
|
585
|
+
return pickString(
|
|
586
|
+
block.media_id,
|
|
587
|
+
block.mediaId,
|
|
588
|
+
block.mediaid,
|
|
589
|
+
block.mediaID,
|
|
590
|
+
msg.media_id,
|
|
591
|
+
msg.mediaId,
|
|
592
|
+
msg.mediaid,
|
|
593
|
+
msg.mediaID,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
563
597
|
function resolveBotMediaFilename(msg: any): string {
|
|
564
598
|
if (!msg || typeof msg !== "object") return "";
|
|
565
599
|
const block = msg.file ?? {};
|
|
@@ -568,6 +602,7 @@ function resolveBotMediaFilename(msg: any): string {
|
|
|
568
602
|
block.fileName,
|
|
569
603
|
block.name,
|
|
570
604
|
block.file_name,
|
|
605
|
+
block.file,
|
|
571
606
|
msg.filename,
|
|
572
607
|
msg.fileName,
|
|
573
608
|
msg.name,
|
|
@@ -579,9 +614,10 @@ async function buildBotMediaMessage(params: {
|
|
|
579
614
|
msgtype: "image" | "voice" | "video" | "file";
|
|
580
615
|
url?: string;
|
|
581
616
|
base64?: string;
|
|
617
|
+
mediaId?: string;
|
|
582
618
|
filename?: string;
|
|
583
619
|
}): Promise<InboundBody> {
|
|
584
|
-
const { target, msgtype, url, base64, filename } = params;
|
|
620
|
+
const { target, msgtype, url, base64, mediaId, filename } = params;
|
|
585
621
|
|
|
586
622
|
const fallbackLabel = msgtype === "image"
|
|
587
623
|
? "[image]"
|
|
@@ -591,7 +627,7 @@ async function buildBotMediaMessage(params: {
|
|
|
591
627
|
? "[video]"
|
|
592
628
|
: "[file]";
|
|
593
629
|
|
|
594
|
-
if (!url && !base64) return { text: fallbackLabel };
|
|
630
|
+
if (!url && !base64 && !mediaId) return { text: fallbackLabel };
|
|
595
631
|
|
|
596
632
|
try {
|
|
597
633
|
const cacheKey = buildMediaCacheKey({ url, base64 });
|
|
@@ -625,15 +661,23 @@ async function buildBotMediaMessage(params: {
|
|
|
625
661
|
let buffer: Buffer | null = null;
|
|
626
662
|
let contentType = "";
|
|
627
663
|
if (base64) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
664
|
+
const parsed = parseBase64Input(base64);
|
|
665
|
+
buffer = Buffer.from(parsed.data, "base64");
|
|
666
|
+
if (parsed.mimeType) contentType = parsed.mimeType;
|
|
667
|
+
if (!contentType) {
|
|
668
|
+
if (msgtype === "image") contentType = "image/jpeg";
|
|
669
|
+
else if (msgtype === "voice") contentType = "audio/amr";
|
|
670
|
+
else if (msgtype === "video") contentType = "video/mp4";
|
|
671
|
+
else contentType = "application/octet-stream";
|
|
672
|
+
}
|
|
633
673
|
} else if (url) {
|
|
634
674
|
const media = await fetchMediaFromUrl(url, target.account);
|
|
635
675
|
buffer = media.buffer;
|
|
636
676
|
contentType = media.contentType;
|
|
677
|
+
} else if (mediaId && target.account.corpId && target.account.corpSecret && target.account.agentId) {
|
|
678
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
679
|
+
buffer = media.buffer;
|
|
680
|
+
contentType = media.contentType;
|
|
637
681
|
}
|
|
638
682
|
|
|
639
683
|
if (!buffer) return { text: fallbackLabel };
|
|
@@ -771,9 +815,17 @@ async function buildInboundBody(params: { target: WecomWebhookTarget; msg: Wecom
|
|
|
771
815
|
if (msgtype === "voice") {
|
|
772
816
|
const content = (msg as any).voice?.content;
|
|
773
817
|
if (typeof content === "string" && content.trim()) return { text: content.trim() };
|
|
818
|
+
const recognition = pickString(
|
|
819
|
+
(msg as any).voice?.recognition,
|
|
820
|
+
(msg as any).voice?.text,
|
|
821
|
+
(msg as any).voice?.transcript,
|
|
822
|
+
(msg as any).recognition,
|
|
823
|
+
);
|
|
824
|
+
if (recognition) return { text: recognition };
|
|
774
825
|
const url = resolveBotMediaUrl(msg as any, "voice");
|
|
775
826
|
const base64 = resolveBotMediaBase64(msg as any, "voice");
|
|
776
|
-
|
|
827
|
+
const mediaId = resolveBotMediaId(msg as any, "voice");
|
|
828
|
+
return await buildBotMediaMessage({ target, msgtype: "voice", url, base64, mediaId });
|
|
777
829
|
}
|
|
778
830
|
if (msgtype === "mixed") {
|
|
779
831
|
const items = (msg as any).mixed?.msg_item;
|
|
@@ -794,18 +846,21 @@ async function buildInboundBody(params: { target: WecomWebhookTarget; msg: Wecom
|
|
|
794
846
|
if (msgtype === "image") {
|
|
795
847
|
const url = resolveBotMediaUrl(msg as any, "image");
|
|
796
848
|
const base64 = resolveBotMediaBase64(msg as any, "image");
|
|
797
|
-
|
|
849
|
+
const mediaId = resolveBotMediaId(msg as any, "image");
|
|
850
|
+
return await buildBotMediaMessage({ target, msgtype: "image", url, base64, mediaId });
|
|
798
851
|
}
|
|
799
852
|
if (msgtype === "file") {
|
|
800
853
|
const url = resolveBotMediaUrl(msg as any, "file");
|
|
801
854
|
const base64 = resolveBotMediaBase64(msg as any, "file");
|
|
855
|
+
const mediaId = resolveBotMediaId(msg as any, "file");
|
|
802
856
|
const filename = resolveBotMediaFilename(msg as any);
|
|
803
|
-
return await buildBotMediaMessage({ target, msgtype: "file", url, base64, filename });
|
|
857
|
+
return await buildBotMediaMessage({ target, msgtype: "file", url, base64, mediaId, filename });
|
|
804
858
|
}
|
|
805
859
|
if (msgtype === "video") {
|
|
806
860
|
const url = resolveBotMediaUrl(msg as any, "video");
|
|
807
861
|
const base64 = resolveBotMediaBase64(msg as any, "video");
|
|
808
|
-
|
|
862
|
+
const mediaId = resolveBotMediaId(msg as any, "video");
|
|
863
|
+
return await buildBotMediaMessage({ target, msgtype: "video", url, base64, mediaId });
|
|
809
864
|
}
|
|
810
865
|
if (msgtype === "event") {
|
|
811
866
|
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|