@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 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
- - 机器人命令:`/help`、`/status`、`/clear`、`/sendfile`
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
- - 机器人命令:`/help`、`/status`、`/clear`、`/sendfile`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -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
- if (names.length === 0) return false;
240
+ const ext = extractExtension(text);
241
+ if (names.length === 0 && !ext) return false;
161
242
 
162
- const tempDir = resolveMediaTempDir(target);
243
+ const searchDir = resolveSearchDir(text, target);
163
244
  let dirEntries: string[] = [];
164
245
  try {
165
- dirEntries = await readdir(tempDir);
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
- for (const name of names) {
174
- let fullPath = "";
175
- if (name.startsWith("/")) {
176
- fullPath = name;
177
- } else if (dirSet.has(name)) {
178
- fullPath = join(tempDir, name);
179
- }
180
- if (!fullPath) {
181
- missing.push(name);
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(`${basename(path)}(过大)`);
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(basename(path));
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> {
@@ -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 { fetchMediaFromUrl, sendWecomFile, sendWecomImage, sendWecomVideo, sendWecomVoice, uploadWecomMedia } from "./wecom-api.js";
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
- buffer = Buffer.from(base64, "base64");
629
- if (msgtype === "image") contentType = "image/jpeg";
630
- else if (msgtype === "voice") contentType = "audio/amr";
631
- else if (msgtype === "video") contentType = "video/mp4";
632
- else contentType = "application/octet-stream";
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
- return await buildBotMediaMessage({ target, msgtype: "voice", url, base64 });
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
- return await buildBotMediaMessage({ target, msgtype: "image", url, base64 });
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
- return await buildBotMediaMessage({ target, msgtype: "video", url, base64 });
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();