openzca 0.1.45 → 0.1.47

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 +9 -1
  2. package/dist/cli.js +399 -40
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -69,7 +69,7 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
69
69
  |---------|-------------|
70
70
  | `openzca msg send <threadId> <message>` | Send text message |
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 |
@@ -83,6 +83,7 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
83
83
  | `openzca msg recent <threadId>` | List recent messages (`-n`, `--json`, newest-first); group mode prefers direct group-history endpoint (websocket fallback) |
84
84
 
85
85
  Media commands accept local files, `file://` paths, and repeatable `--url` options. Add `--group` for group threads.
86
+ `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
87
  Local paths using `~` are expanded automatically (for positional file args, `--url`, and `OPENZCA_LISTEN_MEDIA_DIR`).
87
88
  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
89
 
@@ -126,6 +127,11 @@ For media debugging, grep these events in the debug log:
126
127
  | `openzca group info <groupId>` | Get group details |
127
128
  | `openzca group members <groupId>` | List members |
128
129
  | `openzca group create <name> <members...>` | Create a group |
130
+ | `openzca group poll create <groupId>` | Create a poll (`--question`, repeatable `--option`, optional poll flags) |
131
+ | `openzca group poll detail <pollId>` | Get poll details |
132
+ | `openzca group poll vote <pollId>` | Vote on a poll with repeatable `--option <id>` |
133
+ | `openzca group poll lock <pollId>` | Close a poll |
134
+ | `openzca group poll share <pollId>` | Share a poll |
129
135
  | `openzca group rename <groupId> <name>` | Rename group |
130
136
  | `openzca group avatar <groupId> <file>` | Change group avatar |
131
137
  | `openzca group settings <groupId>` | Update settings (`--lock-name`, `--sign-admin`, etc.) |
@@ -146,6 +152,8 @@ For media debugging, grep these events in the debug log:
146
152
  | `openzca group leave <groupId>` | Leave group |
147
153
  | `openzca group disperse <groupId>` | Disperse group |
148
154
 
155
+ Poll creation currently targets group threads only and maps to the existing `zca-js` group poll APIs. `group poll create` requires `--question` plus at least two `--option` values, and also supports `--multi`, `--allow-add-option`, `--hide-vote-preview`, `--anonymous`, and `--expire-ms`.
156
+
149
157
  ### friend — Friend management
150
158
 
151
159
  | Command | Description |
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 {
@@ -578,6 +578,60 @@ async function assertFilesExist(files) {
578
578
  }
579
579
  }
580
580
 
581
+ // src/lib/group-poll.ts
582
+ function parsePositiveInteger(value, label) {
583
+ const normalized = value.trim();
584
+ if (!/^[1-9]\d*$/.test(normalized)) {
585
+ throw new Error(`${label} must be a positive integer.`);
586
+ }
587
+ const parsed = Number.parseInt(normalized, 10);
588
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
589
+ throw new Error(`${label} must be a positive integer.`);
590
+ }
591
+ return parsed;
592
+ }
593
+ function requireQuestion(value) {
594
+ const normalized = value?.trim() ?? "";
595
+ if (!normalized) {
596
+ throw new Error("Poll question is required.");
597
+ }
598
+ return normalized;
599
+ }
600
+ function normalizeOptions(values) {
601
+ const normalized = (values ?? []).map((value) => value.trim());
602
+ if (normalized.length < 2) {
603
+ throw new Error("Poll must include at least two options.");
604
+ }
605
+ for (let index = 0; index < normalized.length; index += 1) {
606
+ if (!normalized[index]) {
607
+ throw new Error(`Poll option ${index + 1} cannot be empty.`);
608
+ }
609
+ }
610
+ return normalized;
611
+ }
612
+ function parsePollId(value) {
613
+ return parsePositiveInteger(value, "Poll id");
614
+ }
615
+ function parsePollOptionIds(values) {
616
+ const optionIds = values ?? [];
617
+ if (optionIds.length === 0) {
618
+ throw new Error("Provide at least one option id.");
619
+ }
620
+ return optionIds.map((value) => parsePositiveInteger(value, "Option id"));
621
+ }
622
+ function buildCreatePollOptions(options) {
623
+ const expireMs = options.expireMs?.trim();
624
+ return {
625
+ question: requireQuestion(options.question),
626
+ options: normalizeOptions(options.option),
627
+ expiredTime: expireMs ? parsePositiveInteger(expireMs, "expire-ms") : void 0,
628
+ allowMultiChoices: Boolean(options.multi),
629
+ allowAddNewOption: Boolean(options.allowAddOption),
630
+ hideVotePreview: Boolean(options.hideVotePreview),
631
+ isAnonymous: Boolean(options.anonymous)
632
+ };
633
+ }
634
+
581
635
  // src/lib/text-send.ts
582
636
  import { ThreadType } from "zca-js";
583
637
 
@@ -960,6 +1014,187 @@ async function resolveGroupMentionsIfNeeded(params, text) {
960
1014
  return mentions.length > 0 ? mentions : void 0;
961
1015
  }
962
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
+
963
1198
  // src/cli.ts
964
1199
  var require2 = createRequire(import.meta.url);
965
1200
  var { version: PKG_VERSION } = require2("../package.json");
@@ -1022,9 +1257,9 @@ function resolveDebugEnabled(command) {
1022
1257
  }
1023
1258
  function resolveDebugFilePath(command) {
1024
1259
  const options = getDebugOptions(command);
1025
- 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");
1026
1261
  const normalized = normalizeMediaInput(configured);
1027
- return path4.isAbsolute(normalized) ? normalized : path4.resolve(process.cwd(), normalized);
1262
+ return path5.isAbsolute(normalized) ? normalized : path5.resolve(process.cwd(), normalized);
1028
1263
  }
1029
1264
  function writeDebugLine(event, details, command) {
1030
1265
  if (!resolveDebugEnabled(command)) {
@@ -1035,7 +1270,7 @@ function writeDebugLine(event, details, command) {
1035
1270
  `;
1036
1271
  const filePath = resolveDebugFilePath(command);
1037
1272
  try {
1038
- fsSync.mkdirSync(path4.dirname(filePath), { recursive: true });
1273
+ fsSync.mkdirSync(path5.dirname(filePath), { recursive: true });
1039
1274
  fsSync.appendFileSync(filePath, line, "utf8");
1040
1275
  } catch {
1041
1276
  }
@@ -1123,14 +1358,14 @@ function collectIdsFromCacheEntries(entries, keys) {
1123
1358
  return ids;
1124
1359
  }
1125
1360
  function getListenerOwnerLockPath(profile) {
1126
- return path4.join(getProfileDir(profile), "listener-owner.json");
1361
+ return path5.join(getProfileDir(profile), "listener-owner.json");
1127
1362
  }
1128
1363
  function getListenIpcSocketPath(profile) {
1129
1364
  if (process.platform === "win32") {
1130
1365
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
1131
1366
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
1132
1367
  }
1133
- return path4.join(getProfileDir(profile), "listen.sock");
1368
+ return path5.join(getProfileDir(profile), "listen.sock");
1134
1369
  }
1135
1370
  function parsePositiveIntFromUnknown(value) {
1136
1371
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -1157,7 +1392,7 @@ function isProcessAlive(pid) {
1157
1392
  }
1158
1393
  async function readListenerOwnerRecord(lockPath) {
1159
1394
  try {
1160
- const raw = await fs4.readFile(lockPath, "utf8");
1395
+ const raw = await fs5.readFile(lockPath, "utf8");
1161
1396
  const parsed = JSON.parse(raw);
1162
1397
  const pid = parsePositiveIntFromUnknown(parsed.pid);
1163
1398
  if (!pid) return null;
@@ -1177,11 +1412,11 @@ async function readActiveListenerOwner(profile) {
1177
1412
  const lockPath = getListenerOwnerLockPath(profile);
1178
1413
  const record = await readListenerOwnerRecord(lockPath);
1179
1414
  if (!record) {
1180
- await fs4.rm(lockPath, { force: true });
1415
+ await fs5.rm(lockPath, { force: true });
1181
1416
  return null;
1182
1417
  }
1183
1418
  if (!isProcessAlive(record.pid)) {
1184
- await fs4.rm(lockPath, { force: true });
1419
+ await fs5.rm(lockPath, { force: true });
1185
1420
  return null;
1186
1421
  }
1187
1422
  return record;
@@ -1197,7 +1432,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1197
1432
  };
1198
1433
  for (let attempt = 0; attempt < 3; attempt += 1) {
1199
1434
  try {
1200
- await fs4.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
1435
+ await fs5.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
1201
1436
  `, {
1202
1437
  encoding: "utf8",
1203
1438
  flag: "wx"
@@ -1210,7 +1445,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1210
1445
  released = true;
1211
1446
  const current = await readListenerOwnerRecord(lockPath);
1212
1447
  if (current && current.pid !== process.pid) return;
1213
- await fs4.rm(lockPath, { force: true });
1448
+ await fs5.rm(lockPath, { force: true });
1214
1449
  writeDebugLine(
1215
1450
  "listen.owner.released",
1216
1451
  {
@@ -1231,7 +1466,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1231
1466
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
1232
1467
  );
1233
1468
  }
1234
- await fs4.rm(lockPath, { force: true });
1469
+ await fs5.rm(lockPath, { force: true });
1235
1470
  }
1236
1471
  }
1237
1472
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -1249,7 +1484,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1249
1484
  }
1250
1485
  const socketPath = getListenIpcSocketPath(profile);
1251
1486
  if (process.platform !== "win32") {
1252
- await fs4.rm(socketPath, { force: true });
1487
+ await fs5.rm(socketPath, { force: true });
1253
1488
  }
1254
1489
  const uploadTimeoutMs = parsePositiveIntFromEnv(
1255
1490
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -1421,7 +1656,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1421
1656
  server.close(() => resolve());
1422
1657
  });
1423
1658
  if (process.platform !== "win32") {
1424
- await fs4.rm(socketPath, { force: true });
1659
+ await fs5.rm(socketPath, { force: true });
1425
1660
  }
1426
1661
  writeDebugLine(
1427
1662
  "listen.ipc.stopped",
@@ -2364,7 +2599,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2364
2599
  });
2365
2600
  }
2366
2601
  async function parseCredentialFile(filePath) {
2367
- const raw = await fs4.readFile(filePath, "utf8");
2602
+ const raw = await fs5.readFile(filePath, "utf8");
2368
2603
  const parsed = JSON.parse(raw);
2369
2604
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
2370
2605
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -2385,7 +2620,7 @@ async function waitForFileContent(filePath, timeoutMs) {
2385
2620
  const startedAt = Date.now();
2386
2621
  while (Date.now() - startedAt < timeoutMs) {
2387
2622
  try {
2388
- const data = await fs4.readFile(filePath);
2623
+ const data = await fs5.readFile(filePath);
2389
2624
  if (data.length > 0) {
2390
2625
  return data;
2391
2626
  }
@@ -2400,8 +2635,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
2400
2635
  if (!scriptPath) {
2401
2636
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
2402
2637
  }
2403
- const tempDir = await fs4.mkdtemp(path4.join(os3.tmpdir(), "openzca-qr-"));
2404
- 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"));
2405
2640
  const child = spawn2(
2406
2641
  process.execPath,
2407
2642
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -2679,7 +2914,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
2679
2914
  if (fromType) return fromType;
2680
2915
  try {
2681
2916
  const parsedUrl = new URL(mediaUrl);
2682
- const ext = path4.extname(parsedUrl.pathname);
2917
+ const ext = path5.extname(parsedUrl.pathname);
2683
2918
  if (ext) return ext;
2684
2919
  } catch {
2685
2920
  }
@@ -2710,20 +2945,20 @@ function parseInboundMediaFetchTimeoutMs() {
2710
2945
  return Math.trunc(parsed);
2711
2946
  }
2712
2947
  function resolveOpenClawMediaDir() {
2713
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path4.join(os3.homedir(), ".openclaw");
2714
- 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");
2715
2950
  }
2716
2951
  function resolveInboundMediaDir(profile) {
2717
2952
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
2718
2953
  if (configuredRaw) {
2719
2954
  const configured = normalizeMediaInput(configuredRaw);
2720
- return path4.isAbsolute(configured) ? configured : path4.resolve(process.cwd(), configured);
2955
+ return path5.isAbsolute(configured) ? configured : path5.resolve(process.cwd(), configured);
2721
2956
  }
2722
2957
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
2723
2958
  if (legacyRequested) {
2724
- return path4.join(getProfileDir(profile), "inbound-media");
2959
+ return path5.join(getProfileDir(profile), "inbound-media");
2725
2960
  }
2726
- return path4.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
2961
+ return path5.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
2727
2962
  }
2728
2963
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2729
2964
  const maxBytes = parseMaxInboundMediaBytes();
@@ -2757,11 +2992,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2757
2992
  return null;
2758
2993
  }
2759
2994
  const dir = resolveInboundMediaDir(profile);
2760
- await fs4.mkdir(dir, { recursive: true });
2995
+ await fs5.mkdir(dir, { recursive: true });
2761
2996
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
2762
2997
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
2763
- const mediaPath = path4.join(dir, `${id}${ext}`);
2764
- await fs4.writeFile(mediaPath, data);
2998
+ const mediaPath = path5.join(dir, `${id}${ext}`);
2999
+ await fs5.writeFile(mediaPath, data);
2765
3000
  return { mediaPath, mediaType };
2766
3001
  }
2767
3002
  async function cacheRemoteMediaEntries(params) {
@@ -3253,7 +3488,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
3253
3488
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
3254
3489
  wrapAction(async (file, command) => {
3255
3490
  const profile = await currentProfile(command);
3256
- const credentials = file ? await parseCredentialFile(path4.resolve(normalizeMediaInput(file))) : toCredentials(
3491
+ const credentials = file ? await parseCredentialFile(path5.resolve(normalizeMediaInput(file))) : toCredentials(
3257
3492
  await loadCredentials(profile) ?? (() => {
3258
3493
  throw new Error(
3259
3494
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -3382,42 +3617,121 @@ msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (rep
3382
3617
  }
3383
3618
  )
3384
3619
  );
3385
- 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(
3386
3621
  wrapAction(
3387
3622
  async (threadId, file, opts, command) => {
3388
- const { api } = await requireApi(command);
3623
+ const { api, profile } = await requireApi(command);
3624
+ const threadType = asThreadType(opts.group);
3389
3625
  const normalizedFile = file ? normalizeMediaInput(file) : void 0;
3390
3626
  const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
3391
3627
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
3392
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;
3393
3632
  writeDebugLine(
3394
3633
  "msg.video.inputs",
3395
3634
  {
3396
3635
  threadId,
3397
3636
  isGroup: Boolean(opts.group),
3398
3637
  localInputs,
3399
- urlInputs
3638
+ urlInputs,
3639
+ thumbnail: normalizedThumbnail
3400
3640
  },
3401
3641
  command
3402
3642
  );
3403
3643
  const downloaded = await downloadUrlsToTempFiles(urlInputs);
3644
+ const downloadedThumbnail = await downloadUrlsToTempFiles(thumbnailUrlInputs);
3404
3645
  try {
3405
3646
  const attachments = [...localInputs, ...downloaded.files];
3406
3647
  if (attachments.length === 0) {
3407
3648
  throw new Error("Provide at least one video file or --url.");
3408
3649
  }
3409
3650
  await assertFilesExist(attachments);
3410
- const response = await api.sendMessage(
3411
- {
3412
- msg: opts.message ?? "",
3413
- attachments
3414
- },
3415
- threadId,
3416
- 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
+ )
3417
3730
  );
3418
3731
  output(response, false);
3419
3732
  } finally {
3420
3733
  await downloaded.cleanup();
3734
+ await downloadedThumbnail.cleanup();
3421
3735
  }
3422
3736
  }
3423
3737
  )
@@ -3867,6 +4181,51 @@ group.command("create <name> <members...>").description("Create new group").acti
3867
4181
  output(response, false);
3868
4182
  })
3869
4183
  );
4184
+ var groupPoll = group.command("poll").description("Group poll management");
4185
+ groupPoll.command("create <groupId>").requiredOption("-q, --question <text>", "Poll question").requiredOption("-o, --option <text>", "Poll option (repeatable)", collectValues, []).option("--multi", "Allow multiple choices").option("--allow-add-option", "Allow members to add new options").option("--hide-vote-preview", "Hide results until the member votes").option("--anonymous", "Hide voters").option("--expire-ms <ms>", "Poll expiration time in milliseconds").description("Create a poll in a group").action(
4186
+ wrapAction(
4187
+ async (groupId, opts, command) => {
4188
+ const pollOptions = buildCreatePollOptions(opts);
4189
+ const { api } = await requireApi(command);
4190
+ output(await api.createPoll(pollOptions, groupId), false);
4191
+ }
4192
+ )
4193
+ );
4194
+ groupPoll.command("detail <pollId>").description("Get poll detail").action(
4195
+ wrapAction(async (pollId, command) => {
4196
+ const normalizedPollId = parsePollId(pollId);
4197
+ const { api } = await requireApi(command);
4198
+ output(await api.getPollDetail(normalizedPollId), false);
4199
+ })
4200
+ );
4201
+ groupPoll.command("vote <pollId>").requiredOption("-o, --option <id>", "Poll option id (repeatable)", collectValues, []).description("Vote on a group poll").action(
4202
+ wrapAction(
4203
+ async (pollId, opts, command) => {
4204
+ const normalizedPollId = parsePollId(pollId);
4205
+ const optionIds = parsePollOptionIds(opts.option);
4206
+ const { api } = await requireApi(command);
4207
+ const response = await api.votePoll(
4208
+ normalizedPollId,
4209
+ optionIds.length === 1 ? optionIds[0] : optionIds
4210
+ );
4211
+ output(response, false);
4212
+ }
4213
+ )
4214
+ );
4215
+ groupPoll.command("lock <pollId>").description("Close a poll").action(
4216
+ wrapAction(async (pollId, command) => {
4217
+ const normalizedPollId = parsePollId(pollId);
4218
+ const { api } = await requireApi(command);
4219
+ output(await api.lockPoll(normalizedPollId), false);
4220
+ })
4221
+ );
4222
+ groupPoll.command("share <pollId>").description("Share a poll").action(
4223
+ wrapAction(async (pollId, command) => {
4224
+ const normalizedPollId = parsePollId(pollId);
4225
+ const { api } = await requireApi(command);
4226
+ output(await api.sharePoll(normalizedPollId), false);
4227
+ })
4228
+ );
3870
4229
  group.command("rename <groupId> <name>").description("Rename group").action(
3871
4230
  wrapAction(async (groupId, name, command) => {
3872
4231
  const { api } = await requireApi(command);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsup src/cli.ts --format esm --target node18 --out-dir dist --clean",
18
18
  "dev": "tsx src/cli.ts",
19
+ "test": "tsx --test tests/*.test.ts",
19
20
  "typecheck": "tsc -p tsconfig.json",
20
21
  "lint": "tsc -p tsconfig.json --noEmit",
21
22
  "prepublishOnly": "npm run build"