@openclaw-china/wecom-app 0.1.2 → 0.1.3

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/dist/index.d.ts CHANGED
@@ -41,6 +41,16 @@ type WecomAppAccountConfig = {
41
41
  };
42
42
  /** 媒体文件大小限制 (MB),默认 100 */
43
43
  maxFileSizeMB?: number;
44
+ /**
45
+ * 语音发送转码策略(可选)
46
+ * enabled=true 时:当检测到 wav/mp3 等不支持的语音格式,
47
+ * - 若系统存在 ffmpeg:自动转码为 amr 再以 voice 发送
48
+ * - 若无 ffmpeg:降级为 file 发送
49
+ */
50
+ voiceTranscode?: {
51
+ enabled?: boolean;
52
+ prefer?: "amr";
53
+ };
44
54
  /** 欢迎文本 */
45
55
  welcomeText?: string;
46
56
  /** DM 策略 */
@@ -281,6 +291,19 @@ declare const wecomAppPlugin: {
281
291
  };
282
292
  };
283
293
  };
294
+ voiceTranscode: {
295
+ type: string;
296
+ additionalProperties: boolean;
297
+ properties: {
298
+ enabled: {
299
+ type: string;
300
+ };
301
+ prefer: {
302
+ type: string;
303
+ enum: string[];
304
+ };
305
+ };
306
+ };
284
307
  welcomeText: {
285
308
  type: string;
286
309
  };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { homedir, tmpdir } from 'os';
2
+ import { join, extname, basename } from 'path';
1
3
  import crypto from 'crypto';
2
- import { mkdir, writeFile, unlink } from 'fs/promises';
3
- import { extname, join } from 'path';
4
- import { tmpdir } from 'os';
4
+ import { mkdir, writeFile, rename, unlink, readdir, stat } from 'fs/promises';
5
+ import { spawn } from 'child_process';
5
6
 
6
7
  var __defProp = Object.defineProperty;
7
8
  var __export = (target, all) => {
@@ -4048,8 +4049,6 @@ var coerce = {
4048
4049
  date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
4049
4050
  };
4050
4051
  var NEVER = INVALID;
4051
-
4052
- // src/config.ts
4053
4052
  var DEFAULT_ACCOUNT_ID = "default";
4054
4053
  var WecomAppAccountSchema = external_exports.object({
4055
4054
  name: external_exports.string().optional(),
@@ -4071,6 +4070,13 @@ var WecomAppAccountSchema = external_exports.object({
4071
4070
  maxBytes: external_exports.number().optional(),
4072
4071
  keepDays: external_exports.number().optional()
4073
4072
  }).optional(),
4073
+ // 语音发送策略(可选):当遇到不支持的格式(如 wav/mp3)时,
4074
+ // - enabled=true 且系统存在 ffmpeg:自动转码为 amr 后再发送 voice
4075
+ // - 否则:降级为 file 发送(并可配合 caption 提示)
4076
+ voiceTranscode: external_exports.object({
4077
+ enabled: external_exports.boolean().optional(),
4078
+ prefer: external_exports.enum(["amr"]).optional()
4079
+ }).optional(),
4074
4080
  // 其他字段
4075
4081
  welcomeText: external_exports.string().optional(),
4076
4082
  dmPolicy: external_exports.enum(["open", "pairing", "allowlist", "disabled"]).optional(),
@@ -4107,6 +4113,14 @@ var WecomAppConfigJsonSchema = {
4107
4113
  keepDays: { type: "number" }
4108
4114
  }
4109
4115
  },
4116
+ voiceTranscode: {
4117
+ type: "object",
4118
+ additionalProperties: false,
4119
+ properties: {
4120
+ enabled: { type: "boolean" },
4121
+ prefer: { type: "string", enum: ["amr"] }
4122
+ }
4123
+ },
4110
4124
  welcomeText: { type: "string" },
4111
4125
  dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist", "disabled"] },
4112
4126
  allowFrom: { type: "array", items: { type: "string" } },
@@ -4234,15 +4248,24 @@ function resolveAllowFrom(config) {
4234
4248
  function resolveGroupAllowFrom(config) {
4235
4249
  return config.groupAllowFrom ?? [];
4236
4250
  }
4251
+ var DEFAULT_INBOUND_MEDIA_DIR = join(homedir(), ".openclaw", "media", "wecom-app", "inbound");
4237
4252
  var DEFAULT_INBOUND_MEDIA_MAX_BYTES = 10 * 1024 * 1024;
4253
+ var DEFAULT_INBOUND_MEDIA_KEEP_DAYS = 7;
4238
4254
  function resolveInboundMediaEnabled(config) {
4239
4255
  if (typeof config.inboundMedia?.enabled === "boolean") return config.inboundMedia.enabled;
4240
4256
  return true;
4241
4257
  }
4258
+ function resolveInboundMediaDir(config) {
4259
+ return (config.inboundMedia?.dir ?? "").trim() || DEFAULT_INBOUND_MEDIA_DIR;
4260
+ }
4242
4261
  function resolveInboundMediaMaxBytes(config) {
4243
4262
  const v = config.inboundMedia?.maxBytes;
4244
4263
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : DEFAULT_INBOUND_MEDIA_MAX_BYTES;
4245
4264
  }
4265
+ function resolveInboundMediaKeepDays(config) {
4266
+ const v = config.inboundMedia?.keepDays;
4267
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : DEFAULT_INBOUND_MEDIA_KEEP_DAYS;
4268
+ }
4246
4269
 
4247
4270
  // ../../packages/shared/src/logger/logger.ts
4248
4271
  function createLogger(prefix, opts) {
@@ -4449,6 +4472,83 @@ function encryptWecomAppPlaintext(params) {
4449
4472
  return encrypted.toString("base64");
4450
4473
  }
4451
4474
  var DOWNLOAD_TIMEOUT = 12e4;
4475
+ function formatDateDir(d = /* @__PURE__ */ new Date()) {
4476
+ const yyyy = d.getFullYear();
4477
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
4478
+ const dd = String(d.getDate()).padStart(2, "0");
4479
+ return `${yyyy}-${mm}-${dd}`;
4480
+ }
4481
+ function isProbablyInWecomTmpDir(p) {
4482
+ try {
4483
+ const base = join(tmpdir(), "wecom-app-media");
4484
+ const norm = (s) => s.replace(/\\/g, "/").toLowerCase();
4485
+ return norm(p).includes(norm(base));
4486
+ } catch {
4487
+ return false;
4488
+ }
4489
+ }
4490
+ async function finalizeInboundMedia(account, filePath) {
4491
+ const p = String(filePath ?? "").trim();
4492
+ if (!p) return p;
4493
+ if (!isProbablyInWecomTmpDir(p)) return p;
4494
+ const baseDir = resolveInboundMediaDir(account.config ?? {});
4495
+ const datedDir = join(baseDir, formatDateDir());
4496
+ await mkdir(datedDir, { recursive: true });
4497
+ const name = basename(p);
4498
+ const dest = join(datedDir, name);
4499
+ try {
4500
+ await rename(p, dest);
4501
+ return dest;
4502
+ } catch {
4503
+ try {
4504
+ await unlink(p);
4505
+ } catch {
4506
+ }
4507
+ return p;
4508
+ }
4509
+ }
4510
+ async function pruneInboundMediaDir(account) {
4511
+ const baseDir = resolveInboundMediaDir(account.config ?? {});
4512
+ const keepDays = resolveInboundMediaKeepDays(account.config ?? {});
4513
+ if (keepDays < 0) return;
4514
+ const now = Date.now();
4515
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1e3;
4516
+ let entries;
4517
+ try {
4518
+ entries = await readdir(baseDir);
4519
+ } catch {
4520
+ return;
4521
+ }
4522
+ for (const entry of entries) {
4523
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry)) continue;
4524
+ const dirPath = join(baseDir, entry);
4525
+ let st;
4526
+ try {
4527
+ st = await stat(dirPath);
4528
+ } catch {
4529
+ continue;
4530
+ }
4531
+ if (!st.isDirectory()) continue;
4532
+ const dirTime = st.mtimeMs || st.ctimeMs || 0;
4533
+ if (dirTime >= cutoff) continue;
4534
+ let files = [];
4535
+ try {
4536
+ files = await readdir(dirPath);
4537
+ } catch {
4538
+ continue;
4539
+ }
4540
+ for (const f of files) {
4541
+ const fp = join(dirPath, f);
4542
+ try {
4543
+ const fst = await stat(fp);
4544
+ if (fst.isFile() && (fst.mtimeMs || fst.ctimeMs || 0) < cutoff) {
4545
+ await unlink(fp);
4546
+ }
4547
+ } catch {
4548
+ }
4549
+ }
4550
+ }
4551
+ }
4452
4552
  var FileSizeLimitError2 = class _FileSizeLimitError extends Error {
4453
4553
  actualSize;
4454
4554
  limitSize;
@@ -4584,12 +4684,6 @@ function parseContentDispositionFilename(headerValue) {
4584
4684
  if (m2?.[1]) return m2[1].trim().replace(/^"|"$/g, "");
4585
4685
  return void 0;
4586
4686
  }
4587
- async function cleanupFile(filePath) {
4588
- try {
4589
- await unlink(filePath);
4590
- } catch {
4591
- }
4592
- }
4593
4687
  function getWecomTempDir() {
4594
4688
  return join(tmpdir(), "wecom-app-media");
4595
4689
  }
@@ -5036,6 +5130,10 @@ async function downloadVoice(voiceUrl) {
5036
5130
  async function downloadAndSendVoice(account, target, voiceUrl) {
5037
5131
  try {
5038
5132
  console.log(`[wecom-app] Downloading voice from: ${voiceUrl}`);
5133
+ const voiceExt = (voiceUrl.split("?")[0].match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
5134
+ if (voiceExt === "wav") {
5135
+ console.warn(`[wecom-app] Voice format is .wav; WeCom usually expects .amr/.speex. Consider converting to .amr before sending.`);
5136
+ }
5039
5137
  const { buffer: voiceBuffer, contentType } = await downloadVoice(voiceUrl);
5040
5138
  console.log(`[wecom-app] Voice downloaded, size: ${voiceBuffer.length} bytes, contentType: ${contentType || "unknown"}`);
5041
5139
  const extMatch = voiceUrl.match(/\.([^.]+)$/);
@@ -5050,10 +5148,13 @@ async function downloadAndSendVoice(account, target, voiceUrl) {
5050
5148
  return result;
5051
5149
  } catch (err) {
5052
5150
  console.error(`[wecom-app] downloadAndSendVoice error:`, err);
5151
+ const rawMsg = err instanceof Error ? err.message : String(err);
5152
+ const voiceExt = (voiceUrl.split("?")[0].match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
5153
+ const hint = voiceExt === "wav" ? "WeCom voice usually requires .amr/.speex. Your file is .wav; please convert to .amr and retry. (e.g., ffmpeg -i in.wav -ar 8000 -ac 1 -c:a amr_nb out.amr)" : "";
5053
5154
  return {
5054
5155
  ok: false,
5055
5156
  errcode: -1,
5056
- errmsg: err instanceof Error ? err.message : String(err)
5157
+ errmsg: hint ? `${rawMsg} | hint: ${hint}` : rawMsg
5057
5158
  };
5058
5159
  }
5059
5160
  }
@@ -5166,9 +5267,26 @@ async function downloadAndSendFile(account, target, fileUrl) {
5166
5267
  console.log(`[wecom-app] Downloading file from: ${fileUrl}`);
5167
5268
  const { buffer: fileBuffer, contentType } = await downloadFile(fileUrl);
5168
5269
  console.log(`[wecom-app] File downloaded, size: ${fileBuffer.length} bytes, contentType: ${contentType || "unknown"}`);
5169
- const extMatch = fileUrl.match(/\.([^.]+)$/);
5170
- const ext = extMatch ? `.${extMatch[1]}` : ".bin";
5171
- const filename = `file${ext}`;
5270
+ let filename = "file.bin";
5271
+ try {
5272
+ if (!fileUrl.startsWith("http://") && !fileUrl.startsWith("https://")) {
5273
+ const path = await import('path');
5274
+ const base = path.basename(fileUrl);
5275
+ if (base && base !== "." && base !== "/") {
5276
+ filename = base;
5277
+ }
5278
+ } else {
5279
+ const u = new URL(fileUrl);
5280
+ const base = u.pathname.split("/").filter(Boolean).pop();
5281
+ if (base) filename = base;
5282
+ }
5283
+ } catch {
5284
+ }
5285
+ if (!/\.[A-Za-z0-9]{1,10}$/.test(filename)) {
5286
+ const extMatch = fileUrl.split("?")[0].match(/\.([^.]+)$/);
5287
+ const ext = extMatch ? `.${extMatch[1]}` : ".bin";
5288
+ filename = `file${ext}`;
5289
+ }
5172
5290
  console.log(`[wecom-app] Uploading file to WeCom media API, filename: ${filename}`);
5173
5291
  const mediaId = await uploadMedia(account, fileBuffer, filename, contentType, "file");
5174
5292
  console.log(`[wecom-app] File uploaded, media_id: ${mediaId}`);
@@ -5242,8 +5360,9 @@ async function enrichInboundContentWithMedia(params) {
5242
5360
  text,
5243
5361
  mediaPaths,
5244
5362
  cleanup: async () => {
5245
- for (const p of mediaPaths) {
5246
- await cleanupFile(p);
5363
+ try {
5364
+ await pruneInboundMediaDir(account);
5365
+ } catch {
5247
5366
  }
5248
5367
  }
5249
5368
  });
@@ -5256,8 +5375,9 @@ async function enrichInboundContentWithMedia(params) {
5256
5375
  if (mediaId) {
5257
5376
  const saved = await downloadWecomMediaToFile(account, mediaId, { maxBytes, prefix: "img" });
5258
5377
  if (saved.ok && saved.path) {
5259
- mediaPaths.push(saved.path);
5260
- return makeResult(`[image] saved:${saved.path}`);
5378
+ const finalPath = await finalizeInboundMedia(account, saved.path);
5379
+ mediaPaths.push(finalPath);
5380
+ return makeResult(`[image] saved:${finalPath}`);
5261
5381
  }
5262
5382
  return makeResult(`[image] (save failed) ${saved.error ?? ""}`.trim());
5263
5383
  }
@@ -5266,8 +5386,9 @@ async function enrichInboundContentWithMedia(params) {
5266
5386
  try {
5267
5387
  const saved = await downloadWecomMediaToFile(account, url, { maxBytes, prefix: "img" });
5268
5388
  if (saved.ok && saved.path) {
5269
- mediaPaths.push(saved.path);
5270
- return makeResult(`[image] saved:${saved.path}`);
5389
+ const finalPath = await finalizeInboundMedia(account, saved.path);
5390
+ mediaPaths.push(finalPath);
5391
+ return makeResult(`[image] saved:${finalPath}`);
5271
5392
  }
5272
5393
  } catch {
5273
5394
  }
@@ -5284,8 +5405,9 @@ async function enrichInboundContentWithMedia(params) {
5284
5405
  if (mediaId) {
5285
5406
  const saved = await downloadWecomMediaToFile(account, mediaId, { maxBytes, prefix: "file" });
5286
5407
  if (saved.ok && saved.path) {
5287
- mediaPaths.push(saved.path);
5288
- return makeResult(`[file] saved:${saved.path}`);
5408
+ const finalPath = await finalizeInboundMedia(account, saved.path);
5409
+ mediaPaths.push(finalPath);
5410
+ return makeResult(`[file] saved:${finalPath}`);
5289
5411
  }
5290
5412
  return makeResult(`[file] (save failed) ${saved.error ?? ""}`.trim());
5291
5413
  }
@@ -5302,12 +5424,13 @@ async function enrichInboundContentWithMedia(params) {
5302
5424
  if (mediaId) {
5303
5425
  const saved = await downloadWecomMediaToFile(account, mediaId, { maxBytes, prefix: "voice" });
5304
5426
  if (saved.ok && saved.path) {
5305
- mediaPaths.push(saved.path);
5427
+ const finalPath = await finalizeInboundMedia(account, saved.path);
5428
+ mediaPaths.push(finalPath);
5306
5429
  if (recognition) {
5307
- return makeResult(`[voice] saved:${saved.path}
5430
+ return makeResult(`[voice] saved:${finalPath}
5308
5431
  [recognition] ${recognition}`);
5309
5432
  }
5310
- return makeResult(`[voice] saved:${saved.path}`);
5433
+ return makeResult(`[voice] saved:${finalPath}`);
5311
5434
  }
5312
5435
  if (recognition) {
5313
5436
  return makeResult(`[voice] (save failed) ${saved.error ?? ""}
@@ -5350,8 +5473,9 @@ async function enrichInboundContentWithMedia(params) {
5350
5473
  try {
5351
5474
  const saved = await downloadWecomMediaToFile(account, mediaId, { maxBytes, prefix: "img" });
5352
5475
  if (saved.ok && saved.path) {
5353
- mediaPaths.push(saved.path);
5354
- parts.push(`[image] saved:${saved.path}`);
5476
+ const finalPath = await finalizeInboundMedia(account, saved.path);
5477
+ mediaPaths.push(finalPath);
5478
+ parts.push(`[image] saved:${finalPath}`);
5355
5479
  } else {
5356
5480
  const url = String(typed.image?.url ?? "").trim();
5357
5481
  parts.push(url ? `[image] ${url}` : "[image]");
@@ -6069,14 +6193,40 @@ async function handleWecomAppWebhookRequest(req, res) {
6069
6193
  );
6070
6194
  return true;
6071
6195
  }
6196
+ async function hasFfmpeg() {
6197
+ return new Promise((resolve) => {
6198
+ const p = spawn("ffmpeg", ["-version"], { stdio: "ignore" });
6199
+ p.on("error", () => resolve(false));
6200
+ p.on("exit", (code) => resolve(code === 0));
6201
+ });
6202
+ }
6203
+ async function transcodeToAmr(params) {
6204
+ const args = ["-y", "-i", params.inputPath, "-ar", "8000", "-ac", "1", "-c:a", "amr_nb", params.outputPath];
6205
+ await new Promise((resolve, reject) => {
6206
+ const p = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
6207
+ let err = "";
6208
+ p.stderr?.on("data", (d) => err += String(d));
6209
+ p.on("error", (e) => reject(e));
6210
+ p.on("exit", (code) => {
6211
+ if (code === 0) return resolve();
6212
+ reject(new Error(`ffmpeg transcode failed (code=${code}): ${err.slice(0, 2e3)}`));
6213
+ });
6214
+ });
6215
+ }
6072
6216
 
6073
6217
  // src/channel.ts
6074
6218
  function detectMediaType2(filePath, mimeType) {
6075
6219
  if (mimeType) {
6076
6220
  const mime = mimeType.split(";")[0].trim().toLowerCase();
6221
+ if (mime.includes("svg")) {
6222
+ return "file";
6223
+ }
6077
6224
  if (mime.startsWith("image/")) {
6078
6225
  return "image";
6079
6226
  }
6227
+ if (mime === "audio/wav" || mime === "audio/x-wav") {
6228
+ return "file";
6229
+ }
6080
6230
  if (mime.startsWith("audio/") || mime === "audio/amr") {
6081
6231
  return "voice";
6082
6232
  }
@@ -6089,10 +6239,16 @@ function detectMediaType2(filePath, mimeType) {
6089
6239
  if (imageExts.includes(ext)) {
6090
6240
  return "image";
6091
6241
  }
6092
- const voiceExts = ["amr", "speex", "mp3", "wav"];
6242
+ if (ext === "svg") {
6243
+ return "file";
6244
+ }
6245
+ const voiceExts = ["amr", "speex", "mp3"];
6093
6246
  if (voiceExts.includes(ext)) {
6094
6247
  return "voice";
6095
6248
  }
6249
+ if (ext === "wav") {
6250
+ return "file";
6251
+ }
6096
6252
  return "file";
6097
6253
  }
6098
6254
  var meta = {
@@ -6383,8 +6539,53 @@ var wecomAppPlugin = {
6383
6539
  result = await downloadAndSendImage(account, target, params.mediaUrl);
6384
6540
  } else if (mediaType === "voice") {
6385
6541
  console.log(`[wecom-app] Routing to downloadAndSendVoice`);
6386
- result = await downloadAndSendVoice(account, target, params.mediaUrl);
6542
+ const voiceUrl = params.mediaUrl;
6543
+ const ext = (voiceUrl.split("?")[0].match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
6544
+ const likelyUnsupported = ext === "wav" || ext === "mp3";
6545
+ const transcodeEnabled = Boolean(account.config.voiceTranscode?.enabled);
6546
+ if (likelyUnsupported && transcodeEnabled) {
6547
+ const can = await hasFfmpeg();
6548
+ if (can) {
6549
+ try {
6550
+ if (!voiceUrl.startsWith("http://") && !voiceUrl.startsWith("https://")) {
6551
+ const os = await import('os');
6552
+ const path = await import('path');
6553
+ const fs = await import('fs');
6554
+ const out = path.join(os.tmpdir(), `wecom-app-voice-${Date.now()}.amr`);
6555
+ console.log(`[wecom-app] voiceTranscode: ffmpeg available, transcoding ${voiceUrl} -> ${out}`);
6556
+ await transcodeToAmr({ inputPath: voiceUrl, outputPath: out });
6557
+ result = await downloadAndSendVoice(account, target, out);
6558
+ try {
6559
+ await fs.promises.unlink(out);
6560
+ } catch {
6561
+ }
6562
+ } else {
6563
+ console.warn(`[wecom-app] voiceTranscode enabled but mediaUrl is remote; fallback to file send (download once is not implemented yet)`);
6564
+ result = await downloadAndSendFile(account, target, voiceUrl);
6565
+ }
6566
+ } catch (e) {
6567
+ console.warn(`[wecom-app] voiceTranscode failed; fallback to file send:`, e);
6568
+ result = await downloadAndSendFile(account, target, voiceUrl);
6569
+ }
6570
+ } else {
6571
+ console.warn(`[wecom-app] voiceTranscode enabled but ffmpeg not found; fallback to file send`);
6572
+ result = await downloadAndSendFile(account, target, voiceUrl);
6573
+ }
6574
+ } else if (likelyUnsupported) {
6575
+ console.log(`[wecom-app] Voice format .${ext} likely unsupported; fallback to file send`);
6576
+ result = await downloadAndSendFile(account, target, voiceUrl);
6577
+ } else {
6578
+ result = await downloadAndSendVoice(account, target, voiceUrl);
6579
+ }
6387
6580
  } else {
6581
+ if (params.text?.trim()) {
6582
+ try {
6583
+ console.log(`[wecom-app] Sending caption text before file: ${params.text}`);
6584
+ await sendWecomAppMessage(account, target, params.text);
6585
+ } catch (err) {
6586
+ console.warn(`[wecom-app] Failed to send caption before file:`, err);
6587
+ }
6588
+ }
6388
6589
  console.log(`[wecom-app] Routing to downloadAndSendFile`);
6389
6590
  result = await downloadAndSendFile(account, target, params.mediaUrl);
6390
6591
  }