openzca 0.1.56 → 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 +509 -124
  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) {
@@ -3291,6 +3395,125 @@ function inferReplyMessageThreadId(params) {
3291
3395
  return void 0;
3292
3396
  }
3293
3397
 
3398
+ // src/lib/adaptive-batch.ts
3399
+ var RETRYABLE_LOOKUP_ERROR_PATTERNS = [
3400
+ /retry limit/i,
3401
+ /\brate limit/i,
3402
+ /\btoo many requests?\b/i,
3403
+ /\btimeout\b/i,
3404
+ /\btimed out\b/i,
3405
+ /\betimedout\b/i,
3406
+ /\beconnreset\b/i,
3407
+ /\besockettimedout\b/i,
3408
+ /\bsocket hang up\b/i,
3409
+ /\btemporar(?:y|ily)\b/i
3410
+ ];
3411
+ var SPLITTABLE_LOOKUP_ERROR_PATTERNS = [
3412
+ /\binvalid param(?:eter)?s?\b/i,
3413
+ /\binvalid request\b/i,
3414
+ /\bbad request\b/i,
3415
+ /tham so khong hop le/i,
3416
+ /tham số không hợp lệ/i
3417
+ ];
3418
+ function sleep2(ms) {
3419
+ return new Promise((resolve) => {
3420
+ setTimeout(resolve, ms);
3421
+ });
3422
+ }
3423
+ function chunkKeys(keys, size) {
3424
+ const chunkSize = Math.max(1, Math.trunc(size) || 1);
3425
+ const chunks = [];
3426
+ for (let index = 0; index < keys.length; index += chunkSize) {
3427
+ chunks.push(keys.slice(index, index + chunkSize));
3428
+ }
3429
+ return chunks;
3430
+ }
3431
+ function toErrorText(error) {
3432
+ return error instanceof Error ? error.message : String(error);
3433
+ }
3434
+ function isRetryableLookupError(error) {
3435
+ const message = toErrorText(error);
3436
+ return RETRYABLE_LOOKUP_ERROR_PATTERNS.some((pattern) => pattern.test(message));
3437
+ }
3438
+ function isSplittableLookupError(error) {
3439
+ const message = toErrorText(error);
3440
+ return SPLITTABLE_LOOKUP_ERROR_PATTERNS.some((pattern) => pattern.test(message));
3441
+ }
3442
+ async function runAdaptiveBatch(keys, options) {
3443
+ const maxRetries = Math.max(0, options.maxRetries ?? 2);
3444
+ const initialDelayMs = Math.max(0, options.retryDelayMs ?? 400);
3445
+ const backoffMultiplier = Math.max(1, options.backoffMultiplier ?? 2);
3446
+ const shouldRetry = options.shouldRetry ?? isRetryableLookupError;
3447
+ const shouldSplit = options.shouldSplit ?? isSplittableLookupError;
3448
+ let attempt = 0;
3449
+ let delayMs = initialDelayMs;
3450
+ while (true) {
3451
+ try {
3452
+ return await options.fetchBatch(keys) ?? {};
3453
+ } catch (error) {
3454
+ if (keys.length > 1 && shouldSplit(error)) {
3455
+ throw error;
3456
+ }
3457
+ attempt += 1;
3458
+ if (attempt > maxRetries || !shouldRetry(error)) {
3459
+ throw error;
3460
+ }
3461
+ await options.onRetry?.({
3462
+ keys: [...keys],
3463
+ attempt,
3464
+ maxRetries,
3465
+ delayMs,
3466
+ error
3467
+ });
3468
+ if (delayMs > 0) {
3469
+ await sleep2(delayMs);
3470
+ }
3471
+ delayMs = Math.max(delayMs * backoffMultiplier, delayMs + 1);
3472
+ }
3473
+ }
3474
+ }
3475
+ async function fetchAdaptiveObjectBatches(keys, options) {
3476
+ const uniqueKeys = Array.from(new Set(keys.map((value) => value.trim()).filter(Boolean)));
3477
+ const pending = chunkKeys(uniqueKeys, options.initialBatchSize ?? 5);
3478
+ const values = /* @__PURE__ */ new Map();
3479
+ const errors = [];
3480
+ const shouldRetry = options.shouldRetry ?? isRetryableLookupError;
3481
+ const shouldSplit = options.shouldSplit ?? isSplittableLookupError;
3482
+ const continueOnItemError = options.continueOnItemError ?? true;
3483
+ const batchDelayMs = Math.max(0, options.batchDelayMs ?? 75);
3484
+ while (pending.length > 0) {
3485
+ const batch = pending.shift();
3486
+ if (!batch || batch.length === 0) {
3487
+ continue;
3488
+ }
3489
+ try {
3490
+ const result = await runAdaptiveBatch(batch, options);
3491
+ for (const key of batch) {
3492
+ const value = result[key];
3493
+ if (value !== void 0) {
3494
+ values.set(key, value);
3495
+ }
3496
+ }
3497
+ } catch (error) {
3498
+ if (batch.length > 1 && (shouldSplit(error) || shouldRetry(error))) {
3499
+ pending.unshift(...chunkKeys(batch, Math.ceil(batch.length / 2)));
3500
+ continue;
3501
+ }
3502
+ if (!continueOnItemError || batch.length > 1) {
3503
+ throw error;
3504
+ }
3505
+ const itemError = { key: batch[0], error };
3506
+ errors.push(itemError);
3507
+ await options.onItemError?.(itemError);
3508
+ continue;
3509
+ }
3510
+ if (batchDelayMs > 0 && pending.length > 0) {
3511
+ await sleep2(batchDelayMs);
3512
+ }
3513
+ }
3514
+ return { values, errors };
3515
+ }
3516
+
3294
3517
  // src/cli.ts
3295
3518
  var require3 = createRequire2(import.meta.url);
3296
3519
  var { version: PKG_VERSION } = require3("../package.json");
@@ -3372,9 +3595,9 @@ function resolveDebugEnabled(command) {
3372
3595
  }
3373
3596
  function resolveDebugFilePath(command) {
3374
3597
  const options = getDebugOptions(command);
3375
- 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");
3376
3599
  const normalized = normalizeMediaInput(configured);
3377
- return path6.isAbsolute(normalized) ? normalized : path6.resolve(process.cwd(), normalized);
3600
+ return path7.isAbsolute(normalized) ? normalized : path7.resolve(process.cwd(), normalized);
3378
3601
  }
3379
3602
  function writeDebugLine(event, details, command) {
3380
3603
  if (!resolveDebugEnabled(command)) {
@@ -3385,7 +3608,7 @@ function writeDebugLine(event, details, command) {
3385
3608
  `;
3386
3609
  const filePath = resolveDebugFilePath(command);
3387
3610
  try {
3388
- fsSync.mkdirSync(path6.dirname(filePath), { recursive: true });
3611
+ fsSync.mkdirSync(path7.dirname(filePath), { recursive: true });
3389
3612
  fsSync.appendFileSync(filePath, line, "utf8");
3390
3613
  } catch {
3391
3614
  }
@@ -3492,14 +3715,14 @@ function collectIdsFromCacheEntries(entries, keys) {
3492
3715
  return ids;
3493
3716
  }
3494
3717
  function getListenerOwnerLockPath(profile) {
3495
- return path6.join(getProfileDir(profile), "listener-owner.json");
3718
+ return path7.join(getProfileDir(profile), "listener-owner.json");
3496
3719
  }
3497
3720
  function getListenIpcSocketPath(profile) {
3498
3721
  if (process.platform === "win32") {
3499
3722
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
3500
3723
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
3501
3724
  }
3502
- return path6.join(getProfileDir(profile), "listen.sock");
3725
+ return path7.join(getProfileDir(profile), "listen.sock");
3503
3726
  }
3504
3727
  function parsePositiveIntFromUnknown(value) {
3505
3728
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -3532,6 +3755,44 @@ function retrySendMethod(operation, command, metaBuilder) {
3532
3755
  }
3533
3756
  });
3534
3757
  }
3758
+ var LOOKUP_BATCH_SIZE = 5;
3759
+ var LOOKUP_RETRY_COUNT = 2;
3760
+ var LOOKUP_RETRY_DELAY_MS = 400;
3761
+ var LOOKUP_BATCH_DELAY_MS = 75;
3762
+ async function fetchGroupInfoRecords(api, groupIds) {
3763
+ const { values } = await fetchAdaptiveObjectBatches(groupIds, {
3764
+ fetchBatch: async (keys) => {
3765
+ const response = await api.getGroupInfo(keys);
3766
+ return response.gridInfoMap ?? {};
3767
+ },
3768
+ initialBatchSize: LOOKUP_BATCH_SIZE,
3769
+ maxRetries: LOOKUP_RETRY_COUNT,
3770
+ retryDelayMs: LOOKUP_RETRY_DELAY_MS,
3771
+ batchDelayMs: LOOKUP_BATCH_DELAY_MS
3772
+ });
3773
+ return values;
3774
+ }
3775
+ async function fetchGroupInfoRecord(api, groupId) {
3776
+ const groups = await fetchGroupInfoRecords(api, [groupId]);
3777
+ const group2 = groups.get(groupId);
3778
+ if (!group2) {
3779
+ throw new Error(`Group not found: ${groupId}`);
3780
+ }
3781
+ return group2;
3782
+ }
3783
+ async function fetchGroupMemberProfiles(api, memberIds) {
3784
+ const { values } = await fetchAdaptiveObjectBatches(memberIds, {
3785
+ fetchBatch: async (keys) => {
3786
+ const response = await api.getGroupMembersInfo(keys);
3787
+ return response.profiles ?? {};
3788
+ },
3789
+ initialBatchSize: LOOKUP_BATCH_SIZE,
3790
+ maxRetries: LOOKUP_RETRY_COUNT,
3791
+ retryDelayMs: LOOKUP_RETRY_DELAY_MS,
3792
+ batchDelayMs: LOOKUP_BATCH_DELAY_MS
3793
+ });
3794
+ return values;
3795
+ }
3535
3796
  function isProcessAlive(pid) {
3536
3797
  if (!Number.isInteger(pid) || pid <= 0) return false;
3537
3798
  try {
@@ -3545,7 +3806,7 @@ function isProcessAlive(pid) {
3545
3806
  }
3546
3807
  async function readListenerOwnerRecord(lockPath) {
3547
3808
  try {
3548
- const raw = await fs6.readFile(lockPath, "utf8");
3809
+ const raw = await fs7.readFile(lockPath, "utf8");
3549
3810
  const parsed = JSON.parse(raw);
3550
3811
  const pid = parsePositiveIntFromUnknown(parsed.pid);
3551
3812
  if (!pid) return null;
@@ -3565,11 +3826,11 @@ async function readActiveListenerOwner(profile) {
3565
3826
  const lockPath = getListenerOwnerLockPath(profile);
3566
3827
  const record = await readListenerOwnerRecord(lockPath);
3567
3828
  if (!record) {
3568
- await fs6.rm(lockPath, { force: true });
3829
+ await fs7.rm(lockPath, { force: true });
3569
3830
  return null;
3570
3831
  }
3571
3832
  if (!isProcessAlive(record.pid)) {
3572
- await fs6.rm(lockPath, { force: true });
3833
+ await fs7.rm(lockPath, { force: true });
3573
3834
  return null;
3574
3835
  }
3575
3836
  return record;
@@ -3585,7 +3846,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3585
3846
  };
3586
3847
  for (let attempt = 0; attempt < 3; attempt += 1) {
3587
3848
  try {
3588
- await fs6.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3849
+ await fs7.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3589
3850
  `, {
3590
3851
  encoding: "utf8",
3591
3852
  flag: "wx"
@@ -3598,7 +3859,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3598
3859
  released = true;
3599
3860
  const current = await readListenerOwnerRecord(lockPath);
3600
3861
  if (current && current.pid !== process.pid) return;
3601
- await fs6.rm(lockPath, { force: true });
3862
+ await fs7.rm(lockPath, { force: true });
3602
3863
  writeDebugLine(
3603
3864
  "listen.owner.released",
3604
3865
  {
@@ -3619,7 +3880,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3619
3880
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
3620
3881
  );
3621
3882
  }
3622
- await fs6.rm(lockPath, { force: true });
3883
+ await fs7.rm(lockPath, { force: true });
3623
3884
  }
3624
3885
  }
3625
3886
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -3637,7 +3898,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3637
3898
  }
3638
3899
  const socketPath = getListenIpcSocketPath(profile);
3639
3900
  if (process.platform !== "win32") {
3640
- await fs6.rm(socketPath, { force: true });
3901
+ await fs7.rm(socketPath, { force: true });
3641
3902
  }
3642
3903
  const uploadTimeoutMs = parsePositiveIntFromEnv(
3643
3904
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -3737,7 +3998,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3737
3998
  command
3738
3999
  );
3739
4000
  } catch (error) {
3740
- fail(parsed.requestId, toErrorText(error));
4001
+ fail(parsed.requestId, toErrorText2(error));
3741
4002
  writeDebugLine(
3742
4003
  "listen.ipc.upload.error",
3743
4004
  {
@@ -3746,7 +4007,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3746
4007
  requestId: parsed.requestId,
3747
4008
  threadId: parsed.threadId,
3748
4009
  threadType: parsed.threadType,
3749
- message: toErrorText(error)
4010
+ message: toErrorText2(error)
3750
4011
  },
3751
4012
  command
3752
4013
  );
@@ -3775,7 +4036,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3775
4036
  {
3776
4037
  profile,
3777
4038
  sessionId,
3778
- message: toErrorText(error)
4039
+ message: toErrorText2(error)
3779
4040
  },
3780
4041
  command
3781
4042
  );
@@ -3787,7 +4048,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3787
4048
  {
3788
4049
  profile,
3789
4050
  sessionId,
3790
- message: toErrorText(error)
4051
+ message: toErrorText2(error)
3791
4052
  },
3792
4053
  command
3793
4054
  );
@@ -3818,7 +4079,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3818
4079
  server.close(() => resolve());
3819
4080
  });
3820
4081
  if (process.platform !== "win32") {
3821
- await fs6.rm(socketPath, { force: true });
4082
+ await fs7.rm(socketPath, { force: true });
3822
4083
  }
3823
4084
  writeDebugLine(
3824
4085
  "listen.ipc.stopped",
@@ -3976,7 +4237,7 @@ async function resolveUploadThreadType(api, profile, threadId, groupFlag, comman
3976
4237
  {
3977
4238
  profile,
3978
4239
  threadId,
3979
- message: toErrorText(error)
4240
+ message: toErrorText2(error)
3980
4241
  },
3981
4242
  command
3982
4243
  );
@@ -4001,7 +4262,7 @@ async function resolveUploadThreadType(api, profile, threadId, groupFlag, comman
4001
4262
  {
4002
4263
  profile,
4003
4264
  threadId,
4004
- message: toErrorText(error)
4265
+ message: toErrorText2(error)
4005
4266
  },
4006
4267
  command
4007
4268
  );
@@ -4242,8 +4503,8 @@ async function persistOutgoingMessageBestEffort(params) {
4242
4503
  });
4243
4504
  }
4244
4505
  }
4245
- async function persistGroupMembersSnapshot(profile, groupId, api) {
4246
- const rows = await listGroupMemberRows(api, groupId);
4506
+ async function persistGroupMembersSnapshot(profile, groupId, api, groupInfo) {
4507
+ const rows = await listGroupMemberRows(api, groupId, groupInfo);
4247
4508
  const snapshotAtMs = Date.now();
4248
4509
  for (const row of rows) {
4249
4510
  await persistContact({
@@ -4448,7 +4709,15 @@ async function prepareDbGroupTarget(params) {
4448
4709
  isHidden: params.hiddenIds.has(params.groupId),
4449
4710
  rawJson: params.rawJson
4450
4711
  });
4451
- await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
4712
+ if (params.hydrateMembers === false) {
4713
+ return {};
4714
+ }
4715
+ try {
4716
+ await persistGroupMembersSnapshot(params.profile, params.groupId, params.api, params.group);
4717
+ return {};
4718
+ } catch (error) {
4719
+ return { memberSnapshotError: toErrorText2(error) };
4720
+ }
4452
4721
  }
4453
4722
  function resolveContactDisplayName(params) {
4454
4723
  return params.displayName?.trim() || params.zaloName?.trim() || params.fallbackTitle?.trim() || params.userId.trim() || void 0;
@@ -4550,18 +4819,17 @@ async function hydrateUnknownLiveGroup(params) {
4550
4819
  }
4551
4820
  }
4552
4821
  if (group2 || title) {
4553
- await persistThread({
4822
+ await prepareDbGroupTarget({
4554
4823
  profile: params.profile,
4555
- scopeThreadId: params.groupId,
4556
- rawThreadId: params.groupId,
4557
- threadType: "group",
4824
+ api: params.api,
4825
+ groupId: params.groupId,
4826
+ group: group2,
4558
4827
  title,
4559
- rawJson: group2 ? JSON.stringify(group2) : void 0
4828
+ rawJson: group2 ? JSON.stringify(group2) : void 0,
4829
+ pinnedIds: /* @__PURE__ */ new Set(),
4830
+ hiddenIds: /* @__PURE__ */ new Set(),
4831
+ hydrateMembers: Boolean(group2)
4560
4832
  });
4561
- try {
4562
- await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
4563
- } catch {
4564
- }
4565
4833
  return;
4566
4834
  }
4567
4835
  if (params.fallbackTitle?.trim()) {
@@ -4631,16 +4899,23 @@ async function syncDbGroupHistoryFull(params) {
4631
4899
  pagesRequested = result.pagesRequested;
4632
4900
  listenerImportedCount = await getStoredGroupMessageCount() - beforeCount;
4633
4901
  } catch (error) {
4634
- stopReason = `fallback_window:${toErrorText(error)}`;
4902
+ stopReason = `fallback_window:${toErrorText2(error)}`;
4635
4903
  completeness = "window";
4636
4904
  }
4637
4905
  const fallbackCount = 200;
4638
4906
  params.progress?.(`merging recent group API window (${fallbackCount} per group)`);
4639
4907
  const beforeApiCount = await getStoredGroupMessageCount();
4908
+ const topoffErrors = [];
4640
4909
  for (const groupId of params.targetGroupIds) {
4641
- const messages = await fetchRecentGroupMessagesViaApi(params.api, groupId, fallbackCount);
4642
- await persistMessages(messages);
4643
- params.progress?.(`group ${groupId}: fetched ${messages.length} message(s) from group history API`);
4910
+ try {
4911
+ const messages = await fetchRecentGroupMessagesViaApi(params.api, groupId, fallbackCount);
4912
+ await persistMessages(messages);
4913
+ params.progress?.(`group ${groupId}: fetched ${messages.length} message(s) from group history API`);
4914
+ } catch (error) {
4915
+ const message = toErrorText2(error);
4916
+ topoffErrors.push({ groupId, error: message });
4917
+ params.progress?.(`group ${groupId}: group history API skipped (${message})`);
4918
+ }
4644
4919
  }
4645
4920
  const afterCount = await getStoredGroupMessageCount();
4646
4921
  const apiAddedCount = afterCount - beforeApiCount;
@@ -4670,7 +4945,8 @@ async function syncDbGroupHistoryFull(params) {
4670
4945
  imported,
4671
4946
  completeness,
4672
4947
  stopReason,
4673
- pagesRequested
4948
+ pagesRequested,
4949
+ topoffErrors
4674
4950
  });
4675
4951
  }
4676
4952
  async function syncDbFriendDirectory(params) {
@@ -4807,25 +5083,70 @@ async function runDbSync(params) {
4807
5083
  });
4808
5084
  }
4809
5085
  if (params.mode === "all" || params.mode === "groups") {
4810
- const groups = await buildGroupsDetailed(api);
5086
+ const groups = await api.getAllGroups();
5087
+ const groupIds = Object.keys(groups.gridVerMap ?? {});
4811
5088
  const targetGroupIds = /* @__PURE__ */ new Set();
4812
5089
  const titleById = /* @__PURE__ */ new Map();
4813
- for (const group2 of groups) {
4814
- const record = group2;
4815
- const groupId = normalizeCachedId(record.groupId);
4816
- if (!groupId) continue;
4817
- const title = typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
4818
- targetGroupIds.add(groupId);
4819
- titleById.set(groupId, title);
4820
- await prepareDbGroupTarget({
4821
- profile,
4822
- api,
4823
- groupId,
4824
- title,
4825
- rawJson: JSON.stringify(group2),
4826
- pinnedIds,
4827
- hiddenIds
4828
- });
5090
+ params.progress?.(`syncing group directory for ${groupIds.length} group(s)`);
5091
+ for (const groupId of groupIds) {
5092
+ let group2;
5093
+ let title;
5094
+ try {
5095
+ try {
5096
+ group2 = await fetchGroupInfoRecord(api, groupId);
5097
+ title = extractGroupTitle(group2);
5098
+ } catch (error) {
5099
+ const message = toErrorText2(error);
5100
+ params.progress?.(`group ${groupId}: metadata unavailable (${message}), continuing`);
5101
+ summary.syncState.push({
5102
+ kind: "group",
5103
+ groupId,
5104
+ status: "warning",
5105
+ stage: "metadata",
5106
+ error: message
5107
+ });
5108
+ }
5109
+ const { memberSnapshotError } = await prepareDbGroupTarget({
5110
+ profile,
5111
+ api,
5112
+ groupId,
5113
+ group: group2,
5114
+ title,
5115
+ rawJson: group2 ? JSON.stringify(group2) : void 0,
5116
+ pinnedIds,
5117
+ hiddenIds,
5118
+ hydrateMembers: Boolean(group2)
5119
+ });
5120
+ if (memberSnapshotError) {
5121
+ params.progress?.(`group ${groupId}: member snapshot unavailable (${memberSnapshotError}), continuing`);
5122
+ summary.syncState.push({
5123
+ kind: "group",
5124
+ groupId,
5125
+ status: "warning",
5126
+ stage: "members",
5127
+ error: memberSnapshotError
5128
+ });
5129
+ }
5130
+ targetGroupIds.add(groupId);
5131
+ titleById.set(groupId, title);
5132
+ } catch (error) {
5133
+ const message = toErrorText2(error);
5134
+ params.progress?.(`group ${groupId}: skipped (${message})`);
5135
+ await setSyncState({
5136
+ profile,
5137
+ scopeThreadId: groupId,
5138
+ threadType: "group",
5139
+ status: "error",
5140
+ error: message
5141
+ });
5142
+ summary.syncState.push({
5143
+ kind: "group",
5144
+ groupId,
5145
+ status: "error",
5146
+ stage: "prepare",
5147
+ error: message
5148
+ });
5149
+ }
4829
5150
  }
4830
5151
  await syncDbGroupHistoryFull({
4831
5152
  profile,
@@ -4841,18 +5162,29 @@ async function runDbSync(params) {
4841
5162
  if (!params.groupId) {
4842
5163
  throw new Error("Missing group id for db sync group.");
4843
5164
  }
4844
- const groupInfo = await api.getGroupInfo(params.groupId);
4845
- const group2 = groupInfo.gridInfoMap[params.groupId];
4846
- const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : void 0;
4847
- await prepareDbGroupTarget({
5165
+ const group2 = await fetchGroupInfoRecord(api, params.groupId);
5166
+ const title = extractGroupTitle(group2);
5167
+ const { memberSnapshotError } = await prepareDbGroupTarget({
4848
5168
  profile,
4849
5169
  api,
4850
5170
  groupId: params.groupId,
5171
+ group: group2,
4851
5172
  title,
4852
5173
  rawJson: group2 ? JSON.stringify(group2) : void 0,
4853
5174
  pinnedIds,
4854
- hiddenIds
5175
+ hiddenIds,
5176
+ hydrateMembers: Boolean(group2)
4855
5177
  });
5178
+ if (memberSnapshotError) {
5179
+ params.progress?.(`group ${params.groupId}: member snapshot unavailable (${memberSnapshotError}), continuing`);
5180
+ summary.syncState.push({
5181
+ kind: "group",
5182
+ groupId: params.groupId,
5183
+ status: "warning",
5184
+ stage: "members",
5185
+ error: memberSnapshotError
5186
+ });
5187
+ }
4856
5188
  await syncDbGroupHistoryFull({
4857
5189
  profile,
4858
5190
  api,
@@ -4911,8 +5243,8 @@ async function buildGroupsDetailed(api) {
4911
5243
  const groups = await api.getAllGroups();
4912
5244
  const ids = Object.keys(groups.gridVerMap ?? {});
4913
5245
  if (ids.length === 0) return [];
4914
- const info = await api.getGroupInfo(ids);
4915
- return ids.map((id) => info.gridInfoMap?.[id]).filter((item) => Boolean(item));
5246
+ const info = await fetchGroupInfoRecords(api, ids);
5247
+ return ids.map((id) => info.get(id)).filter((item) => Boolean(item));
4916
5248
  }
4917
5249
  function normalizeGroupMemberId(value) {
4918
5250
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -4923,9 +5255,8 @@ function normalizeGroupMemberId(value) {
4923
5255
  if (!trimmed) return "";
4924
5256
  return trimmed.replace(/_\d+$/, "");
4925
5257
  }
4926
- async function listGroupMemberRows(api, groupId) {
4927
- const info = await api.getGroupInfo(groupId);
4928
- const groupInfo = info.gridInfoMap[groupId];
5258
+ async function listGroupMemberRows(api, groupId, preloadedGroupInfo) {
5259
+ const groupInfo = preloadedGroupInfo ?? await fetchGroupInfoRecord(api, groupId);
4929
5260
  if (!groupInfo) {
4930
5261
  throw new Error(`Group not found: ${groupId}`);
4931
5262
  }
@@ -4949,10 +5280,9 @@ async function listGroupMemberRows(api, groupId) {
4949
5280
  ...Array.from(currentMemberMap.keys())
4950
5281
  ])
4951
5282
  );
4952
- const profiles = ids.length > 0 ? await api.getGroupMembersInfo(ids) : { profiles: {} };
4953
- const rawProfileMap = profiles.profiles;
5283
+ const profileLookup = ids.length > 0 ? await fetchGroupMemberProfiles(api, ids) : /* @__PURE__ */ new Map();
4954
5284
  const profileMap = /* @__PURE__ */ new Map();
4955
- for (const [key, profile] of Object.entries(rawProfileMap)) {
5285
+ for (const [key, profile] of profileLookup.entries()) {
4956
5286
  if (!profile) continue;
4957
5287
  const normalizedKey = normalizeGroupMemberId(key);
4958
5288
  if (normalizedKey && !profileMap.has(normalizedKey)) {
@@ -5007,7 +5337,7 @@ function isListenerAlreadyStarted(error) {
5007
5337
  if (!(error instanceof Error)) return false;
5008
5338
  return /already started/i.test(error.message);
5009
5339
  }
5010
- function toErrorText(error) {
5340
+ function toErrorText2(error) {
5011
5341
  return error instanceof Error ? error.message : String(error);
5012
5342
  }
5013
5343
  var SHUTDOWN_CALLBACKS = /* @__PURE__ */ new Set();
@@ -5045,7 +5375,7 @@ async function runShutdownCallbacks(signal) {
5045
5375
  "process.signal.callback_error",
5046
5376
  {
5047
5377
  signal,
5048
- message: toErrorText(error)
5378
+ message: toErrorText2(error)
5049
5379
  },
5050
5380
  void 0
5051
5381
  );
@@ -5128,7 +5458,7 @@ async function withUploadListener(api, command, task) {
5128
5458
  writeDebugLine(
5129
5459
  "msg.upload.listener.error",
5130
5460
  {
5131
- message: toErrorText(error)
5461
+ message: toErrorText2(error)
5132
5462
  },
5133
5463
  command
5134
5464
  );
@@ -5170,7 +5500,7 @@ async function withUploadListener(api, command, task) {
5170
5500
  finish();
5171
5501
  };
5172
5502
  const onConnectError = (error) => {
5173
- finish(new Error(`Upload listener connection error: ${toErrorText(error)}`));
5503
+ finish(new Error(`Upload listener connection error: ${toErrorText2(error)}`));
5174
5504
  };
5175
5505
  const onConnectClosed = (code, reason) => {
5176
5506
  finish(
@@ -5796,7 +6126,7 @@ function toDbRecordFromRecentMessage(params) {
5796
6126
  });
5797
6127
  }
5798
6128
  async function parseCredentialFile(filePath) {
5799
- const raw = await fs6.readFile(filePath, "utf8");
6129
+ const raw = await fs7.readFile(filePath, "utf8");
5800
6130
  const parsed = JSON.parse(raw);
5801
6131
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
5802
6132
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -5808,7 +6138,7 @@ async function parseCredentialFile(filePath) {
5808
6138
  language: parsed.language
5809
6139
  };
5810
6140
  }
5811
- function sleep2(ms) {
6141
+ function sleep3(ms) {
5812
6142
  return new Promise((resolve) => {
5813
6143
  setTimeout(resolve, ms);
5814
6144
  });
@@ -5817,13 +6147,13 @@ async function waitForFileContent(filePath, timeoutMs) {
5817
6147
  const startedAt = Date.now();
5818
6148
  while (Date.now() - startedAt < timeoutMs) {
5819
6149
  try {
5820
- const data = await fs6.readFile(filePath);
6150
+ const data = await fs7.readFile(filePath);
5821
6151
  if (data.length > 0) {
5822
6152
  return data;
5823
6153
  }
5824
6154
  } catch {
5825
6155
  }
5826
- await sleep2(150);
6156
+ await sleep3(150);
5827
6157
  }
5828
6158
  throw new Error(`Timed out waiting for QR image file: ${filePath}`);
5829
6159
  }
@@ -5832,8 +6162,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
5832
6162
  if (!scriptPath) {
5833
6163
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
5834
6164
  }
5835
- const tempDir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-qr-"));
5836
- 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"));
5837
6167
  const child = spawn2(
5838
6168
  process.execPath,
5839
6169
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -6111,7 +6441,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
6111
6441
  if (fromType) return fromType;
6112
6442
  try {
6113
6443
  const parsedUrl = new URL(mediaUrl);
6114
- const ext = path6.extname(parsedUrl.pathname);
6444
+ const ext = path7.extname(parsedUrl.pathname);
6115
6445
  if (ext) return ext;
6116
6446
  } catch {
6117
6447
  }
@@ -6142,20 +6472,20 @@ function parseInboundMediaFetchTimeoutMs() {
6142
6472
  return Math.trunc(parsed);
6143
6473
  }
6144
6474
  function resolveOpenClawMediaDir() {
6145
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path6.join(os4.homedir(), ".openclaw");
6146
- 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");
6147
6477
  }
6148
6478
  function resolveInboundMediaDir(profile) {
6149
6479
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
6150
6480
  if (configuredRaw) {
6151
6481
  const configured = normalizeMediaInput(configuredRaw);
6152
- return path6.isAbsolute(configured) ? configured : path6.resolve(process.cwd(), configured);
6482
+ return path7.isAbsolute(configured) ? configured : path7.resolve(process.cwd(), configured);
6153
6483
  }
6154
6484
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
6155
6485
  if (legacyRequested) {
6156
- return path6.join(getProfileDir(profile), "inbound-media");
6486
+ return path7.join(getProfileDir(profile), "inbound-media");
6157
6487
  }
6158
- return path6.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6488
+ return path7.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6159
6489
  }
6160
6490
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6161
6491
  const maxBytes = parseMaxInboundMediaBytes();
@@ -6189,11 +6519,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6189
6519
  return null;
6190
6520
  }
6191
6521
  const dir = resolveInboundMediaDir(profile);
6192
- await fs6.mkdir(dir, { recursive: true });
6522
+ await fs7.mkdir(dir, { recursive: true });
6193
6523
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
6194
6524
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
6195
- const mediaPath = path6.join(dir, `${id}${ext}`);
6196
- await fs6.writeFile(mediaPath, data);
6525
+ const mediaPath = path7.join(dir, `${id}${ext}`);
6526
+ await fs7.writeFile(mediaPath, data);
6197
6527
  return { mediaPath, mediaType };
6198
6528
  }
6199
6529
  async function cacheRemoteMediaEntries(params) {
@@ -6703,7 +7033,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
6703
7033
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
6704
7034
  wrapAction(async (file, command) => {
6705
7035
  const profile = await currentProfile(command);
6706
- const credentials = file ? await parseCredentialFile(path6.resolve(normalizeMediaInput(file))) : toCredentials(
7036
+ const credentials = file ? await parseCredentialFile(path7.resolve(normalizeMediaInput(file))) : toCredentials(
6707
7037
  await loadCredentials(profile) ?? (() => {
6708
7038
  throw new Error(
6709
7039
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -6810,7 +7140,7 @@ dbCmd.command("reset").option("-y, --yes", "Delete the SQLite DB file for the ac
6810
7140
  const removedPaths = [];
6811
7141
  const deleteIfExists = async (filename) => {
6812
7142
  try {
6813
- await fs6.unlink(filename);
7143
+ await fs7.unlink(filename);
6814
7144
  removedPaths.push(filename);
6815
7145
  } catch (error) {
6816
7146
  if (error.code !== "ENOENT") {
@@ -7523,53 +7853,108 @@ msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (rep
7523
7853
  }
7524
7854
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
7525
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;
7526
7859
  writeDebugLine(
7527
7860
  "msg.voice.inputs",
7528
7861
  {
7529
7862
  threadId,
7530
7863
  isGroup: Boolean(opts.group),
7531
7864
  localInputs,
7532
- urlInputs
7865
+ urlInputs,
7866
+ publishConfigured: Boolean(publishCommand),
7867
+ ffmpegAvailable: localInputs.length > 0 ? ffmpegAvailable : void 0,
7868
+ mode: usePublishFlow ? "publish" : "legacy"
7533
7869
  },
7534
7870
  command
7535
7871
  );
7536
- const downloaded = await downloadUrlsToTempFiles(urlInputs);
7537
- try {
7538
- const attachments = [...localInputs, ...downloaded.files];
7539
- await assertFilesExist(attachments);
7540
- const results = [];
7541
- const uploaded = await api.uploadAttachment(attachments, threadId, type);
7542
- for (const item of uploaded) {
7543
- if (item.fileType === "others" || item.fileType === "video") {
7544
- 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();
7545
7886
  }
7546
7887
  }
7547
- if (results.length === 0) {
7548
- throw new Error(
7549
- "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
7550
- );
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;
7551
7904
  }
7552
- output(results, false);
7553
- if (await shouldWriteToDb(profile)) {
7554
- scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
7555
- await persistOutgoingMessageBestEffort({
7556
- profile,
7557
- api,
7558
- threadId,
7559
- group: opts.group,
7560
- msgType: "voice",
7561
- response: results,
7562
- rawPayload: uploaded,
7563
- media: uploaded.map((item) => ({
7564
- mediaKind: "voice",
7565
- mediaUrl: "fileUrl" in item && typeof item.fileUrl === "string" ? item.fileUrl : void 0,
7566
- rawJson: JSON.stringify(item)
7567
- }))
7568
- });
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)
7569
7922
  });
7570
7923
  }
7571
- } finally {
7572
- 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
+ });
7573
7958
  }
7574
7959
  }
7575
7960
  )
@@ -9052,7 +9437,7 @@ ${replyContextText}` : replyContextText;
9052
9437
  code,
9053
9438
  reason: reason || void 0,
9054
9439
  delayMs: keepAliveRestartDelayMs,
9055
- message: toErrorText(error),
9440
+ message: toErrorText2(error),
9056
9441
  sessionId
9057
9442
  },
9058
9443
  command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.56",
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": {