openzca 0.1.46 → 0.1.48

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 +11 -3
  2. package/dist/cli.js +300 -40
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -50,7 +50,7 @@ openzca listen
50
50
 
51
51
  | Command | Description |
52
52
  |---------|-------------|
53
- | `openzca auth login` | Login with QR code (`--qr-path <path>` to save QR image) |
53
+ | `openzca auth login` | Login with QR code (`--qr-path <path>` to save QR image, `--qr-base64` for integration mode) |
54
54
  | `openzca auth login-cred [file]` | Login using a credential JSON file |
55
55
  | `openzca auth logout` | Remove saved credentials |
56
56
  | `openzca auth status` | Show login status |
@@ -67,9 +67,9 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
67
67
 
68
68
  | Command | Description |
69
69
  |---------|-------------|
70
- | `openzca msg send <threadId> <message>` | Send text message |
70
+ | `openzca msg send <threadId> <message>` | Send text with formatting (`**bold**`, `*italic*`, `~~strike~~`, etc.) and group @mention resolution (`--raw` to skip formatting) |
71
71
  | `openzca msg image <threadId> [file]` | Send image(s) from file or URL |
72
- | `openzca msg video <threadId> [file]` | Send video(s) from file or URL |
72
+ | `openzca msg video <threadId> [file]` | Send video(s) from file or URL; single `.mp4` inputs try native video mode |
73
73
  | `openzca msg voice <threadId> [file]` | Send voice message from local file or URL (`.aac`, `.mp3`, `.m4a`, `.wav`, `.ogg`) |
74
74
  | `openzca msg sticker <threadId> <stickerId>` | Send a sticker |
75
75
  | `openzca msg link <threadId> <url>` | Send a link |
@@ -78,11 +78,17 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
78
78
  | `openzca msg typing <threadId>` | Send typing indicator |
79
79
  | `openzca msg forward <message> <targets...>` | Forward text to multiple targets |
80
80
  | `openzca msg delete <msgId> <cliMsgId> <uidFrom> <threadId>` | Delete a message |
81
+ | `openzca msg edit <msgId> <cliMsgId> <threadId> <message>` | Edit message (undo + resend shim) |
81
82
  | `openzca msg undo <msgId> <cliMsgId> <threadId>` | Recall a sent message |
82
83
  | `openzca msg upload <arg1> [arg2]` | Upload and send file(s) |
83
84
  | `openzca msg recent <threadId>` | List recent messages (`-n`, `--json`, newest-first); group mode prefers direct group-history endpoint (websocket fallback) |
85
+ | `openzca msg pin <threadId>` | Pin a conversation |
86
+ | `openzca msg unpin <threadId>` | Unpin a conversation |
87
+ | `openzca msg list-pins` | List pinned conversations |
88
+ | `openzca msg member-info <userId>` | Get member/user profile info |
84
89
 
85
90
  Media commands accept local files, `file://` paths, and repeatable `--url` options. Add `--group` for group threads.
91
+ `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.
86
92
  Local paths using `~` are expanded automatically (for positional file args, `--url`, and `OPENZCA_LISTEN_MEDIA_DIR`).
87
93
  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.
88
94
 
@@ -166,6 +172,7 @@ Poll creation currently targets group threads only and maps to the existing `zca
166
172
  | `openzca friend reject <userId>` | Reject friend request |
167
173
  | `openzca friend cancel <userId>` | Cancel sent friend request |
168
174
  | `openzca friend sent` | List sent requests |
175
+ | `openzca friend request-status <userId>` | Check friend request status for user |
169
176
  | `openzca friend remove <userId>` | Remove a friend |
170
177
  | `openzca friend alias <userId> <alias>` | Set friend alias |
171
178
  | `openzca friend remove-alias <userId>` | Remove alias |
@@ -174,6 +181,7 @@ Poll creation currently targets group threads only and maps to the existing `zca
174
181
  | `openzca friend unblock <userId>` | Unblock user |
175
182
  | `openzca friend block-feed <userId>` | Block user from viewing your feed |
176
183
  | `openzca friend unblock-feed <userId>` | Unblock user from viewing your feed |
184
+ | `openzca friend boards <conversationId>` | Get boards in conversation |
177
185
 
178
186
  ### me — Profile
179
187
 
package/dist/cli.js CHANGED
@@ -4,10 +4,10 @@
4
4
  import { createRequire } from "module";
5
5
  import { spawn as spawn2 } from "child_process";
6
6
  import fsSync from "fs";
7
- import fs4 from "fs/promises";
7
+ import fs5 from "fs/promises";
8
8
  import net from "net";
9
- import os3 from "os";
10
- import path4 from "path";
9
+ import os4 from "os";
10
+ import path5 from "path";
11
11
  import util from "util";
12
12
  import { Command } from "commander";
13
13
  import {
@@ -1014,6 +1014,187 @@ async function resolveGroupMentionsIfNeeded(params, text) {
1014
1014
  return mentions.length > 0 ? mentions : void 0;
1015
1015
  }
1016
1016
 
1017
+ // src/lib/video-send.ts
1018
+ import { execFile } from "child_process";
1019
+ import fs4 from "fs/promises";
1020
+ import os3 from "os";
1021
+ import path4 from "path";
1022
+ import { promisify } from "util";
1023
+ var execFileAsync = promisify(execFile);
1024
+ function planVideoSendMode(params) {
1025
+ const { files, ffmpegAvailable } = params;
1026
+ if (!ffmpegAvailable) {
1027
+ return {
1028
+ mode: "attachment",
1029
+ reason: "ffmpeg is unavailable for native video mode"
1030
+ };
1031
+ }
1032
+ if (files.length !== 1) {
1033
+ return {
1034
+ mode: "attachment",
1035
+ reason: "native-video mode supports one video at a time"
1036
+ };
1037
+ }
1038
+ const ext = path4.extname(files[0] ?? "").toLowerCase();
1039
+ if (ext !== ".mp4") {
1040
+ return {
1041
+ mode: "attachment",
1042
+ reason: "native-video mode currently supports .mp4 inputs only"
1043
+ };
1044
+ }
1045
+ return { mode: "native" };
1046
+ }
1047
+ function parseVideoProbeOutput(raw) {
1048
+ const parsed = JSON.parse(raw);
1049
+ const videoStream = parsed.streams?.find((stream) => stream.codec_type === "video");
1050
+ const width = toPositiveInteger(videoStream?.width);
1051
+ const height = toPositiveInteger(videoStream?.height);
1052
+ const durationSeconds = toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(parsed.format?.duration);
1053
+ return {
1054
+ durationMs: durationSeconds ? Math.round(durationSeconds * 1e3) : void 0,
1055
+ width,
1056
+ height
1057
+ };
1058
+ }
1059
+ function toPositiveInteger(value) {
1060
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1061
+ return Math.trunc(value);
1062
+ }
1063
+ if (typeof value === "string") {
1064
+ const numeric = Number(value);
1065
+ if (Number.isFinite(numeric) && numeric > 0) {
1066
+ return Math.trunc(numeric);
1067
+ }
1068
+ }
1069
+ return void 0;
1070
+ }
1071
+ function toPositiveNumber(value) {
1072
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1073
+ return value;
1074
+ }
1075
+ if (typeof value === "string") {
1076
+ const numeric = Number(value);
1077
+ if (Number.isFinite(numeric) && numeric > 0) {
1078
+ return numeric;
1079
+ }
1080
+ }
1081
+ return void 0;
1082
+ }
1083
+ async function runBinary(command, args) {
1084
+ try {
1085
+ const { stdout } = await execFileAsync(command, args, {
1086
+ encoding: "utf8",
1087
+ maxBuffer: 10 * 1024 * 1024
1088
+ });
1089
+ return stdout;
1090
+ } catch (error) {
1091
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
1092
+ throw new Error(`${command} is required for native video mode`);
1093
+ }
1094
+ if (error instanceof Error && "stderr" in error) {
1095
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
1096
+ throw new Error(stderr ? `${command} failed: ${stderr}` : `${command} failed: ${error.message}`);
1097
+ }
1098
+ throw error;
1099
+ }
1100
+ }
1101
+ async function isFfmpegAvailable() {
1102
+ try {
1103
+ await execFileAsync("ffmpeg", ["-version"], {
1104
+ encoding: "utf8",
1105
+ maxBuffer: 1024 * 1024
1106
+ });
1107
+ return true;
1108
+ } catch (error) {
1109
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
1110
+ return false;
1111
+ }
1112
+ return false;
1113
+ }
1114
+ }
1115
+ async function maybeProbeVideoFile(filePath) {
1116
+ try {
1117
+ const raw = await runBinary("ffprobe", [
1118
+ "-v",
1119
+ "error",
1120
+ "-print_format",
1121
+ "json",
1122
+ "-show_format",
1123
+ "-show_streams",
1124
+ filePath
1125
+ ]);
1126
+ return parseVideoProbeOutput(raw);
1127
+ } catch {
1128
+ return {};
1129
+ }
1130
+ }
1131
+ async function generateVideoThumbnail(videoPath) {
1132
+ const dir = await fs4.mkdtemp(path4.join(os3.tmpdir(), "openzca-video-thumb-"));
1133
+ const outputPath = path4.join(dir, "thumbnail.jpg");
1134
+ try {
1135
+ await runBinary("ffmpeg", [
1136
+ "-y",
1137
+ "-ss",
1138
+ "1",
1139
+ "-i",
1140
+ videoPath,
1141
+ "-frames:v",
1142
+ "1",
1143
+ "-q:v",
1144
+ "2",
1145
+ outputPath
1146
+ ]);
1147
+ await fs4.access(outputPath);
1148
+ } catch (error) {
1149
+ await fs4.rm(dir, { recursive: true, force: true });
1150
+ throw error;
1151
+ }
1152
+ return {
1153
+ path: outputPath,
1154
+ cleanup: async () => {
1155
+ await fs4.rm(dir, { recursive: true, force: true });
1156
+ }
1157
+ };
1158
+ }
1159
+ function pickUploadedVideoUrl(uploaded) {
1160
+ if (!uploaded || !("fileUrl" in uploaded) || typeof uploaded.fileUrl !== "string" || uploaded.fileUrl.length === 0) {
1161
+ throw new Error("Video upload did not return a file URL");
1162
+ }
1163
+ return uploaded.fileUrl;
1164
+ }
1165
+ function pickUploadedThumbnailUrl(uploaded) {
1166
+ if (!uploaded || uploaded.fileType !== "image") {
1167
+ throw new Error("Thumbnail upload did not return an image result");
1168
+ }
1169
+ return uploaded.normalUrl || uploaded.hdUrl || uploaded.thumbUrl;
1170
+ }
1171
+ async function sendNativeVideo(params) {
1172
+ const metadata = await maybeProbeVideoFile(params.videoPath);
1173
+ const generatedThumbnail = params.thumbnailPath ? null : await generateVideoThumbnail(params.videoPath);
1174
+ const thumbnailPath = params.thumbnailPath ?? generatedThumbnail?.path;
1175
+ if (!thumbnailPath) {
1176
+ throw new Error("Unable to resolve thumbnail path for native video send");
1177
+ }
1178
+ try {
1179
+ const uploadedVideo = await params.api.uploadAttachment([params.videoPath], params.threadId, params.threadType);
1180
+ const uploadedThumbnail = await params.api.uploadAttachment([thumbnailPath], params.threadId, params.threadType);
1181
+ return await params.api.sendVideo(
1182
+ {
1183
+ msg: params.message ?? "",
1184
+ videoUrl: pickUploadedVideoUrl(uploadedVideo[0]),
1185
+ thumbnailUrl: pickUploadedThumbnailUrl(uploadedThumbnail[0]),
1186
+ duration: metadata.durationMs,
1187
+ width: metadata.width,
1188
+ height: metadata.height
1189
+ },
1190
+ params.threadId,
1191
+ params.threadType
1192
+ );
1193
+ } finally {
1194
+ await generatedThumbnail?.cleanup();
1195
+ }
1196
+ }
1197
+
1017
1198
  // src/cli.ts
1018
1199
  var require2 = createRequire(import.meta.url);
1019
1200
  var { version: PKG_VERSION } = require2("../package.json");
@@ -1076,9 +1257,9 @@ function resolveDebugEnabled(command) {
1076
1257
  }
1077
1258
  function resolveDebugFilePath(command) {
1078
1259
  const options = getDebugOptions(command);
1079
- const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path4.join(APP_HOME, "logs", "openzca-debug.log");
1260
+ const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path5.join(APP_HOME, "logs", "openzca-debug.log");
1080
1261
  const normalized = normalizeMediaInput(configured);
1081
- return path4.isAbsolute(normalized) ? normalized : path4.resolve(process.cwd(), normalized);
1262
+ return path5.isAbsolute(normalized) ? normalized : path5.resolve(process.cwd(), normalized);
1082
1263
  }
1083
1264
  function writeDebugLine(event, details, command) {
1084
1265
  if (!resolveDebugEnabled(command)) {
@@ -1089,7 +1270,7 @@ function writeDebugLine(event, details, command) {
1089
1270
  `;
1090
1271
  const filePath = resolveDebugFilePath(command);
1091
1272
  try {
1092
- fsSync.mkdirSync(path4.dirname(filePath), { recursive: true });
1273
+ fsSync.mkdirSync(path5.dirname(filePath), { recursive: true });
1093
1274
  fsSync.appendFileSync(filePath, line, "utf8");
1094
1275
  } catch {
1095
1276
  }
@@ -1177,14 +1358,14 @@ function collectIdsFromCacheEntries(entries, keys) {
1177
1358
  return ids;
1178
1359
  }
1179
1360
  function getListenerOwnerLockPath(profile) {
1180
- return path4.join(getProfileDir(profile), "listener-owner.json");
1361
+ return path5.join(getProfileDir(profile), "listener-owner.json");
1181
1362
  }
1182
1363
  function getListenIpcSocketPath(profile) {
1183
1364
  if (process.platform === "win32") {
1184
1365
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
1185
1366
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
1186
1367
  }
1187
- return path4.join(getProfileDir(profile), "listen.sock");
1368
+ return path5.join(getProfileDir(profile), "listen.sock");
1188
1369
  }
1189
1370
  function parsePositiveIntFromUnknown(value) {
1190
1371
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -1211,7 +1392,7 @@ function isProcessAlive(pid) {
1211
1392
  }
1212
1393
  async function readListenerOwnerRecord(lockPath) {
1213
1394
  try {
1214
- const raw = await fs4.readFile(lockPath, "utf8");
1395
+ const raw = await fs5.readFile(lockPath, "utf8");
1215
1396
  const parsed = JSON.parse(raw);
1216
1397
  const pid = parsePositiveIntFromUnknown(parsed.pid);
1217
1398
  if (!pid) return null;
@@ -1231,11 +1412,11 @@ async function readActiveListenerOwner(profile) {
1231
1412
  const lockPath = getListenerOwnerLockPath(profile);
1232
1413
  const record = await readListenerOwnerRecord(lockPath);
1233
1414
  if (!record) {
1234
- await fs4.rm(lockPath, { force: true });
1415
+ await fs5.rm(lockPath, { force: true });
1235
1416
  return null;
1236
1417
  }
1237
1418
  if (!isProcessAlive(record.pid)) {
1238
- await fs4.rm(lockPath, { force: true });
1419
+ await fs5.rm(lockPath, { force: true });
1239
1420
  return null;
1240
1421
  }
1241
1422
  return record;
@@ -1251,7 +1432,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1251
1432
  };
1252
1433
  for (let attempt = 0; attempt < 3; attempt += 1) {
1253
1434
  try {
1254
- await fs4.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
1435
+ await fs5.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
1255
1436
  `, {
1256
1437
  encoding: "utf8",
1257
1438
  flag: "wx"
@@ -1264,7 +1445,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1264
1445
  released = true;
1265
1446
  const current = await readListenerOwnerRecord(lockPath);
1266
1447
  if (current && current.pid !== process.pid) return;
1267
- await fs4.rm(lockPath, { force: true });
1448
+ await fs5.rm(lockPath, { force: true });
1268
1449
  writeDebugLine(
1269
1450
  "listen.owner.released",
1270
1451
  {
@@ -1285,7 +1466,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1285
1466
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
1286
1467
  );
1287
1468
  }
1288
- await fs4.rm(lockPath, { force: true });
1469
+ await fs5.rm(lockPath, { force: true });
1289
1470
  }
1290
1471
  }
1291
1472
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -1303,7 +1484,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1303
1484
  }
1304
1485
  const socketPath = getListenIpcSocketPath(profile);
1305
1486
  if (process.platform !== "win32") {
1306
- await fs4.rm(socketPath, { force: true });
1487
+ await fs5.rm(socketPath, { force: true });
1307
1488
  }
1308
1489
  const uploadTimeoutMs = parsePositiveIntFromEnv(
1309
1490
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -1475,7 +1656,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1475
1656
  server.close(() => resolve());
1476
1657
  });
1477
1658
  if (process.platform !== "win32") {
1478
- await fs4.rm(socketPath, { force: true });
1659
+ await fs5.rm(socketPath, { force: true });
1479
1660
  }
1480
1661
  writeDebugLine(
1481
1662
  "listen.ipc.stopped",
@@ -2418,7 +2599,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2418
2599
  });
2419
2600
  }
2420
2601
  async function parseCredentialFile(filePath) {
2421
- const raw = await fs4.readFile(filePath, "utf8");
2602
+ const raw = await fs5.readFile(filePath, "utf8");
2422
2603
  const parsed = JSON.parse(raw);
2423
2604
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
2424
2605
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -2439,7 +2620,7 @@ async function waitForFileContent(filePath, timeoutMs) {
2439
2620
  const startedAt = Date.now();
2440
2621
  while (Date.now() - startedAt < timeoutMs) {
2441
2622
  try {
2442
- const data = await fs4.readFile(filePath);
2623
+ const data = await fs5.readFile(filePath);
2443
2624
  if (data.length > 0) {
2444
2625
  return data;
2445
2626
  }
@@ -2454,8 +2635,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
2454
2635
  if (!scriptPath) {
2455
2636
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
2456
2637
  }
2457
- const tempDir = await fs4.mkdtemp(path4.join(os3.tmpdir(), "openzca-qr-"));
2458
- const targetPath = path4.resolve(qrPath ?? path4.join(tempDir, "qr.png"));
2638
+ const tempDir = await fs5.mkdtemp(path5.join(os4.tmpdir(), "openzca-qr-"));
2639
+ const targetPath = path5.resolve(qrPath ?? path5.join(tempDir, "qr.png"));
2459
2640
  const child = spawn2(
2460
2641
  process.execPath,
2461
2642
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -2733,7 +2914,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
2733
2914
  if (fromType) return fromType;
2734
2915
  try {
2735
2916
  const parsedUrl = new URL(mediaUrl);
2736
- const ext = path4.extname(parsedUrl.pathname);
2917
+ const ext = path5.extname(parsedUrl.pathname);
2737
2918
  if (ext) return ext;
2738
2919
  } catch {
2739
2920
  }
@@ -2764,20 +2945,20 @@ function parseInboundMediaFetchTimeoutMs() {
2764
2945
  return Math.trunc(parsed);
2765
2946
  }
2766
2947
  function resolveOpenClawMediaDir() {
2767
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path4.join(os3.homedir(), ".openclaw");
2768
- return path4.join(stateDir, "media");
2948
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path5.join(os4.homedir(), ".openclaw");
2949
+ return path5.join(stateDir, "media");
2769
2950
  }
2770
2951
  function resolveInboundMediaDir(profile) {
2771
2952
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
2772
2953
  if (configuredRaw) {
2773
2954
  const configured = normalizeMediaInput(configuredRaw);
2774
- return path4.isAbsolute(configured) ? configured : path4.resolve(process.cwd(), configured);
2955
+ return path5.isAbsolute(configured) ? configured : path5.resolve(process.cwd(), configured);
2775
2956
  }
2776
2957
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
2777
2958
  if (legacyRequested) {
2778
- return path4.join(getProfileDir(profile), "inbound-media");
2959
+ return path5.join(getProfileDir(profile), "inbound-media");
2779
2960
  }
2780
- return path4.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
2961
+ return path5.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
2781
2962
  }
2782
2963
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2783
2964
  const maxBytes = parseMaxInboundMediaBytes();
@@ -2811,11 +2992,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2811
2992
  return null;
2812
2993
  }
2813
2994
  const dir = resolveInboundMediaDir(profile);
2814
- await fs4.mkdir(dir, { recursive: true });
2995
+ await fs5.mkdir(dir, { recursive: true });
2815
2996
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
2816
2997
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
2817
- const mediaPath = path4.join(dir, `${id}${ext}`);
2818
- await fs4.writeFile(mediaPath, data);
2998
+ const mediaPath = path5.join(dir, `${id}${ext}`);
2999
+ await fs5.writeFile(mediaPath, data);
2819
3000
  return { mediaPath, mediaType };
2820
3001
  }
2821
3002
  async function cacheRemoteMediaEntries(params) {
@@ -3307,7 +3488,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
3307
3488
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
3308
3489
  wrapAction(async (file, command) => {
3309
3490
  const profile = await currentProfile(command);
3310
- const credentials = file ? await parseCredentialFile(path4.resolve(normalizeMediaInput(file))) : toCredentials(
3491
+ const credentials = file ? await parseCredentialFile(path5.resolve(normalizeMediaInput(file))) : toCredentials(
3311
3492
  await loadCredentials(profile) ?? (() => {
3312
3493
  throw new Error(
3313
3494
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -3436,42 +3617,121 @@ msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (rep
3436
3617
  }
3437
3618
  )
3438
3619
  );
3439
- msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (repeatable)", collectValues, []).option("-m, --message <message>", "Caption").option("--thumbnail <url>", "Thumbnail URL (kept for compatibility)").option("-g, --group", "Send to group").description("Send video(s) from file or URL").action(
3620
+ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (repeatable)", collectValues, []).option("-m, --message <message>", "Caption").option("--thumbnail <pathOrUrl>", "Thumbnail image path or URL (optional)").option("-g, --group", "Send to group").description("Send video(s) from file or URL").action(
3440
3621
  wrapAction(
3441
3622
  async (threadId, file, opts, command) => {
3442
- const { api } = await requireApi(command);
3623
+ const { api, profile } = await requireApi(command);
3624
+ const threadType = asThreadType(opts.group);
3443
3625
  const normalizedFile = file ? normalizeMediaInput(file) : void 0;
3444
3626
  const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
3445
3627
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
3446
3628
  const localInputs = files.filter((entry) => !isHttpUrl(entry));
3629
+ const normalizedThumbnail = opts.thumbnail ? normalizeMediaInput(opts.thumbnail) : void 0;
3630
+ const thumbnailUrlInputs = normalizedThumbnail && isHttpUrl(normalizedThumbnail) ? [normalizedThumbnail] : [];
3631
+ const thumbnailLocalPath = normalizedThumbnail && !isHttpUrl(normalizedThumbnail) ? normalizedThumbnail : void 0;
3447
3632
  writeDebugLine(
3448
3633
  "msg.video.inputs",
3449
3634
  {
3450
3635
  threadId,
3451
3636
  isGroup: Boolean(opts.group),
3452
3637
  localInputs,
3453
- urlInputs
3638
+ urlInputs,
3639
+ thumbnail: normalizedThumbnail
3454
3640
  },
3455
3641
  command
3456
3642
  );
3457
3643
  const downloaded = await downloadUrlsToTempFiles(urlInputs);
3644
+ const downloadedThumbnail = await downloadUrlsToTempFiles(thumbnailUrlInputs);
3458
3645
  try {
3459
3646
  const attachments = [...localInputs, ...downloaded.files];
3460
3647
  if (attachments.length === 0) {
3461
3648
  throw new Error("Provide at least one video file or --url.");
3462
3649
  }
3463
3650
  await assertFilesExist(attachments);
3464
- const response = await api.sendMessage(
3465
- {
3466
- msg: opts.message ?? "",
3467
- attachments
3468
- },
3469
- threadId,
3470
- asThreadType(opts.group)
3651
+ if (thumbnailLocalPath) {
3652
+ await assertFilesExist([thumbnailLocalPath]);
3653
+ }
3654
+ const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER", true);
3655
+ if (enforceSingleOwner) {
3656
+ const owner = await readActiveListenerOwner(profile);
3657
+ if (owner && owner.pid !== process.pid) {
3658
+ throw new Error(
3659
+ `Active listener owner detected for profile "${profile}" (pid ${owner.pid}), but video upload IPC is unavailable. Restart \`openzca listen\` with latest version or set OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER=0 to allow fallback listener startup.`
3660
+ );
3661
+ }
3662
+ }
3663
+ const ffmpegAvailable = await isFfmpegAvailable();
3664
+ const videoPlan = planVideoSendMode({
3665
+ files: attachments,
3666
+ ffmpegAvailable
3667
+ });
3668
+ if (videoPlan.mode === "native") {
3669
+ const thumbnailPath = thumbnailLocalPath ?? downloadedThumbnail.files[0];
3670
+ try {
3671
+ const response2 = await withUploadListener(
3672
+ api,
3673
+ command,
3674
+ async () => sendNativeVideo({
3675
+ api,
3676
+ threadId,
3677
+ threadType,
3678
+ videoPath: attachments[0],
3679
+ message: opts.message,
3680
+ thumbnailPath
3681
+ })
3682
+ );
3683
+ writeDebugLine(
3684
+ "msg.video.native.success",
3685
+ {
3686
+ threadId,
3687
+ isGroup: Boolean(opts.group),
3688
+ videoPath: attachments[0],
3689
+ thumbnailPath: thumbnailPath ?? null
3690
+ },
3691
+ command
3692
+ );
3693
+ output(response2, false);
3694
+ return;
3695
+ } catch (error) {
3696
+ writeDebugLine(
3697
+ "msg.video.native.failed",
3698
+ {
3699
+ threadId,
3700
+ isGroup: Boolean(opts.group),
3701
+ videoPath: attachments[0],
3702
+ thumbnailPath: thumbnailPath ?? null,
3703
+ message: error instanceof Error ? error.message : String(error)
3704
+ },
3705
+ command
3706
+ );
3707
+ }
3708
+ } else {
3709
+ writeDebugLine(
3710
+ "msg.video.native.skipped",
3711
+ {
3712
+ threadId,
3713
+ isGroup: Boolean(opts.group),
3714
+ reason: videoPlan.reason
3715
+ },
3716
+ command
3717
+ );
3718
+ }
3719
+ const response = await withUploadListener(
3720
+ api,
3721
+ command,
3722
+ async () => api.sendMessage(
3723
+ {
3724
+ msg: opts.message ?? "",
3725
+ attachments
3726
+ },
3727
+ threadId,
3728
+ threadType
3729
+ )
3471
3730
  );
3472
3731
  output(response, false);
3473
3732
  } finally {
3474
3733
  await downloaded.cleanup();
3734
+ await downloadedThumbnail.cleanup();
3475
3735
  }
3476
3736
  }
3477
3737
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "commander": "^14.0.3",
47
47
  "image-size": "^2.0.2",
48
48
  "qrcode-terminal": "^0.12.0",
49
- "zca-js": "^2.0.4"
49
+ "zca-js": "^2.1.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.2.3",