@marshulll/openclaw-wecom 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/docs/INSTALL.md +2 -0
- package/package.json +1 -1
- package/wecom/src/wecom-app.ts +269 -1
package/README.en.md
CHANGED
|
@@ -93,6 +93,8 @@ Install guide: `docs/INSTALL.md`
|
|
|
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
|
+
- Natural language also works: "send me this file image-xxx.jpg" (default match in `media.tempDir`)
|
|
97
|
+
- If multiple matches are found, a list will be returned for confirmation
|
|
96
98
|
|
|
97
99
|
## Media auto recognition (optional)
|
|
98
100
|
- **Voice send/receive does NOT require API**; only auto transcription needs an OpenAI-compatible API
|
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ openclaw gateway restart
|
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
|
+
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
98
100
|
|
|
99
101
|
## 多媒体自动识别(可选)
|
|
100
102
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
package/README.zh.md
CHANGED
|
@@ -95,6 +95,8 @@ openclaw gateway restart
|
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
|
+
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
98
100
|
|
|
99
101
|
## 多媒体自动识别(可选)
|
|
100
102
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
package/docs/INSTALL.md
CHANGED
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";
|
|
@@ -130,6 +130,263 @@ function sleep(ms: number): Promise<void> {
|
|
|
130
130
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function resolveSendIntervalMs(target: WecomWebhookTarget): number {
|
|
134
|
+
const interval = target.account.config.sendQueue?.intervalMs;
|
|
135
|
+
return typeof interval === "number" && interval >= 0 ? interval : 400;
|
|
136
|
+
}
|
|
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
|
+
|
|
160
|
+
function extractFilenameCandidates(text: string): string[] {
|
|
161
|
+
const candidates = new Set<string>();
|
|
162
|
+
const normalized = text.replace(/[,,;;|]/g, " ");
|
|
163
|
+
const regex = /(?:\/|file:\/\/)?[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}/g;
|
|
164
|
+
for (const match of normalized.matchAll(regex)) {
|
|
165
|
+
const value = match[0];
|
|
166
|
+
if (value) candidates.add(value.replace(/^file:\/\//, ""));
|
|
167
|
+
}
|
|
168
|
+
return Array.from(candidates);
|
|
169
|
+
}
|
|
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
|
+
|
|
217
|
+
async function tryHandleNaturalFileSend(params: {
|
|
218
|
+
target: WecomWebhookTarget;
|
|
219
|
+
text: string;
|
|
220
|
+
fromUser: string;
|
|
221
|
+
chatId?: string;
|
|
222
|
+
isGroup: boolean;
|
|
223
|
+
}): Promise<boolean> {
|
|
224
|
+
const { target, text, fromUser, chatId, isGroup } = params;
|
|
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
|
+
|
|
238
|
+
if (!/(发给我|发送给我|发我|给我)/.test(text)) return false;
|
|
239
|
+
const names = extractFilenameCandidates(text);
|
|
240
|
+
const ext = extractExtension(text);
|
|
241
|
+
if (names.length === 0 && !ext) return false;
|
|
242
|
+
|
|
243
|
+
const searchDir = resolveSearchDir(text, target);
|
|
244
|
+
let dirEntries: string[] = [];
|
|
245
|
+
try {
|
|
246
|
+
dirEntries = await readdir(searchDir.path);
|
|
247
|
+
} catch {
|
|
248
|
+
dirEntries = [];
|
|
249
|
+
}
|
|
250
|
+
const dirSet = new Set(dirEntries);
|
|
251
|
+
|
|
252
|
+
const resolved: { name: string; path: string }[] = [];
|
|
253
|
+
const missing: string[] = [];
|
|
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) {
|
|
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
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (resolved.length === 0) {
|
|
293
|
+
const hint = dirEntries.length ? `可用文件示例:${dirEntries.slice(0, 5).join(", ")}` : "当前目录无可用文件";
|
|
294
|
+
await sendWecomText({
|
|
295
|
+
account: target.account,
|
|
296
|
+
toUser: fromUser,
|
|
297
|
+
chatId: isGroup ? chatId : undefined,
|
|
298
|
+
text: `未找到指定文件:${missing.join(", ")}。\n${hint}`,
|
|
299
|
+
});
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
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;
|
|
335
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
336
|
+
const intervalMs = resolveSendIntervalMs(target);
|
|
337
|
+
let sent = 0;
|
|
338
|
+
const failed: string[] = [];
|
|
339
|
+
for (const item of items) {
|
|
340
|
+
try {
|
|
341
|
+
const info = await stat(item.path);
|
|
342
|
+
if (maxBytes && info.size > maxBytes) {
|
|
343
|
+
failed.push(`${item.name}(过大)`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const buffer = await readFile(item.path);
|
|
347
|
+
const mediaId = await uploadWecomMedia({
|
|
348
|
+
account: target.account,
|
|
349
|
+
type: "file",
|
|
350
|
+
buffer,
|
|
351
|
+
filename: item.name,
|
|
352
|
+
});
|
|
353
|
+
await sendWecomFile({
|
|
354
|
+
account: target.account,
|
|
355
|
+
toUser: fromUser,
|
|
356
|
+
chatId: isGroup ? chatId : undefined,
|
|
357
|
+
mediaId,
|
|
358
|
+
});
|
|
359
|
+
sent += 1;
|
|
360
|
+
await appendOperationLog(target, {
|
|
361
|
+
action: "natural-sendfile",
|
|
362
|
+
accountId: target.account.accountId,
|
|
363
|
+
toUser: fromUser,
|
|
364
|
+
chatId,
|
|
365
|
+
path: item.path,
|
|
366
|
+
size: info.size,
|
|
367
|
+
});
|
|
368
|
+
if (intervalMs) await sleep(intervalMs);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
failed.push(item.name);
|
|
371
|
+
await appendOperationLog(target, {
|
|
372
|
+
action: "natural-sendfile",
|
|
373
|
+
accountId: target.account.accountId,
|
|
374
|
+
toUser: fromUser,
|
|
375
|
+
chatId,
|
|
376
|
+
path: item.path,
|
|
377
|
+
error: String(err),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const summary = `已发送 ${sent} 个文件${failed.length ? `,失败:${failed.join(", ")}` : ""}`;
|
|
382
|
+
await sendWecomText({
|
|
383
|
+
account: target.account,
|
|
384
|
+
toUser: fromUser,
|
|
385
|
+
chatId: isGroup ? chatId : undefined,
|
|
386
|
+
text: summary,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
133
390
|
async function appendOperationLog(target: WecomWebhookTarget, entry: Record<string, unknown>): Promise<void> {
|
|
134
391
|
const logPath = target.account.config.operations?.logPath?.trim();
|
|
135
392
|
if (!logPath) return;
|
|
@@ -861,6 +1118,17 @@ async function processAppMessage(params: {
|
|
|
861
1118
|
if (handled) return;
|
|
862
1119
|
}
|
|
863
1120
|
|
|
1121
|
+
if (msgType === "text") {
|
|
1122
|
+
const handled = await tryHandleNaturalFileSend({
|
|
1123
|
+
target,
|
|
1124
|
+
text: messageText,
|
|
1125
|
+
fromUser,
|
|
1126
|
+
chatId,
|
|
1127
|
+
isGroup,
|
|
1128
|
+
});
|
|
1129
|
+
if (handled) return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
864
1132
|
try {
|
|
865
1133
|
await startAgentForApp({
|
|
866
1134
|
target,
|