@marshulll/openclaw-wecom 0.1.27 → 0.1.28
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 -1
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/docs/INSTALL.md +1 -1
- package/docs/TESTING.md +30 -0
- package/package.json +2 -1
- package/wecom/package.json +2 -2
- package/wecom/src/media-utils.ts +73 -0
- package/wecom/src/wecom-api.ts +27 -2
- package/wecom/src/wecom-app.ts +162 -154
- package/wecom/src/wecom-bot.ts +42 -75
package/README.en.md
CHANGED
|
@@ -96,7 +96,7 @@ Install guide: `docs/INSTALL.md`
|
|
|
96
96
|
- Example: `/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
97
97
|
- Natural language also works: "send me this file image-xxx.jpg" (default match in `media.tempDir`)
|
|
98
98
|
- Search scope keywords: `桌面` → `~/Desktop`, `下载` → `~/Downloads`, `临时` → `media.tempDir`
|
|
99
|
-
- If multiple matches are found, a list will be returned for confirmation
|
|
99
|
+
- If multiple matches are found, a list will be returned for confirmation; reply "more" to paginate
|
|
100
100
|
|
|
101
101
|
## Proactive send (App mode)
|
|
102
102
|
Push endpoint path: `{webhookPath}/push` (e.g. `/wecom/app/push`).
|
|
@@ -137,5 +137,6 @@ Media (file/image/voice/video): use `mediaUrl` or `mediaBase64`. You can also se
|
|
|
137
137
|
- Dev doc: `docs/TECHNICAL.md`
|
|
138
138
|
- Install: `docs/INSTALL.md`
|
|
139
139
|
- Examples: `docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
|
|
140
|
+
- Testing: `docs/TESTING.md`
|
|
140
141
|
|
|
141
142
|
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
|
@@ -98,7 +98,7 @@ openclaw gateway restart
|
|
|
98
98
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
99
99
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
100
100
|
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
101
|
-
-
|
|
101
|
+
- 多文件会先返回列表,回复“全部”或序号再发送;回复“更多”可翻页
|
|
102
102
|
|
|
103
103
|
## 主动消息(App 模式)
|
|
104
104
|
主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
|
|
@@ -139,3 +139,4 @@ curl -X POST "https://你的域名/wecom/app/push" \
|
|
|
139
139
|
- 开发文档:`docs/TECHNICAL.md`
|
|
140
140
|
- 安装配置:`docs/INSTALL.md`
|
|
141
141
|
- 配置示例:`docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
|
|
142
|
+
- 测试清单:`docs/TESTING.md`
|
package/README.zh.md
CHANGED
|
@@ -98,7 +98,7 @@ openclaw gateway restart
|
|
|
98
98
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
99
99
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
100
100
|
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
101
|
-
-
|
|
101
|
+
- 多文件会先返回列表,回复“全部”或序号再发送;回复“更多”可翻页
|
|
102
102
|
|
|
103
103
|
## 主动消息(App 模式)
|
|
104
104
|
主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
|
|
@@ -139,3 +139,4 @@ curl -X POST "https://你的域名/wecom/app/push" \
|
|
|
139
139
|
- 开发文档:`docs/TECHNICAL.md`
|
|
140
140
|
- 安装配置:`docs/INSTALL.md`
|
|
141
141
|
- 配置示例:`docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
|
|
142
|
+
- 测试清单:`docs/TESTING.md`
|
package/docs/INSTALL.md
CHANGED
|
@@ -134,7 +134,7 @@ openclaw gateway restart
|
|
|
134
134
|
- 目录会自动打包为 zip 再发送
|
|
135
135
|
- 自然语言也可触发:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
136
136
|
- 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
|
|
137
|
-
-
|
|
137
|
+
- 如匹配多个文件,会返回列表让你确认(回复“全部”或序号;回复“更多”翻页)
|
|
138
138
|
|
|
139
139
|
示例:
|
|
140
140
|
```
|
package/docs/TESTING.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 测试清单(手动)
|
|
2
|
+
|
|
3
|
+
> 用于快速回归核对,不要求自动化。
|
|
4
|
+
|
|
5
|
+
## Bot 模式(/wecom/bot)
|
|
6
|
+
- 文本:收到文本后能正常回复
|
|
7
|
+
- 图片:能识别/描述(如开启视觉)或至少回复“已收到图片”
|
|
8
|
+
- 语音:如有识别文本则直接处理,否则走语音文件
|
|
9
|
+
- 视频:能返回视频概述(启用 `media.auto.video` 时)
|
|
10
|
+
- 文件:提示已保存路径并可用 Read 工具读取
|
|
11
|
+
|
|
12
|
+
## App 模式(/wecom/app)
|
|
13
|
+
- 文本:正常回复
|
|
14
|
+
- 图片/语音/视频/文件:同上,确认保存路径与识别结果
|
|
15
|
+
- 群聊:`chatId` 有效,能在群聊回复
|
|
16
|
+
|
|
17
|
+
## /sendfile(App 模式)
|
|
18
|
+
- 单文件绝对路径发送
|
|
19
|
+
- 多文件路径发送(含空格路径)
|
|
20
|
+
- 目录自动打包 zip
|
|
21
|
+
- 自然语言:唯一命中直接发送,多命中返回列表,回复“全部/序号”生效
|
|
22
|
+
- 多页列表:回复“更多”可翻页
|
|
23
|
+
|
|
24
|
+
## 主动推送(App 模式)
|
|
25
|
+
- `/wecom/app/push` 文本
|
|
26
|
+
- `/wecom/app/push` 媒体(file/image/voice/video)
|
|
27
|
+
|
|
28
|
+
## 大文件与异常
|
|
29
|
+
- 超过 `media.maxBytes` 的媒体会提示“过大”
|
|
30
|
+
- 未配置 App 凭据时,Bot 媒体提示更明确
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshulll/openclaw-wecom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
|
|
6
6
|
"author": "OpenClaw",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"files": [
|
|
57
57
|
"wecom/**",
|
|
58
58
|
"docs/INSTALL.md",
|
|
59
|
+
"docs/TESTING.md",
|
|
59
60
|
"docs/wecom.config.example.json",
|
|
60
61
|
"docs/wecom.config.full.example.json",
|
|
61
62
|
"openclaw.plugin.json",
|
package/wecom/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshulll/openclaw-wecom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
|
|
6
6
|
"author": "OpenClaw",
|
|
@@ -41,5 +41,5 @@
|
|
|
41
41
|
"channel",
|
|
42
42
|
"plugin"
|
|
43
43
|
],
|
|
44
|
-
"license": "
|
|
44
|
+
"license": "MIT"
|
|
45
45
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { WecomWebhookTarget } from "./monitor.js";
|
|
6
|
+
|
|
7
|
+
const cleanupExecuted = new Set<string>();
|
|
8
|
+
|
|
9
|
+
export function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
10
|
+
if (!contentType) return fallback;
|
|
11
|
+
if (contentType.includes("png")) return "png";
|
|
12
|
+
if (contentType.includes("gif")) return "gif";
|
|
13
|
+
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
14
|
+
if (contentType.includes("mp4")) return "mp4";
|
|
15
|
+
if (contentType.includes("amr")) return "amr";
|
|
16
|
+
if (contentType.includes("wav")) return "wav";
|
|
17
|
+
if (contentType.includes("mp3")) return "mp3";
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function cleanupMediaDir(
|
|
22
|
+
dir: string,
|
|
23
|
+
retentionHours?: number,
|
|
24
|
+
cleanupOnStart?: boolean,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
if (cleanupOnStart === false) return;
|
|
27
|
+
if (!retentionHours || retentionHours <= 0) return;
|
|
28
|
+
if (cleanupExecuted.has(dir)) return;
|
|
29
|
+
cleanupExecuted.add(dir);
|
|
30
|
+
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
31
|
+
try {
|
|
32
|
+
const entries = await readdir(dir);
|
|
33
|
+
await Promise.all(entries.map(async (entry) => {
|
|
34
|
+
const full = join(dir, entry);
|
|
35
|
+
try {
|
|
36
|
+
const info = await stat(full);
|
|
37
|
+
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
38
|
+
await rm(full, { force: true });
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
50
|
+
return target.account.config.media?.tempDir?.trim()
|
|
51
|
+
|| join(tmpdir(), "openclaw-wecom");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
55
|
+
const maxBytes = target.account.config.media?.maxBytes;
|
|
56
|
+
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
|
|
60
|
+
const hours = target.account.config.media?.retentionHours;
|
|
61
|
+
return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sanitizeFilename(name: string, fallback: string): string {
|
|
65
|
+
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
66
|
+
const trimmed = base.trim();
|
|
67
|
+
const safe = trimmed
|
|
68
|
+
.replace(/[^\w.\-() ]+/g, "_")
|
|
69
|
+
.replace(/\s+/g, " ")
|
|
70
|
+
.trim();
|
|
71
|
+
const finalName = safe.slice(0, 120);
|
|
72
|
+
return finalName || fallback;
|
|
73
|
+
}
|
package/wecom/src/wecom-api.ts
CHANGED
|
@@ -61,6 +61,7 @@ class RateLimiter {
|
|
|
61
61
|
|
|
62
62
|
const accessTokenCaches = new Map<string, WecomTokenState>();
|
|
63
63
|
const apiLimiter = new RateLimiter({ maxConcurrent: 3, minInterval: 200 });
|
|
64
|
+
export const MEDIA_TOO_LARGE_ERROR = "MEDIA_TOO_LARGE";
|
|
64
65
|
|
|
65
66
|
function ensureAppConfig(account: ResolvedWecomAccount): { corpId: string; corpSecret: string; agentId: number } {
|
|
66
67
|
const corpId = account.corpId ?? "";
|
|
@@ -106,6 +107,23 @@ async function fetchWithRetry(account: ResolvedWecomAccount, input: RequestInfo
|
|
|
106
107
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
function resolveContentLength(res: Response): number | null {
|
|
111
|
+
const raw = res.headers.get("content-length");
|
|
112
|
+
if (!raw) return null;
|
|
113
|
+
const value = Number(raw);
|
|
114
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureNotTooLarge(res: Response, maxBytes?: number): void {
|
|
118
|
+
if (!maxBytes || maxBytes <= 0) return;
|
|
119
|
+
const length = resolveContentLength(res);
|
|
120
|
+
if (length && length > maxBytes) {
|
|
121
|
+
const err = new Error(`${MEDIA_TOO_LARGE_ERROR}: content-length ${length} > limit ${maxBytes}`);
|
|
122
|
+
(err as { code?: string }).code = MEDIA_TOO_LARGE_ERROR;
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
export async function getWecomAccessToken(account: ResolvedWecomAccount): Promise<string> {
|
|
110
128
|
const { corpId, corpSecret } = ensureAppConfig(account);
|
|
111
129
|
const cacheKey = corpId;
|
|
@@ -335,8 +353,9 @@ export async function sendWecomFile(params: {
|
|
|
335
353
|
export async function downloadWecomMedia(params: {
|
|
336
354
|
account: ResolvedWecomAccount;
|
|
337
355
|
mediaId: string;
|
|
356
|
+
maxBytes?: number;
|
|
338
357
|
}): Promise<{ buffer: Buffer; contentType: string } > {
|
|
339
|
-
const { account, mediaId } = params;
|
|
358
|
+
const { account, mediaId, maxBytes } = params;
|
|
340
359
|
const accessToken = await getWecomAccessToken(account);
|
|
341
360
|
const mediaUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(accessToken)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
342
361
|
|
|
@@ -346,6 +365,7 @@ export async function downloadWecomMedia(params: {
|
|
|
346
365
|
}
|
|
347
366
|
|
|
348
367
|
const contentType = res.headers.get("content-type") || "";
|
|
368
|
+
ensureNotTooLarge(res, maxBytes);
|
|
349
369
|
if (contentType.includes("application/json")) {
|
|
350
370
|
const json = await res.json();
|
|
351
371
|
throw new Error(`WeCom media download failed: ${JSON.stringify(json)}`);
|
|
@@ -355,11 +375,16 @@ export async function downloadWecomMedia(params: {
|
|
|
355
375
|
return { buffer, contentType };
|
|
356
376
|
}
|
|
357
377
|
|
|
358
|
-
export async function fetchMediaFromUrl(
|
|
378
|
+
export async function fetchMediaFromUrl(
|
|
379
|
+
url: string,
|
|
380
|
+
account?: ResolvedWecomAccount,
|
|
381
|
+
maxBytes?: number,
|
|
382
|
+
): Promise<{ buffer: Buffer; contentType: string } > {
|
|
359
383
|
const res = account ? await fetchWithRetry(account, url) : await fetch(url);
|
|
360
384
|
if (!res.ok) {
|
|
361
385
|
throw new Error(`Failed to fetch media from URL: ${res.status}`);
|
|
362
386
|
}
|
|
387
|
+
ensureNotTooLarge(res, maxBytes);
|
|
363
388
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
364
389
|
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
365
390
|
return { buffer, contentType };
|
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { XMLParser } from "fast-xml-parser";
|
|
4
|
-
import { appendFile, mkdir, readFile, readdir,
|
|
5
|
-
import { homedir
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join } from "node:path";
|
|
7
7
|
|
|
8
8
|
import type { WecomWebhookTarget } from "./monitor.js";
|
|
@@ -19,7 +19,25 @@ import {
|
|
|
19
19
|
transcribeAudioWithOpenAI,
|
|
20
20
|
} from "./media-auto.js";
|
|
21
21
|
import { describeImageWithVision, resolveVisionConfig } from "./media-vision.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
MEDIA_TOO_LARGE_ERROR,
|
|
24
|
+
downloadWecomMedia,
|
|
25
|
+
fetchMediaFromUrl,
|
|
26
|
+
sendWecomFile,
|
|
27
|
+
sendWecomImage,
|
|
28
|
+
sendWecomText,
|
|
29
|
+
sendWecomVideo,
|
|
30
|
+
sendWecomVoice,
|
|
31
|
+
uploadWecomMedia,
|
|
32
|
+
} from "./wecom-api.js";
|
|
33
|
+
import {
|
|
34
|
+
cleanupMediaDir,
|
|
35
|
+
resolveExtFromContentType,
|
|
36
|
+
resolveMediaMaxBytes,
|
|
37
|
+
resolveMediaRetentionMs,
|
|
38
|
+
resolveMediaTempDir,
|
|
39
|
+
sanitizeFilename,
|
|
40
|
+
} from "./media-utils.js";
|
|
23
41
|
|
|
24
42
|
const xmlParser = new XMLParser({
|
|
25
43
|
ignoreAttributes: false,
|
|
@@ -138,6 +156,7 @@ function resolveSendIntervalMs(target: WecomWebhookTarget): number {
|
|
|
138
156
|
type PendingSendList = {
|
|
139
157
|
items: { name: string; path: string }[];
|
|
140
158
|
dirLabel: string;
|
|
159
|
+
offset: number;
|
|
141
160
|
createdAt: number;
|
|
142
161
|
expiresAt: number;
|
|
143
162
|
};
|
|
@@ -145,6 +164,7 @@ type PendingSendList = {
|
|
|
145
164
|
const pendingSendLists = new Map<string, PendingSendList>();
|
|
146
165
|
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
147
166
|
const MAX_LIST_PREVIEW = 30;
|
|
167
|
+
const LIST_MORE_PATTERN = /(更多|下一页|下页|继续|下一批|more|next)/i;
|
|
148
168
|
|
|
149
169
|
function pendingKey(fromUser: string, chatId?: string): string {
|
|
150
170
|
return chatId ? `${fromUser}::${chatId}` : fromUser;
|
|
@@ -214,6 +234,21 @@ function parseSelection(text: string, items: { name: string; path: string }[]):
|
|
|
214
234
|
return picked.length > 0 ? picked : null;
|
|
215
235
|
}
|
|
216
236
|
|
|
237
|
+
function buildPendingListText(pending: PendingSendList): { text: string; hasMore: boolean } {
|
|
238
|
+
const start = Math.max(0, pending.offset);
|
|
239
|
+
const total = pending.items.length;
|
|
240
|
+
const slice = pending.items.slice(start, start + MAX_LIST_PREVIEW);
|
|
241
|
+
const preview = slice
|
|
242
|
+
.map((item, idx) => `${start + idx + 1}. ${item.name}`)
|
|
243
|
+
.join("\n");
|
|
244
|
+
const hasMore = start + MAX_LIST_PREVIEW < total;
|
|
245
|
+
const tail = hasMore
|
|
246
|
+
? `\n…共 ${total} 个文件,回复“更多”查看下一页。`
|
|
247
|
+
: `\n共 ${total} 个文件。`;
|
|
248
|
+
const text = `在${pending.dirLabel}找到 ${total} 个文件:\n${preview}${tail}\n\n回复“全部”或“1 3 5”或直接发送具体文件名。`;
|
|
249
|
+
return { text, hasMore };
|
|
250
|
+
}
|
|
251
|
+
|
|
217
252
|
async function tryHandleNaturalFileSend(params: {
|
|
218
253
|
target: WecomWebhookTarget;
|
|
219
254
|
text: string;
|
|
@@ -227,6 +262,27 @@ async function tryHandleNaturalFileSend(params: {
|
|
|
227
262
|
const key = pendingKey(fromUser, chatId);
|
|
228
263
|
const pending = pendingSendLists.get(key);
|
|
229
264
|
if (pending) {
|
|
265
|
+
if (LIST_MORE_PATTERN.test(text)) {
|
|
266
|
+
const nextOffset = pending.offset + MAX_LIST_PREVIEW;
|
|
267
|
+
if (nextOffset >= pending.items.length) {
|
|
268
|
+
await sendWecomText({
|
|
269
|
+
account: target.account,
|
|
270
|
+
toUser: fromUser,
|
|
271
|
+
chatId: isGroup ? chatId : undefined,
|
|
272
|
+
text: "已经是最后一页了。",
|
|
273
|
+
});
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
pending.offset = nextOffset;
|
|
277
|
+
const { text: listText } = buildPendingListText(pending);
|
|
278
|
+
await sendWecomText({
|
|
279
|
+
account: target.account,
|
|
280
|
+
toUser: fromUser,
|
|
281
|
+
chatId: isGroup ? chatId : undefined,
|
|
282
|
+
text: listText,
|
|
283
|
+
});
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
230
286
|
const selection = parseSelection(text, pending.items);
|
|
231
287
|
if (selection) {
|
|
232
288
|
pendingSendLists.delete(key);
|
|
@@ -305,21 +361,19 @@ async function tryHandleNaturalFileSend(params: {
|
|
|
305
361
|
return true;
|
|
306
362
|
}
|
|
307
363
|
|
|
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
364
|
pendingSendLists.set(key, {
|
|
313
365
|
items: resolved,
|
|
314
366
|
dirLabel: searchDir.label,
|
|
367
|
+
offset: 0,
|
|
315
368
|
createdAt: Date.now(),
|
|
316
369
|
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
317
370
|
});
|
|
371
|
+
const { text: listText } = buildPendingListText(pendingSendLists.get(key)!);
|
|
318
372
|
await sendWecomText({
|
|
319
373
|
account: target.account,
|
|
320
374
|
toUser: fromUser,
|
|
321
375
|
chatId: isGroup ? chatId : undefined,
|
|
322
|
-
text:
|
|
376
|
+
text: listText,
|
|
323
377
|
});
|
|
324
378
|
return true;
|
|
325
379
|
}
|
|
@@ -406,62 +460,6 @@ function isTextCommand(text: string): boolean {
|
|
|
406
460
|
return text.trim().startsWith("/");
|
|
407
461
|
}
|
|
408
462
|
|
|
409
|
-
function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
410
|
-
if (!contentType) return fallback;
|
|
411
|
-
if (contentType.includes("png")) return "png";
|
|
412
|
-
if (contentType.includes("gif")) return "gif";
|
|
413
|
-
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
414
|
-
if (contentType.includes("mp4")) return "mp4";
|
|
415
|
-
if (contentType.includes("amr")) return "amr";
|
|
416
|
-
if (contentType.includes("wav")) return "wav";
|
|
417
|
-
if (contentType.includes("mp3")) return "mp3";
|
|
418
|
-
return fallback;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const cleanupExecuted = new Set<string>();
|
|
422
|
-
|
|
423
|
-
async function cleanupMediaDir(
|
|
424
|
-
dir: string,
|
|
425
|
-
retentionHours?: number,
|
|
426
|
-
cleanupOnStart?: boolean,
|
|
427
|
-
): Promise<void> {
|
|
428
|
-
if (cleanupOnStart === false) return;
|
|
429
|
-
if (!retentionHours || retentionHours <= 0) return;
|
|
430
|
-
if (cleanupExecuted.has(dir)) return;
|
|
431
|
-
cleanupExecuted.add(dir);
|
|
432
|
-
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
433
|
-
try {
|
|
434
|
-
const entries = await readdir(dir);
|
|
435
|
-
await Promise.all(entries.map(async (entry) => {
|
|
436
|
-
const full = join(dir, entry);
|
|
437
|
-
try {
|
|
438
|
-
const info = await stat(full);
|
|
439
|
-
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
440
|
-
await rm(full, { force: true });
|
|
441
|
-
}
|
|
442
|
-
} catch {
|
|
443
|
-
// ignore
|
|
444
|
-
}
|
|
445
|
-
}));
|
|
446
|
-
} catch {
|
|
447
|
-
// ignore
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
452
|
-
return target.account.config.media?.tempDir?.trim()
|
|
453
|
-
|| join(tmpdir(), "openclaw-wecom");
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
457
|
-
const maxBytes = target.account.config.media?.maxBytes;
|
|
458
|
-
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
|
|
462
|
-
const hours = target.account.config.media?.retentionHours;
|
|
463
|
-
return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
|
|
464
|
-
}
|
|
465
463
|
|
|
466
464
|
function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
|
|
467
465
|
if (!raw) return null;
|
|
@@ -477,6 +475,18 @@ function pickString(...values: unknown[]): string {
|
|
|
477
475
|
return "";
|
|
478
476
|
}
|
|
479
477
|
|
|
478
|
+
function isMediaTooLargeError(err: unknown): boolean {
|
|
479
|
+
if (!err) return false;
|
|
480
|
+
if (typeof err === "string") return err.includes(MEDIA_TOO_LARGE_ERROR);
|
|
481
|
+
if (typeof err === "object") {
|
|
482
|
+
const anyErr = err as { code?: string; message?: string };
|
|
483
|
+
if (anyErr.code === MEDIA_TOO_LARGE_ERROR) return true;
|
|
484
|
+
if (typeof anyErr.message === "string" && anyErr.message.includes(MEDIA_TOO_LARGE_ERROR)) return true;
|
|
485
|
+
if (typeof anyErr.message === "string" && anyErr.message.toLowerCase().includes("media too large")) return true;
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
|
|
480
490
|
function resolveContentTypeFromExt(ext: string): string {
|
|
481
491
|
const value = ext.toLowerCase();
|
|
482
492
|
if (value === "png") return "image/png";
|
|
@@ -588,7 +598,7 @@ async function loadOutboundMedia(params: {
|
|
|
588
598
|
contentType = resolveContentTypeFromExt(ext);
|
|
589
599
|
}
|
|
590
600
|
} else if (spec.url) {
|
|
591
|
-
const media = await fetchMediaFromUrl(spec.url, params.account);
|
|
601
|
+
const media = await fetchMediaFromUrl(spec.url, params.account, params.maxBytes);
|
|
592
602
|
buffer = media.buffer;
|
|
593
603
|
if (!contentType) contentType = media.contentType;
|
|
594
604
|
}
|
|
@@ -603,17 +613,6 @@ async function loadOutboundMedia(params: {
|
|
|
603
613
|
return { buffer, contentType: contentType || resolveContentTypeFromExt(ext), type, filename: safeName };
|
|
604
614
|
}
|
|
605
615
|
|
|
606
|
-
function sanitizeFilename(name: string, fallback: string): string {
|
|
607
|
-
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
608
|
-
const trimmed = base.trim();
|
|
609
|
-
const safe = trimmed
|
|
610
|
-
.replace(/[^\w.\-() ]+/g, "_")
|
|
611
|
-
.replace(/\s+/g, " ")
|
|
612
|
-
.trim();
|
|
613
|
-
const finalName = safe.slice(0, 120);
|
|
614
|
-
return finalName || fallback;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
616
|
function hashKey(input: string): string {
|
|
618
617
|
return crypto.createHash("sha1").update(input).digest("hex");
|
|
619
618
|
}
|
|
@@ -849,8 +848,8 @@ async function processAppMessage(params: {
|
|
|
849
848
|
logVerbose(target, `app voice cache hit: ${cached.path}`);
|
|
850
849
|
messageText = "[用户发送了一条语音消息]\n\n请根据语音内容回复用户。";
|
|
851
850
|
} else {
|
|
852
|
-
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
853
851
|
const maxBytes = resolveMediaMaxBytes(target);
|
|
852
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
854
853
|
if (maxBytes && media.buffer.length > maxBytes) {
|
|
855
854
|
messageText = "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
|
|
856
855
|
} else {
|
|
@@ -885,7 +884,9 @@ async function processAppMessage(params: {
|
|
|
885
884
|
}
|
|
886
885
|
} catch (err) {
|
|
887
886
|
target.runtime.error?.(`wecom app voice download failed: ${String(err)}`);
|
|
888
|
-
messageText =
|
|
887
|
+
messageText = isMediaTooLargeError(err)
|
|
888
|
+
? "[语音消息过大,未处理]\n\n请发送更短的语音消息。"
|
|
889
|
+
: "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。";
|
|
889
890
|
}
|
|
890
891
|
} else {
|
|
891
892
|
messageText = "[用户发送了一条语音消息]\n\n请告诉用户语音处理暂时不可用。";
|
|
@@ -896,6 +897,7 @@ async function processAppMessage(params: {
|
|
|
896
897
|
if (msgType === "image") {
|
|
897
898
|
const mediaId = String(msgObj?.MediaId ?? "");
|
|
898
899
|
const picUrl = String(msgObj?.PicUrl ?? "");
|
|
900
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
899
901
|
try {
|
|
900
902
|
const cacheKey = buildMediaCacheKey({ mediaId, url: picUrl });
|
|
901
903
|
const cached = await getCachedMedia(cacheKey, retentionMs);
|
|
@@ -911,11 +913,11 @@ async function processAppMessage(params: {
|
|
|
911
913
|
let buffer: Buffer | null = null;
|
|
912
914
|
let contentType = "";
|
|
913
915
|
if (mediaId) {
|
|
914
|
-
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
916
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
915
917
|
buffer = media.buffer;
|
|
916
918
|
contentType = media.contentType;
|
|
917
919
|
} else if (picUrl) {
|
|
918
|
-
const media = await fetchMediaFromUrl(picUrl, target.account);
|
|
920
|
+
const media = await fetchMediaFromUrl(picUrl, target.account, maxBytes);
|
|
919
921
|
buffer = media.buffer;
|
|
920
922
|
contentType = media.contentType;
|
|
921
923
|
}
|
|
@@ -973,7 +975,9 @@ async function processAppMessage(params: {
|
|
|
973
975
|
}
|
|
974
976
|
} catch (err) {
|
|
975
977
|
target.runtime.error?.(`wecom app image download failed: ${String(err)}`);
|
|
976
|
-
messageText =
|
|
978
|
+
messageText = isMediaTooLargeError(err)
|
|
979
|
+
? "[图片过大,未处理]\n\n请发送更小的图片。"
|
|
980
|
+
: "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
977
981
|
}
|
|
978
982
|
}
|
|
979
983
|
|
|
@@ -995,47 +999,49 @@ async function processAppMessage(params: {
|
|
|
995
999
|
logVerbose(target, `app video cache hit: ${cached.path}`);
|
|
996
1000
|
messageText = "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
|
|
997
1001
|
} else {
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1002
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
1003
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
1004
|
+
if (maxBytes && media.buffer.length > maxBytes) {
|
|
1005
|
+
messageText = "[视频过大,未处理]\n\n请发送更小的视频。";
|
|
1006
|
+
} else {
|
|
1007
|
+
const ext = resolveExtFromContentType(media.contentType, "mp4");
|
|
1008
|
+
const tempDir = resolveMediaTempDir(target);
|
|
1009
|
+
await mkdir(tempDir, { recursive: true });
|
|
1010
|
+
await cleanupMediaDir(
|
|
1011
|
+
tempDir,
|
|
1012
|
+
target.account.config.media?.retentionHours,
|
|
1013
|
+
target.account.config.media?.cleanupOnStart,
|
|
1014
|
+
);
|
|
1015
|
+
const tempVideoPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
1016
|
+
await writeFile(tempVideoPath, media.buffer);
|
|
1017
|
+
const mimeType = media.contentType || "video/mp4";
|
|
1018
|
+
mediaContext = { type: "video", path: tempVideoPath, mimeType };
|
|
1019
|
+
storeCachedMedia(cacheKey, {
|
|
1020
|
+
path: tempVideoPath,
|
|
1021
|
+
type: "video",
|
|
1022
|
+
mimeType,
|
|
1023
|
+
createdAt: Date.now(),
|
|
1024
|
+
size: media.buffer.length,
|
|
1025
|
+
});
|
|
1026
|
+
logVerbose(target, `app video saved (${media.buffer.length} bytes): ${tempVideoPath}`);
|
|
1027
|
+
const videoCfg = resolveAutoVideoConfig(target.account.config);
|
|
1028
|
+
const summary = videoCfg
|
|
1029
|
+
? await summarizeVideoWithVision({
|
|
1030
|
+
cfg: videoCfg,
|
|
1031
|
+
account: target.account.config,
|
|
1032
|
+
videoPath: tempVideoPath,
|
|
1033
|
+
})
|
|
1034
|
+
: null;
|
|
1035
|
+
messageText = summary
|
|
1036
|
+
? `[用户发送了一个视频文件]\n\n[视频画面概述]\n${summary}\n\n请根据视频内容回复用户。`
|
|
1037
|
+
: "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
|
|
1038
|
+
}
|
|
1035
1039
|
}
|
|
1036
1040
|
} catch (err) {
|
|
1037
1041
|
target.runtime.error?.(`wecom app video download failed: ${String(err)}`);
|
|
1038
|
-
messageText =
|
|
1042
|
+
messageText = isMediaTooLargeError(err)
|
|
1043
|
+
? "[视频过大,未处理]\n\n请发送更小的视频。"
|
|
1044
|
+
: "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
|
|
1039
1045
|
}
|
|
1040
1046
|
}
|
|
1041
1047
|
}
|
|
@@ -1059,44 +1065,46 @@ async function processAppMessage(params: {
|
|
|
1059
1065
|
? `[用户发送了一个文件: ${cachedName},已保存到: ${cached.path}]\n\n[文件内容预览]\n${preview}\n\n如需更多内容请使用 Read 工具。`
|
|
1060
1066
|
: `[用户发送了一个文件: ${cachedName},已保存到: ${cached.path}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
|
|
1061
1067
|
} else {
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1068
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
1069
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
1070
|
+
if (maxBytes && media.buffer.length > maxBytes) {
|
|
1071
|
+
messageText = "[文件过大,未处理]\n\n请发送更小的文件。";
|
|
1072
|
+
} else {
|
|
1073
|
+
const ext = fileName.includes(".") ? fileName.split(".").pop() : resolveExtFromContentType(media.contentType, "bin");
|
|
1074
|
+
const tempDir = resolveMediaTempDir(target);
|
|
1075
|
+
await mkdir(tempDir, { recursive: true });
|
|
1076
|
+
await cleanupMediaDir(
|
|
1077
|
+
tempDir,
|
|
1078
|
+
target.account.config.media?.retentionHours,
|
|
1079
|
+
target.account.config.media?.cleanupOnStart,
|
|
1080
|
+
);
|
|
1081
|
+
const safeName = sanitizeFilename(fileName, `file-${Date.now()}.${ext}`);
|
|
1082
|
+
const tempFilePath = join(tempDir, safeName);
|
|
1083
|
+
await writeFile(tempFilePath, media.buffer);
|
|
1084
|
+
const mimeType = media.contentType || "application/octet-stream";
|
|
1085
|
+
mediaContext = { type: "file", path: tempFilePath, mimeType };
|
|
1086
|
+
storeCachedMedia(cacheKey, {
|
|
1087
|
+
path: tempFilePath,
|
|
1088
|
+
type: "file",
|
|
1089
|
+
mimeType,
|
|
1090
|
+
createdAt: Date.now(),
|
|
1091
|
+
size: media.buffer.length,
|
|
1092
|
+
});
|
|
1093
|
+
logVerbose(target, `app file saved (${media.buffer.length} bytes): ${tempFilePath}`);
|
|
1094
|
+
const fileCfg = resolveAutoFileConfig(target.account.config);
|
|
1095
|
+
const preview = fileCfg
|
|
1096
|
+
? await extractFileTextPreview({ path: tempFilePath, mimeType, cfg: fileCfg })
|
|
1097
|
+
: null;
|
|
1098
|
+
messageText = preview
|
|
1099
|
+
? `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n[文件内容预览]\n${preview}\n\n如需更多内容请使用 Read 工具。`
|
|
1100
|
+
: `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
|
|
1101
|
+
}
|
|
1096
1102
|
}
|
|
1097
1103
|
} catch (err) {
|
|
1098
1104
|
target.runtime.error?.(`wecom app file download failed: ${String(err)}`);
|
|
1099
|
-
messageText =
|
|
1105
|
+
messageText = isMediaTooLargeError(err)
|
|
1106
|
+
? "[文件过大,未处理]\n\n请发送更小的文件。"
|
|
1107
|
+
: "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
|
|
1100
1108
|
}
|
|
1101
1109
|
}
|
|
1102
1110
|
}
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { mkdir, readFile,
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
3
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
4
|
import { basename, extname, join } from "node:path";
|
|
6
5
|
|
|
7
6
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
@@ -10,6 +9,7 @@ import type { WecomWebhookTarget } from "./monitor.js";
|
|
|
10
9
|
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
|
|
11
10
|
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature } from "./crypto.js";
|
|
12
11
|
import {
|
|
12
|
+
MEDIA_TOO_LARGE_ERROR,
|
|
13
13
|
downloadWecomMedia,
|
|
14
14
|
fetchMediaFromUrl,
|
|
15
15
|
sendWecomFile,
|
|
@@ -28,6 +28,14 @@ import {
|
|
|
28
28
|
summarizeVideoWithVision,
|
|
29
29
|
transcribeAudioWithOpenAI,
|
|
30
30
|
} from "./media-auto.js";
|
|
31
|
+
import {
|
|
32
|
+
cleanupMediaDir,
|
|
33
|
+
resolveExtFromContentType,
|
|
34
|
+
resolveMediaMaxBytes,
|
|
35
|
+
resolveMediaRetentionMs,
|
|
36
|
+
resolveMediaTempDir,
|
|
37
|
+
sanitizeFilename,
|
|
38
|
+
} from "./media-utils.js";
|
|
31
39
|
|
|
32
40
|
const STREAM_TTL_MS = 10 * 60 * 1000;
|
|
33
41
|
const STREAM_MAX_BYTES = 20_480;
|
|
@@ -36,7 +44,6 @@ const DEDUPE_TTL_MS = 2 * 60 * 1000;
|
|
|
36
44
|
const DEDUPE_MAX_ENTRIES = 2_000;
|
|
37
45
|
const MEDIA_CACHE_MAX_ENTRIES = 200;
|
|
38
46
|
|
|
39
|
-
const cleanupExecuted = new Set<string>();
|
|
40
47
|
const mediaCache = new Map<string, { entry: InboundMedia; createdAt: number; size: number; summary?: string }>();
|
|
41
48
|
|
|
42
49
|
type StreamState = {
|
|
@@ -111,66 +118,6 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
|
|
111
118
|
return slice.toString("utf8");
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
115
|
-
if (!contentType) return fallback;
|
|
116
|
-
if (contentType.includes("png")) return "png";
|
|
117
|
-
if (contentType.includes("gif")) return "gif";
|
|
118
|
-
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
119
|
-
if (contentType.includes("mp4")) return "mp4";
|
|
120
|
-
if (contentType.includes("amr")) return "amr";
|
|
121
|
-
if (contentType.includes("wav")) return "wav";
|
|
122
|
-
if (contentType.includes("mp3")) return "mp3";
|
|
123
|
-
return fallback;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function cleanupMediaDir(
|
|
127
|
-
dir: string,
|
|
128
|
-
retentionHours?: number,
|
|
129
|
-
cleanupOnStart?: boolean,
|
|
130
|
-
): Promise<void> {
|
|
131
|
-
if (cleanupOnStart === false) return;
|
|
132
|
-
if (!retentionHours || retentionHours <= 0) return;
|
|
133
|
-
if (cleanupExecuted.has(dir)) return;
|
|
134
|
-
cleanupExecuted.add(dir);
|
|
135
|
-
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
136
|
-
try {
|
|
137
|
-
const entries = await readdir(dir);
|
|
138
|
-
await Promise.all(entries.map(async (entry) => {
|
|
139
|
-
const full = join(dir, entry);
|
|
140
|
-
try {
|
|
141
|
-
const info = await stat(full);
|
|
142
|
-
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
143
|
-
await rm(full, { force: true });
|
|
144
|
-
}
|
|
145
|
-
} catch {
|
|
146
|
-
// ignore
|
|
147
|
-
}
|
|
148
|
-
}));
|
|
149
|
-
} catch {
|
|
150
|
-
// ignore
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
155
|
-
return target.account.config.media?.tempDir?.trim()
|
|
156
|
-
|| join(tmpdir(), "openclaw-wecom");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
160
|
-
const maxBytes = target.account.config.media?.maxBytes;
|
|
161
|
-
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function sanitizeFilename(name: string, fallback: string): string {
|
|
165
|
-
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
166
|
-
const trimmed = base.trim();
|
|
167
|
-
const safe = trimmed
|
|
168
|
-
.replace(/[^\w.\-() ]+/g, "_")
|
|
169
|
-
.replace(/\s+/g, " ")
|
|
170
|
-
.trim();
|
|
171
|
-
const finalName = safe.slice(0, 120);
|
|
172
|
-
return finalName || fallback;
|
|
173
|
-
}
|
|
174
121
|
|
|
175
122
|
function jsonOk(res: ServerResponse, body: unknown): void {
|
|
176
123
|
res.statusCode = 200;
|
|
@@ -503,6 +450,18 @@ function pickString(...values: unknown[]): string {
|
|
|
503
450
|
return "";
|
|
504
451
|
}
|
|
505
452
|
|
|
453
|
+
function isMediaTooLargeError(err: unknown): boolean {
|
|
454
|
+
if (!err) return false;
|
|
455
|
+
if (typeof err === "string") return err.includes(MEDIA_TOO_LARGE_ERROR);
|
|
456
|
+
if (typeof err === "object") {
|
|
457
|
+
const anyErr = err as { code?: string; message?: string };
|
|
458
|
+
if (anyErr.code === MEDIA_TOO_LARGE_ERROR) return true;
|
|
459
|
+
if (typeof anyErr.message === "string" && anyErr.message.includes(MEDIA_TOO_LARGE_ERROR)) return true;
|
|
460
|
+
if (typeof anyErr.message === "string" && anyErr.message.toLowerCase().includes("media too large")) return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
506
465
|
function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
|
|
507
466
|
if (!msg || typeof msg !== "object") return "";
|
|
508
467
|
const block = msg[msgtype] ?? {};
|
|
@@ -628,9 +587,15 @@ async function buildBotMediaMessage(params: {
|
|
|
628
587
|
: "[file]";
|
|
629
588
|
|
|
630
589
|
if (!url && !base64 && !mediaId) return { text: fallbackLabel };
|
|
590
|
+
const hasAppCreds = Boolean(target.account.corpId && target.account.corpSecret && target.account.agentId);
|
|
591
|
+
if (!url && !base64 && mediaId && !hasAppCreds) {
|
|
592
|
+
return {
|
|
593
|
+
text: "[用户发送了媒体,但当前仅配置 Bot]\n\n未配置 App 凭据,无法下载或识别媒体内容。请补充 corpId/corpSecret/agentId。",
|
|
594
|
+
};
|
|
595
|
+
}
|
|
631
596
|
|
|
632
597
|
try {
|
|
633
|
-
const cacheKey = buildMediaCacheKey({ url, base64 });
|
|
598
|
+
const cacheKey = buildMediaCacheKey({ url, base64, mediaId });
|
|
634
599
|
const cached = await getCachedMedia(cacheKey, resolveMediaRetentionMs(target));
|
|
635
600
|
if (cached) {
|
|
636
601
|
if (msgtype === "image" && cached.summary) {
|
|
@@ -660,6 +625,7 @@ async function buildBotMediaMessage(params: {
|
|
|
660
625
|
|
|
661
626
|
let buffer: Buffer | null = null;
|
|
662
627
|
let contentType = "";
|
|
628
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
663
629
|
if (base64) {
|
|
664
630
|
const parsed = parseBase64Input(base64);
|
|
665
631
|
buffer = Buffer.from(parsed.data, "base64");
|
|
@@ -671,18 +637,17 @@ async function buildBotMediaMessage(params: {
|
|
|
671
637
|
else contentType = "application/octet-stream";
|
|
672
638
|
}
|
|
673
639
|
} else if (url) {
|
|
674
|
-
const media = await fetchMediaFromUrl(url, target.account);
|
|
640
|
+
const media = await fetchMediaFromUrl(url, target.account, maxBytes);
|
|
675
641
|
buffer = media.buffer;
|
|
676
642
|
contentType = media.contentType;
|
|
677
|
-
} else if (mediaId &&
|
|
678
|
-
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
643
|
+
} else if (mediaId && hasAppCreds) {
|
|
644
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
|
|
679
645
|
buffer = media.buffer;
|
|
680
646
|
contentType = media.contentType;
|
|
681
647
|
}
|
|
682
648
|
|
|
683
649
|
if (!buffer) return { text: fallbackLabel };
|
|
684
650
|
|
|
685
|
-
const maxBytes = resolveMediaMaxBytes(target);
|
|
686
651
|
if (maxBytes && buffer.length > maxBytes) {
|
|
687
652
|
if (msgtype === "image") return { text: "[图片过大,未处理]\n\n请发送更小的图片。" };
|
|
688
653
|
if (msgtype === "voice") return { text: "[语音消息过大,未处理]\n\n请发送更短的语音消息。" };
|
|
@@ -798,6 +763,12 @@ async function buildBotMediaMessage(params: {
|
|
|
798
763
|
return { text: fallbackLabel };
|
|
799
764
|
} catch (err) {
|
|
800
765
|
target.runtime.error?.(`wecom bot ${msgtype} download failed: ${String(err)}`);
|
|
766
|
+
if (isMediaTooLargeError(err)) {
|
|
767
|
+
if (msgtype === "image") return { text: "[图片过大,未处理]\n\n请发送更小的图片。" };
|
|
768
|
+
if (msgtype === "voice") return { text: "[语音消息过大,未处理]\n\n请发送更短的语音消息。" };
|
|
769
|
+
if (msgtype === "video") return { text: "[视频过大,未处理]\n\n请发送更小的视频。" };
|
|
770
|
+
return { text: "[文件过大,未处理]\n\n请发送更小的文件。" };
|
|
771
|
+
}
|
|
801
772
|
if (msgtype === "image") return { text: "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。" };
|
|
802
773
|
if (msgtype === "voice") return { text: "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。" };
|
|
803
774
|
if (msgtype === "video") return { text: "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。" };
|
|
@@ -991,7 +962,7 @@ async function loadOutboundMedia(params: {
|
|
|
991
962
|
contentType = resolveContentTypeFromExt(ext);
|
|
992
963
|
}
|
|
993
964
|
} else if (spec.url) {
|
|
994
|
-
const media = await fetchMediaFromUrl(spec.url, params.account);
|
|
965
|
+
const media = await fetchMediaFromUrl(spec.url, params.account, params.maxBytes);
|
|
995
966
|
buffer = media.buffer;
|
|
996
967
|
if (!contentType) contentType = media.contentType;
|
|
997
968
|
}
|
|
@@ -1014,16 +985,12 @@ function mediaSentLabel(type: string): string {
|
|
|
1014
985
|
return "[已发送媒体]";
|
|
1015
986
|
}
|
|
1016
987
|
|
|
1017
|
-
function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
|
|
1018
|
-
const hours = target.account.config.media?.retentionHours;
|
|
1019
|
-
return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
988
|
function hashCacheKey(input: string): string {
|
|
1023
989
|
return crypto.createHash("sha1").update(input).digest("hex");
|
|
1024
990
|
}
|
|
1025
991
|
|
|
1026
|
-
function buildMediaCacheKey(params: { url?: string; base64?: string }): string | null {
|
|
992
|
+
function buildMediaCacheKey(params: { url?: string; base64?: string; mediaId?: string }): string | null {
|
|
993
|
+
if (params.mediaId) return `media:${params.mediaId}`;
|
|
1027
994
|
if (params.url) return `url:${hashCacheKey(params.url)}`;
|
|
1028
995
|
if (params.base64) return `b64:${hashCacheKey(params.base64)}`;
|
|
1029
996
|
return null;
|