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.
- package/README.md +9 -1
- package/dist/cli.js +399 -40
- 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
|
|
7
|
+
import fs5 from "fs/promises";
|
|
8
8
|
import net from "net";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
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() ||
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1415
|
+
await fs5.rm(lockPath, { force: true });
|
|
1181
1416
|
return null;
|
|
1182
1417
|
}
|
|
1183
1418
|
if (!isProcessAlive(record.pid)) {
|
|
1184
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2404
|
-
const targetPath =
|
|
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 =
|
|
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() ||
|
|
2714
|
-
return
|
|
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
|
|
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
|
|
2959
|
+
return path5.join(getProfileDir(profile), "inbound-media");
|
|
2725
2960
|
}
|
|
2726
|
-
return
|
|
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
|
|
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 =
|
|
2764
|
-
await
|
|
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(
|
|
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 <
|
|
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
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
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.
|
|
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"
|