openzca 0.1.57 → 0.1.58

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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/dist/cli.js +223 -64
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -125,6 +125,7 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
125
125
 
126
126
  Media commands accept local files, `file://` paths, and repeatable `--url` options. Add `--group` for group threads.
127
127
  `openzca msg video` attempts native video send for a single `.mp4` input by uploading the video and thumbnail to Zalo first. If `ffmpeg` is unavailable, the input is not a single `.mp4`, or native send fails, it falls back to the normal attachment send path. Use `--thumbnail <path-or-url>` to supply the preview image explicitly.
128
+ `openzca msg voice` sends `--url` inputs directly. For local voice files, if both `ffmpeg` and `OPENZCA_VOICE_PUBLISH_CMD` are available, `openzca` normalizes the file to `.m4a`, runs the publish command with the normalized temp file path, expects one public `http(s)` URL on stdout, and sends that URL. Otherwise it falls back to the legacy Zalo upload flow.
128
129
  Local paths using `~` are expanded automatically (for positional file args, `--url`, and `OPENZCA_LISTEN_MEDIA_DIR`).
129
130
  Group text sends via `openzca msg send --group` resolve unique `@Name` or `@userId` mentions against the current group member list using member ids, display names, and usernames. Mention offsets are computed after formatting markers are parsed, so messages like `**@Alice Nguyen** hello` work. If multiple members share the same label, the command fails instead of guessing.
130
131
  When formatted text would produce an oversized outbound payload, `openzca msg send` automatically splits it into multiple sequential text messages using the final outbound text and rebased style/mention offsets. The split happens after formatting is parsed, using both rendered text length and estimated request payload size rather than the raw input string.
@@ -433,6 +434,10 @@ Upload/listener coordination overrides:
433
434
  - `OPENZCA_UPLOAD_GROUP_PROBE`: allow `msg upload` to probe `getGroupInfo` when auto thread-type detection is enabled.
434
435
  - Default: enabled.
435
436
  - Set to `0` to skip probe and rely only on cache matches.
437
+ - `OPENZCA_VOICE_PUBLISH_CMD`: optional command used by `msg voice` for local files.
438
+ - `openzca` passes one normalized `.m4a` temp file path as the first argument.
439
+ - The command must print exactly one public `http(s)` URL to stdout.
440
+ - Requires `ffmpeg`; if unset or `ffmpeg` is unavailable, local voice files keep using the legacy Zalo upload flow.
436
441
 
437
442
  ### account — Multi-account profiles
438
443
 
package/dist/cli.js CHANGED
@@ -4,10 +4,10 @@
4
4
  import { createRequire as createRequire2 } from "module";
5
5
  import { spawn as spawn2 } from "child_process";
6
6
  import fsSync from "fs";
7
- import fs6 from "fs/promises";
7
+ import fs7 from "fs/promises";
8
8
  import net from "net";
9
- import os4 from "os";
10
- import path6 from "path";
9
+ import os5 from "os";
10
+ import path7 from "path";
11
11
  import readline from "readline/promises";
12
12
  import util from "util";
13
13
  import { Command } from "commander";
@@ -3066,6 +3066,110 @@ async function sendNativeVideo(params) {
3066
3066
  }
3067
3067
  }
3068
3068
 
3069
+ // src/lib/voice-send.ts
3070
+ import { execFile as execFile2 } from "child_process";
3071
+ import fs6 from "fs/promises";
3072
+ import os4 from "os";
3073
+ import path6 from "path";
3074
+ import { promisify as promisify2 } from "util";
3075
+ var execFileAsync2 = promisify2(execFile2);
3076
+ function sanitizeOutputBasename(filePath) {
3077
+ const parsed = path6.parse(filePath);
3078
+ const base = parsed.name.trim() || "voice";
3079
+ const sanitized = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
3080
+ return sanitized || "voice";
3081
+ }
3082
+ async function runBinary2(command, args) {
3083
+ try {
3084
+ await execFileAsync2(command, args, {
3085
+ encoding: "utf8",
3086
+ maxBuffer: 10 * 1024 * 1024
3087
+ });
3088
+ } catch (error) {
3089
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
3090
+ throw new Error(`${command} is required for voice publish mode`);
3091
+ }
3092
+ if (error instanceof Error && "stderr" in error) {
3093
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
3094
+ throw new Error(stderr ? `${command} failed: ${stderr}` : `${command} failed: ${error.message}`);
3095
+ }
3096
+ throw error;
3097
+ }
3098
+ }
3099
+ function getVoicePublishCommandFromEnv(env = process.env) {
3100
+ const configured = env.OPENZCA_VOICE_PUBLISH_CMD?.trim();
3101
+ return configured && configured.length > 0 ? configured : null;
3102
+ }
3103
+ function extractPublishedVoiceUrl(stdout) {
3104
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3105
+ const candidate = lines.at(-1);
3106
+ if (!candidate) {
3107
+ throw new Error("Voice publish command did not print a public URL to stdout");
3108
+ }
3109
+ if (!/^https?:\/\/\S+$/i.test(candidate)) {
3110
+ throw new Error(`Voice publish command returned an invalid URL: ${candidate}`);
3111
+ }
3112
+ return candidate;
3113
+ }
3114
+ async function normalizeVoiceForPublish(inputPath) {
3115
+ const dir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-voice-"));
3116
+ const outputPath = path6.join(dir, `${sanitizeOutputBasename(inputPath)}.m4a`);
3117
+ try {
3118
+ await runBinary2("ffmpeg", [
3119
+ "-y",
3120
+ "-v",
3121
+ "error",
3122
+ "-i",
3123
+ inputPath,
3124
+ "-vn",
3125
+ "-map_metadata",
3126
+ "-1",
3127
+ "-ac",
3128
+ "1",
3129
+ "-ar",
3130
+ "44100",
3131
+ "-c:a",
3132
+ "aac",
3133
+ "-b:a",
3134
+ "64k",
3135
+ "-movflags",
3136
+ "+faststart",
3137
+ outputPath
3138
+ ]);
3139
+ await fs6.access(outputPath);
3140
+ } catch (error) {
3141
+ await fs6.rm(dir, { recursive: true, force: true });
3142
+ throw error;
3143
+ }
3144
+ return {
3145
+ path: outputPath,
3146
+ cleanup: async () => {
3147
+ await fs6.rm(dir, { recursive: true, force: true });
3148
+ }
3149
+ };
3150
+ }
3151
+ async function publishVoiceFile(command, filePath) {
3152
+ try {
3153
+ const { stdout } = await execFileAsync2(
3154
+ "sh",
3155
+ ["-c", `${command} "$1"`, "openzca-voice-publish", filePath],
3156
+ {
3157
+ encoding: "utf8",
3158
+ maxBuffer: 10 * 1024 * 1024
3159
+ }
3160
+ );
3161
+ return extractPublishedVoiceUrl(stdout);
3162
+ } catch (error) {
3163
+ if (error instanceof Error && "stderr" in error) {
3164
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
3165
+ throw new Error(
3166
+ stderr ? `Voice publish command failed: ${stderr}` : `Voice publish command failed: ${error.message}`
3167
+ );
3168
+ }
3169
+ throw error;
3170
+ }
3171
+ }
3172
+
3069
3173
  // src/lib/reply.ts
3070
3174
  import { ThreadType as ThreadType2 } from "zca-js";
3071
3175
  function prepareReplyMessage(value, params) {
@@ -3491,9 +3595,9 @@ function resolveDebugEnabled(command) {
3491
3595
  }
3492
3596
  function resolveDebugFilePath(command) {
3493
3597
  const options = getDebugOptions(command);
3494
- const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path6.join(APP_HOME, "logs", "openzca-debug.log");
3598
+ const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path7.join(APP_HOME, "logs", "openzca-debug.log");
3495
3599
  const normalized = normalizeMediaInput(configured);
3496
- return path6.isAbsolute(normalized) ? normalized : path6.resolve(process.cwd(), normalized);
3600
+ return path7.isAbsolute(normalized) ? normalized : path7.resolve(process.cwd(), normalized);
3497
3601
  }
3498
3602
  function writeDebugLine(event, details, command) {
3499
3603
  if (!resolveDebugEnabled(command)) {
@@ -3504,7 +3608,7 @@ function writeDebugLine(event, details, command) {
3504
3608
  `;
3505
3609
  const filePath = resolveDebugFilePath(command);
3506
3610
  try {
3507
- fsSync.mkdirSync(path6.dirname(filePath), { recursive: true });
3611
+ fsSync.mkdirSync(path7.dirname(filePath), { recursive: true });
3508
3612
  fsSync.appendFileSync(filePath, line, "utf8");
3509
3613
  } catch {
3510
3614
  }
@@ -3611,14 +3715,14 @@ function collectIdsFromCacheEntries(entries, keys) {
3611
3715
  return ids;
3612
3716
  }
3613
3717
  function getListenerOwnerLockPath(profile) {
3614
- return path6.join(getProfileDir(profile), "listener-owner.json");
3718
+ return path7.join(getProfileDir(profile), "listener-owner.json");
3615
3719
  }
3616
3720
  function getListenIpcSocketPath(profile) {
3617
3721
  if (process.platform === "win32") {
3618
3722
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
3619
3723
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
3620
3724
  }
3621
- return path6.join(getProfileDir(profile), "listen.sock");
3725
+ return path7.join(getProfileDir(profile), "listen.sock");
3622
3726
  }
3623
3727
  function parsePositiveIntFromUnknown(value) {
3624
3728
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -3702,7 +3806,7 @@ function isProcessAlive(pid) {
3702
3806
  }
3703
3807
  async function readListenerOwnerRecord(lockPath) {
3704
3808
  try {
3705
- const raw = await fs6.readFile(lockPath, "utf8");
3809
+ const raw = await fs7.readFile(lockPath, "utf8");
3706
3810
  const parsed = JSON.parse(raw);
3707
3811
  const pid = parsePositiveIntFromUnknown(parsed.pid);
3708
3812
  if (!pid) return null;
@@ -3722,11 +3826,11 @@ async function readActiveListenerOwner(profile) {
3722
3826
  const lockPath = getListenerOwnerLockPath(profile);
3723
3827
  const record = await readListenerOwnerRecord(lockPath);
3724
3828
  if (!record) {
3725
- await fs6.rm(lockPath, { force: true });
3829
+ await fs7.rm(lockPath, { force: true });
3726
3830
  return null;
3727
3831
  }
3728
3832
  if (!isProcessAlive(record.pid)) {
3729
- await fs6.rm(lockPath, { force: true });
3833
+ await fs7.rm(lockPath, { force: true });
3730
3834
  return null;
3731
3835
  }
3732
3836
  return record;
@@ -3742,7 +3846,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3742
3846
  };
3743
3847
  for (let attempt = 0; attempt < 3; attempt += 1) {
3744
3848
  try {
3745
- await fs6.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3849
+ await fs7.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3746
3850
  `, {
3747
3851
  encoding: "utf8",
3748
3852
  flag: "wx"
@@ -3755,7 +3859,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3755
3859
  released = true;
3756
3860
  const current = await readListenerOwnerRecord(lockPath);
3757
3861
  if (current && current.pid !== process.pid) return;
3758
- await fs6.rm(lockPath, { force: true });
3862
+ await fs7.rm(lockPath, { force: true });
3759
3863
  writeDebugLine(
3760
3864
  "listen.owner.released",
3761
3865
  {
@@ -3776,7 +3880,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3776
3880
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
3777
3881
  );
3778
3882
  }
3779
- await fs6.rm(lockPath, { force: true });
3883
+ await fs7.rm(lockPath, { force: true });
3780
3884
  }
3781
3885
  }
3782
3886
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -3794,7 +3898,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3794
3898
  }
3795
3899
  const socketPath = getListenIpcSocketPath(profile);
3796
3900
  if (process.platform !== "win32") {
3797
- await fs6.rm(socketPath, { force: true });
3901
+ await fs7.rm(socketPath, { force: true });
3798
3902
  }
3799
3903
  const uploadTimeoutMs = parsePositiveIntFromEnv(
3800
3904
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -3975,7 +4079,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3975
4079
  server.close(() => resolve());
3976
4080
  });
3977
4081
  if (process.platform !== "win32") {
3978
- await fs6.rm(socketPath, { force: true });
4082
+ await fs7.rm(socketPath, { force: true });
3979
4083
  }
3980
4084
  writeDebugLine(
3981
4085
  "listen.ipc.stopped",
@@ -6022,7 +6126,7 @@ function toDbRecordFromRecentMessage(params) {
6022
6126
  });
6023
6127
  }
6024
6128
  async function parseCredentialFile(filePath) {
6025
- const raw = await fs6.readFile(filePath, "utf8");
6129
+ const raw = await fs7.readFile(filePath, "utf8");
6026
6130
  const parsed = JSON.parse(raw);
6027
6131
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
6028
6132
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -6043,7 +6147,7 @@ async function waitForFileContent(filePath, timeoutMs) {
6043
6147
  const startedAt = Date.now();
6044
6148
  while (Date.now() - startedAt < timeoutMs) {
6045
6149
  try {
6046
- const data = await fs6.readFile(filePath);
6150
+ const data = await fs7.readFile(filePath);
6047
6151
  if (data.length > 0) {
6048
6152
  return data;
6049
6153
  }
@@ -6058,8 +6162,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
6058
6162
  if (!scriptPath) {
6059
6163
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
6060
6164
  }
6061
- const tempDir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-qr-"));
6062
- const targetPath = path6.resolve(qrPath ?? path6.join(tempDir, "qr.png"));
6165
+ const tempDir = await fs7.mkdtemp(path7.join(os5.tmpdir(), "openzca-qr-"));
6166
+ const targetPath = path7.resolve(qrPath ?? path7.join(tempDir, "qr.png"));
6063
6167
  const child = spawn2(
6064
6168
  process.execPath,
6065
6169
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -6337,7 +6441,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
6337
6441
  if (fromType) return fromType;
6338
6442
  try {
6339
6443
  const parsedUrl = new URL(mediaUrl);
6340
- const ext = path6.extname(parsedUrl.pathname);
6444
+ const ext = path7.extname(parsedUrl.pathname);
6341
6445
  if (ext) return ext;
6342
6446
  } catch {
6343
6447
  }
@@ -6368,20 +6472,20 @@ function parseInboundMediaFetchTimeoutMs() {
6368
6472
  return Math.trunc(parsed);
6369
6473
  }
6370
6474
  function resolveOpenClawMediaDir() {
6371
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path6.join(os4.homedir(), ".openclaw");
6372
- return path6.join(stateDir, "media");
6475
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path7.join(os5.homedir(), ".openclaw");
6476
+ return path7.join(stateDir, "media");
6373
6477
  }
6374
6478
  function resolveInboundMediaDir(profile) {
6375
6479
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
6376
6480
  if (configuredRaw) {
6377
6481
  const configured = normalizeMediaInput(configuredRaw);
6378
- return path6.isAbsolute(configured) ? configured : path6.resolve(process.cwd(), configured);
6482
+ return path7.isAbsolute(configured) ? configured : path7.resolve(process.cwd(), configured);
6379
6483
  }
6380
6484
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
6381
6485
  if (legacyRequested) {
6382
- return path6.join(getProfileDir(profile), "inbound-media");
6486
+ return path7.join(getProfileDir(profile), "inbound-media");
6383
6487
  }
6384
- return path6.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6488
+ return path7.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6385
6489
  }
6386
6490
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6387
6491
  const maxBytes = parseMaxInboundMediaBytes();
@@ -6415,11 +6519,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6415
6519
  return null;
6416
6520
  }
6417
6521
  const dir = resolveInboundMediaDir(profile);
6418
- await fs6.mkdir(dir, { recursive: true });
6522
+ await fs7.mkdir(dir, { recursive: true });
6419
6523
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
6420
6524
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
6421
- const mediaPath = path6.join(dir, `${id}${ext}`);
6422
- await fs6.writeFile(mediaPath, data);
6525
+ const mediaPath = path7.join(dir, `${id}${ext}`);
6526
+ await fs7.writeFile(mediaPath, data);
6423
6527
  return { mediaPath, mediaType };
6424
6528
  }
6425
6529
  async function cacheRemoteMediaEntries(params) {
@@ -6929,7 +7033,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
6929
7033
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
6930
7034
  wrapAction(async (file, command) => {
6931
7035
  const profile = await currentProfile(command);
6932
- const credentials = file ? await parseCredentialFile(path6.resolve(normalizeMediaInput(file))) : toCredentials(
7036
+ const credentials = file ? await parseCredentialFile(path7.resolve(normalizeMediaInput(file))) : toCredentials(
6933
7037
  await loadCredentials(profile) ?? (() => {
6934
7038
  throw new Error(
6935
7039
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -7036,7 +7140,7 @@ dbCmd.command("reset").option("-y, --yes", "Delete the SQLite DB file for the ac
7036
7140
  const removedPaths = [];
7037
7141
  const deleteIfExists = async (filename) => {
7038
7142
  try {
7039
- await fs6.unlink(filename);
7143
+ await fs7.unlink(filename);
7040
7144
  removedPaths.push(filename);
7041
7145
  } catch (error) {
7042
7146
  if (error.code !== "ENOENT") {
@@ -7749,53 +7853,108 @@ msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (rep
7749
7853
  }
7750
7854
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
7751
7855
  const localInputs = files.filter((entry) => !isHttpUrl(entry));
7856
+ const publishCommand = getVoicePublishCommandFromEnv();
7857
+ const ffmpegAvailable = localInputs.length > 0 && publishCommand ? await isFfmpegAvailable() : false;
7858
+ const usePublishFlow = localInputs.length > 0 && Boolean(publishCommand) && ffmpegAvailable;
7752
7859
  writeDebugLine(
7753
7860
  "msg.voice.inputs",
7754
7861
  {
7755
7862
  threadId,
7756
7863
  isGroup: Boolean(opts.group),
7757
7864
  localInputs,
7758
- urlInputs
7865
+ urlInputs,
7866
+ publishConfigured: Boolean(publishCommand),
7867
+ ffmpegAvailable: localInputs.length > 0 ? ffmpegAvailable : void 0,
7868
+ mode: usePublishFlow ? "publish" : "legacy"
7759
7869
  },
7760
7870
  command
7761
7871
  );
7762
- const downloaded = await downloadUrlsToTempFiles(urlInputs);
7763
- try {
7764
- const attachments = [...localInputs, ...downloaded.files];
7765
- await assertFilesExist(attachments);
7766
- const results = [];
7767
- const uploaded = await api.uploadAttachment(attachments, threadId, type);
7768
- for (const item of uploaded) {
7769
- if (item.fileType === "others" || item.fileType === "video") {
7770
- results.push(await sendVoice({ voiceUrl: item.fileUrl }, threadId, type));
7872
+ await assertFilesExist(localInputs);
7873
+ const publishedLocals = [];
7874
+ let uploadedLocals = [];
7875
+ if (usePublishFlow) {
7876
+ for (const localInput of localInputs) {
7877
+ const normalized = await normalizeVoiceForPublish(localInput);
7878
+ try {
7879
+ const mediaUrl = await publishVoiceFile(publishCommand, normalized.path);
7880
+ publishedLocals.push({
7881
+ mediaPath: localInput,
7882
+ mediaUrl
7883
+ });
7884
+ } finally {
7885
+ await normalized.cleanup();
7771
7886
  }
7772
7887
  }
7773
- if (results.length === 0) {
7774
- throw new Error(
7775
- "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
7776
- );
7888
+ } else if (localInputs.length > 0) {
7889
+ uploadedLocals = await withUploadListener(
7890
+ api,
7891
+ command,
7892
+ async () => api.uploadAttachment(localInputs, threadId, type)
7893
+ );
7894
+ }
7895
+ const pendingPublished = [...publishedLocals];
7896
+ const pendingUploaded = [...uploadedLocals];
7897
+ const outboundVoices = [];
7898
+ for (const entry of files) {
7899
+ if (isHttpUrl(entry)) {
7900
+ outboundVoices.push({
7901
+ mediaUrl: entry
7902
+ });
7903
+ continue;
7777
7904
  }
7778
- output(results, false);
7779
- if (await shouldWriteToDb(profile)) {
7780
- scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
7781
- await persistOutgoingMessageBestEffort({
7782
- profile,
7783
- api,
7784
- threadId,
7785
- group: opts.group,
7786
- msgType: "voice",
7787
- response: results,
7788
- rawPayload: uploaded,
7789
- media: uploaded.map((item) => ({
7790
- mediaKind: "voice",
7791
- mediaUrl: "fileUrl" in item && typeof item.fileUrl === "string" ? item.fileUrl : void 0,
7792
- rawJson: JSON.stringify(item)
7793
- }))
7794
- });
7905
+ if (usePublishFlow) {
7906
+ const nextPublished = pendingPublished.shift();
7907
+ if (!nextPublished) {
7908
+ throw new Error(`Voice publish flow lost local file mapping for: ${entry}`);
7909
+ }
7910
+ outboundVoices.push(nextPublished);
7911
+ continue;
7912
+ }
7913
+ const nextUploaded = pendingUploaded.shift();
7914
+ if (!nextUploaded) {
7915
+ throw new Error(`Voice upload flow lost local file mapping for: ${entry}`);
7916
+ }
7917
+ if (nextUploaded.fileType === "others" || nextUploaded.fileType === "video") {
7918
+ outboundVoices.push({
7919
+ mediaPath: entry,
7920
+ mediaUrl: nextUploaded.fileUrl,
7921
+ rawJson: JSON.stringify(nextUploaded)
7795
7922
  });
7796
7923
  }
7797
- } finally {
7798
- await downloaded.cleanup();
7924
+ }
7925
+ if (outboundVoices.length === 0) {
7926
+ throw new Error(
7927
+ "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
7928
+ );
7929
+ }
7930
+ const results = [];
7931
+ for (const item of outboundVoices) {
7932
+ results.push(await sendVoice({ voiceUrl: item.mediaUrl }, threadId, type));
7933
+ }
7934
+ output(results, false);
7935
+ if (await shouldWriteToDb(profile)) {
7936
+ scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
7937
+ await persistOutgoingMessageBestEffort({
7938
+ profile,
7939
+ api,
7940
+ threadId,
7941
+ group: opts.group,
7942
+ msgType: "voice",
7943
+ response: results,
7944
+ rawPayload: {
7945
+ mode: usePublishFlow ? "publish" : "legacy",
7946
+ directUrls: urlInputs,
7947
+ published: publishedLocals,
7948
+ uploaded: uploadedLocals
7949
+ },
7950
+ media: outboundVoices.map((item) => ({
7951
+ mediaKind: "voice",
7952
+ mediaPath: item.mediaPath,
7953
+ mediaUrl: item.mediaUrl,
7954
+ rawJson: item.rawJson
7955
+ }))
7956
+ });
7957
+ });
7799
7958
  }
7800
7959
  }
7801
7960
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {