@marshulll/openclaw-wecom 0.1.24 → 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 +1 -0
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/docs/INSTALL.md +1 -0
- package/package.json +1 -1
- package/wecom/src/wecom-app.ts +160 -36
package/README.en.md
CHANGED
|
@@ -94,6 +94,7 @@ Install guide: `docs/INSTALL.md`
|
|
|
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
|
+
- If multiple matches are found, a list will be returned for confirmation
|
|
97
98
|
|
|
98
99
|
## Media auto recognition (optional)
|
|
99
100
|
- **Voice send/receive does NOT require API**; only auto transcription needs an OpenAI-compatible API
|
package/README.md
CHANGED
|
@@ -96,6 +96,7 @@ openclaw gateway restart
|
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
98
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
99
100
|
|
|
100
101
|
## 多媒体自动识别(可选)
|
|
101
102
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
package/README.zh.md
CHANGED
|
@@ -96,6 +96,7 @@ openclaw gateway restart
|
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
98
|
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
99
|
+
- 多文件会先返回列表,回复“全部”或序号再发送
|
|
99
100
|
|
|
100
101
|
## 多媒体自动识别(可选)
|
|
101
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";
|
|
@@ -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> {
|