@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 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
@@ -132,6 +132,7 @@ openclaw gateway restart
132
132
  - `/sendfile` 仅支持 **服务器绝对路径**
133
133
  - 目录会自动打包为 zip 再发送
134
134
  - 自然语言也可触发:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
135
+ - 如匹配多个文件,会返回列表让你确认(回复“全部”或序号)
135
136
 
136
137
  示例:
137
138
  ```
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.25",
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> {