openzca 0.1.57 → 0.1.59

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 +18 -6
  2. package/dist/cli.js +421 -95
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -125,6 +125,7 @@ You can also open the saved file manually (for example: `open qr.png` on macOS).
125
125
 
126
126
  Media commands accept local files, `file://` paths, and repeatable `--url` options. Add `--group` for group threads.
127
127
  `openzca msg video` attempts native video send for a single `.mp4` input by uploading the video and thumbnail to Zalo first. If `ffmpeg` is unavailable, the input is not a single `.mp4`, or native send fails, it falls back to the normal attachment send path. Use `--thumbnail <path-or-url>` to supply the preview image explicitly.
128
+ `openzca msg voice` sends `--url` inputs directly. For local voice files, if both `ffmpeg` and `OPENZCA_VOICE_PUBLISH_CMD` are available, `openzca` normalizes the file to `.m4a`, runs the publish command with the normalized temp file path, expects one public `http(s)` URL on stdout, and sends that URL. Otherwise it falls back to the legacy Zalo upload flow.
128
129
  Local paths using `~` are expanded automatically (for positional file args, `--url`, and `OPENZCA_LISTEN_MEDIA_DIR`).
129
130
  Group text sends via `openzca msg send --group` resolve unique `@Name` or `@userId` mentions against the current group member list using member ids, display names, and usernames. Mention offsets are computed after formatting markers are parsed, so messages like `**@Alice Nguyen** hello` work. If multiple members share the same label, the command fails instead of guessing.
130
131
  When formatted text would produce an oversized outbound payload, `openzca msg send` automatically splits it into multiple sequential text messages using the final outbound text and rebased style/mention offsets. The split happens after formatting is parsed, using both rendered text length and estimated request payload size rather than the raw input string.
@@ -176,11 +177,11 @@ For media debugging, grep these events in the debug log:
176
177
  | `openzca group info <groupId>` | Get group details |
177
178
  | `openzca group members <groupId>` | List members |
178
179
  | `openzca group create <name> <members...>` | Create a group |
179
- | `openzca group poll create <groupId>` | Create a poll (`--question`, repeatable `--option`, optional poll flags) |
180
- | `openzca group poll detail <pollId>` | Get poll details |
181
- | `openzca group poll vote <pollId>` | Vote on a poll with repeatable `--option <id>` |
182
- | `openzca group poll lock <pollId>` | Close a poll |
183
- | `openzca group poll share <pollId>` | Share a poll |
180
+ | `openzca group poll create <groupId>` | Create a poll (`--question`, repeatable `--option`, optional poll flags, `--json`) |
181
+ | `openzca group poll detail <pollId>` | Get poll details (`--json`) |
182
+ | `openzca group poll vote <pollId>` | Vote on a poll with repeatable `--option <id>` (`--json`) |
183
+ | `openzca group poll lock <pollId>` | Close a poll (`--json`) |
184
+ | `openzca group poll share <pollId>` | Share a poll (`--json`) |
184
185
  | `openzca group rename <groupId> <name>` | Rename group |
185
186
  | `openzca group avatar <groupId> <file>` | Change group avatar |
186
187
  | `openzca group settings <groupId>` | Update settings (`--lock-name`, `--sign-admin`, etc.) |
@@ -201,7 +202,7 @@ For media debugging, grep these events in the debug log:
201
202
  | `openzca group leave <groupId>` | Leave group |
202
203
  | `openzca group disperse <groupId>` | Disperse group |
203
204
 
204
- 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`.
205
+ 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`, `--expire-ms`, and `--json`. Use `--json` to reliably read the returned `poll_id` and option `option_id` values for follow-up detail or vote commands.
205
206
 
206
207
  ### friend — Friend management
207
208
 
@@ -301,6 +302,7 @@ Notes:
301
302
  | `openzca listen --db` | Force DB writes for this listener session |
302
303
  | `openzca listen --no-db` | Disable DB writes for this listener session |
303
304
  | `openzca listen --keep-alive` | Auto-reconnect on disconnect |
305
+ | `openzca listen --self` | Include events produced by the logged-in account |
304
306
  | `openzca listen --supervised --raw` | Supervisor mode with lifecycle JSON events (`session_id`, `connected`, `heartbeat`, `error`, `closed`) |
305
307
  | `openzca listen --keep-alive --recycle-ms <ms>` | Periodically recycle listener process to avoid stale sessions |
306
308
 
@@ -317,8 +319,12 @@ It also includes stable routing fields for downstream tools:
317
319
  - `senderId`, `toId`, `chatType`, `msgType`, `timestamp`
318
320
  - `mentions` (normalized mention entities: `uid`, `pos`, `len`, `type`, optional `text`)
319
321
  - `mentionIds` (flattened mention user IDs)
322
+ - `pollId`, `pollTitle`, `pollOptionIds`, and `poll` when a message or group event carries poll metadata
323
+ - `rawMessage` for poll message payloads, and `rawGroupEvent` for poll group-event payloads
320
324
  - `metadata.threadId`, `metadata.targetId`, `metadata.senderId`, `metadata.toId`
321
325
  - `metadata.mentions`, `metadata.mentionIds`, `metadata.mentionCount`
326
+ - `metadata.pollId`, `metadata.pollTitle`, `metadata.pollOptionIds`, and `metadata.poll`
327
+ - `metadata.rawMessage` / `metadata.rawGroupEvent` for poll payload schema debugging
322
328
  - `quote` and `metadata.quote` when the inbound message is a reply to a previous message
323
329
  - Includes parsed `quote.attach` and extracted `quote.mediaUrls` when attachment URLs are present.
324
330
  - `quoteMediaPath`, `quoteMediaPaths`, `quoteMediaUrl`, `quoteMediaUrls`, `quoteMediaType`, `quoteMediaTypes`
@@ -326,6 +332,8 @@ It also includes stable routing fields for downstream tools:
326
332
 
327
333
  For direct messages, `metadata.senderName` is intentionally omitted so consumers can prefer numeric IDs for routing instead of display-name targets.
328
334
 
335
+ By default, zca-js suppresses events produced by the logged-in account. Use `listen --self --raw` when a caller needs to observe its own actions, such as a poll it just created.
336
+
329
337
  When a reply/quoted message is detected, `content` also appends a compact line:
330
338
 
331
339
  ```text
@@ -433,6 +441,10 @@ Upload/listener coordination overrides:
433
441
  - `OPENZCA_UPLOAD_GROUP_PROBE`: allow `msg upload` to probe `getGroupInfo` when auto thread-type detection is enabled.
434
442
  - Default: enabled.
435
443
  - Set to `0` to skip probe and rely only on cache matches.
444
+ - `OPENZCA_VOICE_PUBLISH_CMD`: optional command used by `msg voice` for local files.
445
+ - `openzca` passes one normalized `.m4a` temp file path as the first argument.
446
+ - The command must print exactly one public `http(s)` URL to stdout.
447
+ - Requires `ffmpeg`; if unset or `ffmpeg` is unavailable, local voice files keep using the legacy Zalo upload flow.
436
448
 
437
449
  ### account — Multi-account profiles
438
450
 
package/dist/cli.js CHANGED
@@ -4,10 +4,10 @@
4
4
  import { createRequire as createRequire2 } from "module";
5
5
  import { spawn as spawn2 } from "child_process";
6
6
  import fsSync from "fs";
7
- import fs6 from "fs/promises";
7
+ import fs7 from "fs/promises";
8
8
  import net from "net";
9
- import os4 from "os";
10
- import path6 from "path";
9
+ import os5 from "os";
10
+ import path7 from "path";
11
11
  import readline from "readline/promises";
12
12
  import util from "util";
13
13
  import { Command } from "commander";
@@ -1735,10 +1735,11 @@ async function imageMetadataGetter(filePath) {
1735
1735
  size: data.length
1736
1736
  };
1737
1737
  }
1738
- function createZaloClient() {
1738
+ function createZaloClient(options) {
1739
1739
  return new Zalo({
1740
1740
  imageMetadataGetter,
1741
- logging: false
1741
+ logging: false,
1742
+ ...options
1742
1743
  });
1743
1744
  }
1744
1745
  function toCredentials(value) {
@@ -1749,14 +1750,14 @@ function toCredentials(value) {
1749
1750
  language: value.language
1750
1751
  };
1751
1752
  }
1752
- async function loginWithStoredCredentials(profileName) {
1753
+ async function loginWithStoredCredentials(profileName, options) {
1753
1754
  const stored = await loadCredentials(profileName);
1754
1755
  if (!stored) {
1755
1756
  throw new Error(
1756
1757
  `Profile "${profileName}" has no credentials. Run: auth login`
1757
1758
  );
1758
1759
  }
1759
- const zalo = createZaloClient();
1760
+ const zalo = createZaloClient(options);
1760
1761
  return zalo.login(toCredentials(stored));
1761
1762
  }
1762
1763
  async function loginWithCredentialPayload(profileName, credentials) {
@@ -2035,6 +2036,96 @@ function buildCreatePollOptions(options) {
2035
2036
  };
2036
2037
  }
2037
2038
 
2039
+ // src/lib/listen-poll.ts
2040
+ var POLL_ID_KEYS = /* @__PURE__ */ new Set(["pollId", "poll_id", "pollID", "pollid"]);
2041
+ var OPTION_ID_KEYS = /* @__PURE__ */ new Set(["optionId", "option_id", "optionID", "optionid"]);
2042
+ var TITLE_KEYS = ["question", "title"];
2043
+ function looksLikeStructuredJsonString(value) {
2044
+ const trimmed = value.trim();
2045
+ if (trimmed.length < 2) return false;
2046
+ const first = trimmed[0];
2047
+ const last = trimmed[trimmed.length - 1];
2048
+ return first === "{" && last === "}" || first === "[" && last === "]";
2049
+ }
2050
+ function parseStructuredJsonString(value) {
2051
+ if (!looksLikeStructuredJsonString(value)) return void 0;
2052
+ try {
2053
+ return JSON.parse(value);
2054
+ } catch {
2055
+ return void 0;
2056
+ }
2057
+ }
2058
+ function normalizeIdentifier(value) {
2059
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
2060
+ return Number.isSafeInteger(value) ? value : String(value);
2061
+ }
2062
+ if (typeof value !== "string") return void 0;
2063
+ const normalized = value.trim();
2064
+ if (!/^[1-9]\d*$/.test(normalized)) return void 0;
2065
+ const numeric = Number(normalized);
2066
+ return Number.isSafeInteger(numeric) ? numeric : normalized;
2067
+ }
2068
+ function firstString(record, keys) {
2069
+ for (const key of keys) {
2070
+ const value = record[key];
2071
+ if (typeof value === "string" && value.trim()) {
2072
+ return value.trim();
2073
+ }
2074
+ }
2075
+ return void 0;
2076
+ }
2077
+ function asRecord(value) {
2078
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2079
+ return value;
2080
+ }
2081
+ function extractInboundPollInfo(...values) {
2082
+ const optionIds = [];
2083
+ const seenOptionIds = /* @__PURE__ */ new Set();
2084
+ let pollId;
2085
+ let title;
2086
+ const pushOptionId = (value) => {
2087
+ const optionId = normalizeIdentifier(value);
2088
+ if (!optionId) return;
2089
+ const key = String(optionId);
2090
+ if (seenOptionIds.has(key)) return;
2091
+ seenOptionIds.add(key);
2092
+ optionIds.push(optionId);
2093
+ };
2094
+ const visit = (value, depth = 0) => {
2095
+ if (depth > 8 || value === null || value === void 0) return;
2096
+ if (typeof value === "string") {
2097
+ const parsed = parseStructuredJsonString(value);
2098
+ if (parsed !== void 0) visit(parsed, depth + 1);
2099
+ return;
2100
+ }
2101
+ if (Array.isArray(value)) {
2102
+ for (const entry of value) visit(entry, depth + 1);
2103
+ return;
2104
+ }
2105
+ const record = asRecord(value);
2106
+ if (!record) return;
2107
+ for (const [key, nested] of Object.entries(record)) {
2108
+ if (!pollId && POLL_ID_KEYS.has(key)) {
2109
+ pollId = normalizeIdentifier(nested);
2110
+ }
2111
+ if (OPTION_ID_KEYS.has(key)) {
2112
+ pushOptionId(nested);
2113
+ }
2114
+ }
2115
+ title ??= firstString(record, TITLE_KEYS);
2116
+ for (const nested of Object.values(record)) {
2117
+ visit(nested, depth + 1);
2118
+ }
2119
+ };
2120
+ for (const value of values) visit(value);
2121
+ if (!pollId) return null;
2122
+ return {
2123
+ pollId,
2124
+ ...title ? { title } : {},
2125
+ ...optionIds.length > 0 ? { optionIds } : {}
2126
+ };
2127
+ }
2128
+
2038
2129
  // src/lib/time-range.ts
2039
2130
  var DURATION_PART_RE = /(\d+)\s*(ms|s|m|h|d|w)/gi;
2040
2131
  function durationToMs(input) {
@@ -3066,6 +3157,110 @@ async function sendNativeVideo(params) {
3066
3157
  }
3067
3158
  }
3068
3159
 
3160
+ // src/lib/voice-send.ts
3161
+ import { execFile as execFile2 } from "child_process";
3162
+ import fs6 from "fs/promises";
3163
+ import os4 from "os";
3164
+ import path6 from "path";
3165
+ import { promisify as promisify2 } from "util";
3166
+ var execFileAsync2 = promisify2(execFile2);
3167
+ function sanitizeOutputBasename(filePath) {
3168
+ const parsed = path6.parse(filePath);
3169
+ const base = parsed.name.trim() || "voice";
3170
+ const sanitized = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
3171
+ return sanitized || "voice";
3172
+ }
3173
+ async function runBinary2(command, args) {
3174
+ try {
3175
+ await execFileAsync2(command, args, {
3176
+ encoding: "utf8",
3177
+ maxBuffer: 10 * 1024 * 1024
3178
+ });
3179
+ } catch (error) {
3180
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
3181
+ throw new Error(`${command} is required for voice publish mode`);
3182
+ }
3183
+ if (error instanceof Error && "stderr" in error) {
3184
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
3185
+ throw new Error(stderr ? `${command} failed: ${stderr}` : `${command} failed: ${error.message}`);
3186
+ }
3187
+ throw error;
3188
+ }
3189
+ }
3190
+ function getVoicePublishCommandFromEnv(env = process.env) {
3191
+ const configured = env.OPENZCA_VOICE_PUBLISH_CMD?.trim();
3192
+ return configured && configured.length > 0 ? configured : null;
3193
+ }
3194
+ function extractPublishedVoiceUrl(stdout) {
3195
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3196
+ const candidate = lines.at(-1);
3197
+ if (!candidate) {
3198
+ throw new Error("Voice publish command did not print a public URL to stdout");
3199
+ }
3200
+ if (!/^https?:\/\/\S+$/i.test(candidate)) {
3201
+ throw new Error(`Voice publish command returned an invalid URL: ${candidate}`);
3202
+ }
3203
+ return candidate;
3204
+ }
3205
+ async function normalizeVoiceForPublish(inputPath) {
3206
+ const dir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-voice-"));
3207
+ const outputPath = path6.join(dir, `${sanitizeOutputBasename(inputPath)}.m4a`);
3208
+ try {
3209
+ await runBinary2("ffmpeg", [
3210
+ "-y",
3211
+ "-v",
3212
+ "error",
3213
+ "-i",
3214
+ inputPath,
3215
+ "-vn",
3216
+ "-map_metadata",
3217
+ "-1",
3218
+ "-ac",
3219
+ "1",
3220
+ "-ar",
3221
+ "44100",
3222
+ "-c:a",
3223
+ "aac",
3224
+ "-b:a",
3225
+ "64k",
3226
+ "-movflags",
3227
+ "+faststart",
3228
+ outputPath
3229
+ ]);
3230
+ await fs6.access(outputPath);
3231
+ } catch (error) {
3232
+ await fs6.rm(dir, { recursive: true, force: true });
3233
+ throw error;
3234
+ }
3235
+ return {
3236
+ path: outputPath,
3237
+ cleanup: async () => {
3238
+ await fs6.rm(dir, { recursive: true, force: true });
3239
+ }
3240
+ };
3241
+ }
3242
+ async function publishVoiceFile(command, filePath) {
3243
+ try {
3244
+ const { stdout } = await execFileAsync2(
3245
+ "sh",
3246
+ ["-c", `${command} "$1"`, "openzca-voice-publish", filePath],
3247
+ {
3248
+ encoding: "utf8",
3249
+ maxBuffer: 10 * 1024 * 1024
3250
+ }
3251
+ );
3252
+ return extractPublishedVoiceUrl(stdout);
3253
+ } catch (error) {
3254
+ if (error instanceof Error && "stderr" in error) {
3255
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
3256
+ throw new Error(
3257
+ stderr ? `Voice publish command failed: ${stderr}` : `Voice publish command failed: ${error.message}`
3258
+ );
3259
+ }
3260
+ throw error;
3261
+ }
3262
+ }
3263
+
3069
3264
  // src/lib/reply.ts
3070
3265
  import { ThreadType as ThreadType2 } from "zca-js";
3071
3266
  function prepareReplyMessage(value, params) {
@@ -3133,7 +3328,7 @@ function prepareStoredReplyMessage(value, params) {
3133
3328
  if (storedThreadType !== void 0 && storedThreadType !== params.threadType) {
3134
3329
  throw new Error("Reply source thread type does not match --group.");
3135
3330
  }
3136
- const storedThreadId = firstString([record.threadId, record.rawThreadId]) ?? void 0;
3331
+ const storedThreadId = firstString2([record.threadId, record.rawThreadId]) ?? void 0;
3137
3332
  if (storedThreadId && storedThreadId !== params.threadId) {
3138
3333
  throw new Error("Reply source belongs to a different thread.");
3139
3334
  }
@@ -3203,7 +3398,7 @@ function parseReplyMessageTtl(value) {
3203
3398
  return Math.trunc(parsed);
3204
3399
  }
3205
3400
  function requireStringLike(values, label) {
3206
- const value = firstString(values);
3401
+ const value = firstString2(values);
3207
3402
  if (!value) {
3208
3403
  throw new Error(`Missing ${label}.`);
3209
3404
  }
@@ -3220,7 +3415,7 @@ function requireTsString(values, label) {
3220
3415
  }
3221
3416
  throw new Error(`Missing ${label}.`);
3222
3417
  }
3223
- function firstString(values) {
3418
+ function firstString2(values) {
3224
3419
  for (const value of values) {
3225
3420
  if (typeof value === "string" && value.trim()) {
3226
3421
  return value.trim();
@@ -3241,7 +3436,7 @@ function isLikelyOpenzcaListenPayload(record) {
3241
3436
  return typeof record.threadId === "string" && (typeof record.senderId === "string" || typeof record.chatType === "string" || typeof record.metadata === "object");
3242
3437
  }
3243
3438
  function inferReplyMessageThreadId(params) {
3244
- const directThreadId = firstString([
3439
+ const directThreadId = firstString2([
3245
3440
  params.sourceRecord.threadId,
3246
3441
  params.sourceRecord.targetId,
3247
3442
  params.sourceRecord.conversationId,
@@ -3254,7 +3449,7 @@ function inferReplyMessageThreadId(params) {
3254
3449
  if (params.threadType === void 0) {
3255
3450
  return void 0;
3256
3451
  }
3257
- const idTo = firstString([
3452
+ const idTo = firstString2([
3258
3453
  params.canonicalRecord.idTo,
3259
3454
  params.sourceRecord.idTo,
3260
3455
  params.sourceRecord.toId,
@@ -3263,7 +3458,7 @@ function inferReplyMessageThreadId(params) {
3263
3458
  if (params.threadType === ThreadType2.Group) {
3264
3459
  return idTo;
3265
3460
  }
3266
- const uidFrom = firstString([
3461
+ const uidFrom = firstString2([
3267
3462
  params.canonicalRecord.uidFrom,
3268
3463
  params.sourceRecord.uidFrom,
3269
3464
  params.sourceRecord.senderId,
@@ -3491,9 +3686,9 @@ function resolveDebugEnabled(command) {
3491
3686
  }
3492
3687
  function resolveDebugFilePath(command) {
3493
3688
  const options = getDebugOptions(command);
3494
- const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path6.join(APP_HOME, "logs", "openzca-debug.log");
3689
+ const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path7.join(APP_HOME, "logs", "openzca-debug.log");
3495
3690
  const normalized = normalizeMediaInput(configured);
3496
- return path6.isAbsolute(normalized) ? normalized : path6.resolve(process.cwd(), normalized);
3691
+ return path7.isAbsolute(normalized) ? normalized : path7.resolve(process.cwd(), normalized);
3497
3692
  }
3498
3693
  function writeDebugLine(event, details, command) {
3499
3694
  if (!resolveDebugEnabled(command)) {
@@ -3504,7 +3699,7 @@ function writeDebugLine(event, details, command) {
3504
3699
  `;
3505
3700
  const filePath = resolveDebugFilePath(command);
3506
3701
  try {
3507
- fsSync.mkdirSync(path6.dirname(filePath), { recursive: true });
3702
+ fsSync.mkdirSync(path7.dirname(filePath), { recursive: true });
3508
3703
  fsSync.appendFileSync(filePath, line, "utf8");
3509
3704
  } catch {
3510
3705
  }
@@ -3611,14 +3806,14 @@ function collectIdsFromCacheEntries(entries, keys) {
3611
3806
  return ids;
3612
3807
  }
3613
3808
  function getListenerOwnerLockPath(profile) {
3614
- return path6.join(getProfileDir(profile), "listener-owner.json");
3809
+ return path7.join(getProfileDir(profile), "listener-owner.json");
3615
3810
  }
3616
3811
  function getListenIpcSocketPath(profile) {
3617
3812
  if (process.platform === "win32") {
3618
3813
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
3619
3814
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
3620
3815
  }
3621
- return path6.join(getProfileDir(profile), "listen.sock");
3816
+ return path7.join(getProfileDir(profile), "listen.sock");
3622
3817
  }
3623
3818
  function parsePositiveIntFromUnknown(value) {
3624
3819
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -3702,7 +3897,7 @@ function isProcessAlive(pid) {
3702
3897
  }
3703
3898
  async function readListenerOwnerRecord(lockPath) {
3704
3899
  try {
3705
- const raw = await fs6.readFile(lockPath, "utf8");
3900
+ const raw = await fs7.readFile(lockPath, "utf8");
3706
3901
  const parsed = JSON.parse(raw);
3707
3902
  const pid = parsePositiveIntFromUnknown(parsed.pid);
3708
3903
  if (!pid) return null;
@@ -3722,11 +3917,11 @@ async function readActiveListenerOwner(profile) {
3722
3917
  const lockPath = getListenerOwnerLockPath(profile);
3723
3918
  const record = await readListenerOwnerRecord(lockPath);
3724
3919
  if (!record) {
3725
- await fs6.rm(lockPath, { force: true });
3920
+ await fs7.rm(lockPath, { force: true });
3726
3921
  return null;
3727
3922
  }
3728
3923
  if (!isProcessAlive(record.pid)) {
3729
- await fs6.rm(lockPath, { force: true });
3924
+ await fs7.rm(lockPath, { force: true });
3730
3925
  return null;
3731
3926
  }
3732
3927
  return record;
@@ -3742,7 +3937,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3742
3937
  };
3743
3938
  for (let attempt = 0; attempt < 3; attempt += 1) {
3744
3939
  try {
3745
- await fs6.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3940
+ await fs7.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
3746
3941
  `, {
3747
3942
  encoding: "utf8",
3748
3943
  flag: "wx"
@@ -3755,7 +3950,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3755
3950
  released = true;
3756
3951
  const current = await readListenerOwnerRecord(lockPath);
3757
3952
  if (current && current.pid !== process.pid) return;
3758
- await fs6.rm(lockPath, { force: true });
3953
+ await fs7.rm(lockPath, { force: true });
3759
3954
  writeDebugLine(
3760
3955
  "listen.owner.released",
3761
3956
  {
@@ -3776,7 +3971,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
3776
3971
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
3777
3972
  );
3778
3973
  }
3779
- await fs6.rm(lockPath, { force: true });
3974
+ await fs7.rm(lockPath, { force: true });
3780
3975
  }
3781
3976
  }
3782
3977
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -3794,7 +3989,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3794
3989
  }
3795
3990
  const socketPath = getListenIpcSocketPath(profile);
3796
3991
  if (process.platform !== "win32") {
3797
- await fs6.rm(socketPath, { force: true });
3992
+ await fs7.rm(socketPath, { force: true });
3798
3993
  }
3799
3994
  const uploadTimeoutMs = parsePositiveIntFromEnv(
3800
3995
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -3975,7 +4170,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
3975
4170
  server.close(() => resolve());
3976
4171
  });
3977
4172
  if (process.platform !== "win32") {
3978
- await fs6.rm(socketPath, { force: true });
4173
+ await fs7.rm(socketPath, { force: true });
3979
4174
  }
3980
4175
  writeDebugLine(
3981
4176
  "listen.ipc.stopped",
@@ -4241,9 +4436,9 @@ async function profileForLogin() {
4241
4436
  await ensureProfile(fallback);
4242
4437
  return fallback;
4243
4438
  }
4244
- async function requireApi(command) {
4439
+ async function requireApi(command, options) {
4245
4440
  const profile = await currentProfile(command);
4246
- const api = await loginWithStoredCredentials(profile);
4441
+ const api = await loginWithStoredCredentials(profile, options);
4247
4442
  return { profile, api };
4248
4443
  }
4249
4444
  function toDbThreadType(groupFlag) {
@@ -6022,7 +6217,7 @@ function toDbRecordFromRecentMessage(params) {
6022
6217
  });
6023
6218
  }
6024
6219
  async function parseCredentialFile(filePath) {
6025
- const raw = await fs6.readFile(filePath, "utf8");
6220
+ const raw = await fs7.readFile(filePath, "utf8");
6026
6221
  const parsed = JSON.parse(raw);
6027
6222
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
6028
6223
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -6043,7 +6238,7 @@ async function waitForFileContent(filePath, timeoutMs) {
6043
6238
  const startedAt = Date.now();
6044
6239
  while (Date.now() - startedAt < timeoutMs) {
6045
6240
  try {
6046
- const data = await fs6.readFile(filePath);
6241
+ const data = await fs7.readFile(filePath);
6047
6242
  if (data.length > 0) {
6048
6243
  return data;
6049
6244
  }
@@ -6058,8 +6253,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
6058
6253
  if (!scriptPath) {
6059
6254
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
6060
6255
  }
6061
- const tempDir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-qr-"));
6062
- const targetPath = path6.resolve(qrPath ?? path6.join(tempDir, "qr.png"));
6256
+ const tempDir = await fs7.mkdtemp(path7.join(os5.tmpdir(), "openzca-qr-"));
6257
+ const targetPath = path7.resolve(qrPath ?? path7.join(tempDir, "qr.png"));
6063
6258
  const child = spawn2(
6064
6259
  process.execPath,
6065
6260
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -6099,7 +6294,7 @@ function normalizeMessageType(value) {
6099
6294
  if (typeof value !== "string") return "";
6100
6295
  return value.trim().toLowerCase();
6101
6296
  }
6102
- function looksLikeStructuredJsonString(value) {
6297
+ function looksLikeStructuredJsonString2(value) {
6103
6298
  const trimmed = value.trim();
6104
6299
  if (trimmed.length < 2) return false;
6105
6300
  const first = trimmed[0];
@@ -6114,7 +6309,7 @@ function normalizeStructuredContent(value, depth = 0) {
6114
6309
  }
6115
6310
  if (typeof value === "string") {
6116
6311
  const trimmed = value.trim();
6117
- if (!looksLikeStructuredJsonString(trimmed)) {
6312
+ if (!looksLikeStructuredJsonString2(trimmed)) {
6118
6313
  return value;
6119
6314
  }
6120
6315
  try {
@@ -6337,7 +6532,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
6337
6532
  if (fromType) return fromType;
6338
6533
  try {
6339
6534
  const parsedUrl = new URL(mediaUrl);
6340
- const ext = path6.extname(parsedUrl.pathname);
6535
+ const ext = path7.extname(parsedUrl.pathname);
6341
6536
  if (ext) return ext;
6342
6537
  } catch {
6343
6538
  }
@@ -6368,20 +6563,20 @@ function parseInboundMediaFetchTimeoutMs() {
6368
6563
  return Math.trunc(parsed);
6369
6564
  }
6370
6565
  function resolveOpenClawMediaDir() {
6371
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path6.join(os4.homedir(), ".openclaw");
6372
- return path6.join(stateDir, "media");
6566
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path7.join(os5.homedir(), ".openclaw");
6567
+ return path7.join(stateDir, "media");
6373
6568
  }
6374
6569
  function resolveInboundMediaDir(profile) {
6375
6570
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
6376
6571
  if (configuredRaw) {
6377
6572
  const configured = normalizeMediaInput(configuredRaw);
6378
- return path6.isAbsolute(configured) ? configured : path6.resolve(process.cwd(), configured);
6573
+ return path7.isAbsolute(configured) ? configured : path7.resolve(process.cwd(), configured);
6379
6574
  }
6380
6575
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
6381
6576
  if (legacyRequested) {
6382
- return path6.join(getProfileDir(profile), "inbound-media");
6577
+ return path7.join(getProfileDir(profile), "inbound-media");
6383
6578
  }
6384
- return path6.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6579
+ return path7.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
6385
6580
  }
6386
6581
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6387
6582
  const maxBytes = parseMaxInboundMediaBytes();
@@ -6415,11 +6610,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
6415
6610
  return null;
6416
6611
  }
6417
6612
  const dir = resolveInboundMediaDir(profile);
6418
- await fs6.mkdir(dir, { recursive: true });
6613
+ await fs7.mkdir(dir, { recursive: true });
6419
6614
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
6420
6615
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
6421
- const mediaPath = path6.join(dir, `${id}${ext}`);
6422
- await fs6.writeFile(mediaPath, data);
6616
+ const mediaPath = path7.join(dir, `${id}${ext}`);
6617
+ await fs7.writeFile(mediaPath, data);
6423
6618
  return { mediaPath, mediaType };
6424
6619
  }
6425
6620
  async function cacheRemoteMediaEntries(params) {
@@ -6640,7 +6835,7 @@ function collectInboundMentions(value, sink, rawText, depth = 0) {
6640
6835
  return;
6641
6836
  }
6642
6837
  if (typeof value === "string") {
6643
- if (!looksLikeStructuredJsonString(value)) return;
6838
+ if (!looksLikeStructuredJsonString2(value)) return;
6644
6839
  try {
6645
6840
  const parsed = JSON.parse(value);
6646
6841
  collectInboundMentions(parsed, sink, rawText, depth + 1);
@@ -6929,7 +7124,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
6929
7124
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
6930
7125
  wrapAction(async (file, command) => {
6931
7126
  const profile = await currentProfile(command);
6932
- const credentials = file ? await parseCredentialFile(path6.resolve(normalizeMediaInput(file))) : toCredentials(
7127
+ const credentials = file ? await parseCredentialFile(path7.resolve(normalizeMediaInput(file))) : toCredentials(
6933
7128
  await loadCredentials(profile) ?? (() => {
6934
7129
  throw new Error(
6935
7130
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -7036,7 +7231,7 @@ dbCmd.command("reset").option("-y, --yes", "Delete the SQLite DB file for the ac
7036
7231
  const removedPaths = [];
7037
7232
  const deleteIfExists = async (filename) => {
7038
7233
  try {
7039
- await fs6.unlink(filename);
7234
+ await fs7.unlink(filename);
7040
7235
  removedPaths.push(filename);
7041
7236
  } catch (error) {
7042
7237
  if (error.code !== "ENOENT") {
@@ -7749,53 +7944,108 @@ msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (rep
7749
7944
  }
7750
7945
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
7751
7946
  const localInputs = files.filter((entry) => !isHttpUrl(entry));
7947
+ const publishCommand = getVoicePublishCommandFromEnv();
7948
+ const ffmpegAvailable = localInputs.length > 0 && publishCommand ? await isFfmpegAvailable() : false;
7949
+ const usePublishFlow = localInputs.length > 0 && Boolean(publishCommand) && ffmpegAvailable;
7752
7950
  writeDebugLine(
7753
7951
  "msg.voice.inputs",
7754
7952
  {
7755
7953
  threadId,
7756
7954
  isGroup: Boolean(opts.group),
7757
7955
  localInputs,
7758
- urlInputs
7956
+ urlInputs,
7957
+ publishConfigured: Boolean(publishCommand),
7958
+ ffmpegAvailable: localInputs.length > 0 ? ffmpegAvailable : void 0,
7959
+ mode: usePublishFlow ? "publish" : "legacy"
7759
7960
  },
7760
7961
  command
7761
7962
  );
7762
- const downloaded = await downloadUrlsToTempFiles(urlInputs);
7763
- try {
7764
- const attachments = [...localInputs, ...downloaded.files];
7765
- await assertFilesExist(attachments);
7766
- const results = [];
7767
- const uploaded = await api.uploadAttachment(attachments, threadId, type);
7768
- for (const item of uploaded) {
7769
- if (item.fileType === "others" || item.fileType === "video") {
7770
- results.push(await sendVoice({ voiceUrl: item.fileUrl }, threadId, type));
7963
+ await assertFilesExist(localInputs);
7964
+ const publishedLocals = [];
7965
+ let uploadedLocals = [];
7966
+ if (usePublishFlow) {
7967
+ for (const localInput of localInputs) {
7968
+ const normalized = await normalizeVoiceForPublish(localInput);
7969
+ try {
7970
+ const mediaUrl = await publishVoiceFile(publishCommand, normalized.path);
7971
+ publishedLocals.push({
7972
+ mediaPath: localInput,
7973
+ mediaUrl
7974
+ });
7975
+ } finally {
7976
+ await normalized.cleanup();
7771
7977
  }
7772
7978
  }
7773
- if (results.length === 0) {
7774
- throw new Error(
7775
- "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
7776
- );
7979
+ } else if (localInputs.length > 0) {
7980
+ uploadedLocals = await withUploadListener(
7981
+ api,
7982
+ command,
7983
+ async () => api.uploadAttachment(localInputs, threadId, type)
7984
+ );
7985
+ }
7986
+ const pendingPublished = [...publishedLocals];
7987
+ const pendingUploaded = [...uploadedLocals];
7988
+ const outboundVoices = [];
7989
+ for (const entry of files) {
7990
+ if (isHttpUrl(entry)) {
7991
+ outboundVoices.push({
7992
+ mediaUrl: entry
7993
+ });
7994
+ continue;
7777
7995
  }
7778
- output(results, false);
7779
- if (await shouldWriteToDb(profile)) {
7780
- scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
7781
- await persistOutgoingMessageBestEffort({
7782
- profile,
7783
- api,
7784
- threadId,
7785
- group: opts.group,
7786
- msgType: "voice",
7787
- response: results,
7788
- rawPayload: uploaded,
7789
- media: uploaded.map((item) => ({
7790
- mediaKind: "voice",
7791
- mediaUrl: "fileUrl" in item && typeof item.fileUrl === "string" ? item.fileUrl : void 0,
7792
- rawJson: JSON.stringify(item)
7793
- }))
7794
- });
7996
+ if (usePublishFlow) {
7997
+ const nextPublished = pendingPublished.shift();
7998
+ if (!nextPublished) {
7999
+ throw new Error(`Voice publish flow lost local file mapping for: ${entry}`);
8000
+ }
8001
+ outboundVoices.push(nextPublished);
8002
+ continue;
8003
+ }
8004
+ const nextUploaded = pendingUploaded.shift();
8005
+ if (!nextUploaded) {
8006
+ throw new Error(`Voice upload flow lost local file mapping for: ${entry}`);
8007
+ }
8008
+ if (nextUploaded.fileType === "others" || nextUploaded.fileType === "video") {
8009
+ outboundVoices.push({
8010
+ mediaPath: entry,
8011
+ mediaUrl: nextUploaded.fileUrl,
8012
+ rawJson: JSON.stringify(nextUploaded)
7795
8013
  });
7796
8014
  }
7797
- } finally {
7798
- await downloaded.cleanup();
8015
+ }
8016
+ if (outboundVoices.length === 0) {
8017
+ throw new Error(
8018
+ "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
8019
+ );
8020
+ }
8021
+ const results = [];
8022
+ for (const item of outboundVoices) {
8023
+ results.push(await sendVoice({ voiceUrl: item.mediaUrl }, threadId, type));
8024
+ }
8025
+ output(results, false);
8026
+ if (await shouldWriteToDb(profile)) {
8027
+ scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
8028
+ await persistOutgoingMessageBestEffort({
8029
+ profile,
8030
+ api,
8031
+ threadId,
8032
+ group: opts.group,
8033
+ msgType: "voice",
8034
+ response: results,
8035
+ rawPayload: {
8036
+ mode: usePublishFlow ? "publish" : "legacy",
8037
+ directUrls: urlInputs,
8038
+ published: publishedLocals,
8039
+ uploaded: uploadedLocals
8040
+ },
8041
+ media: outboundVoices.map((item) => ({
8042
+ mediaKind: "voice",
8043
+ mediaPath: item.mediaPath,
8044
+ mediaUrl: item.mediaUrl,
8045
+ rawJson: item.rawJson
8046
+ }))
8047
+ });
8048
+ });
7799
8049
  }
7800
8050
  }
7801
8051
  )
@@ -8255,23 +8505,23 @@ group.command("create <name> <members...>").description("Create new group").acti
8255
8505
  })
8256
8506
  );
8257
8507
  var groupPoll = group.command("poll").description("Group poll management");
8258
- 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(
8508
+ groupPoll.command("create <groupId>").requiredOption("-q, --question <text>", "Poll question").requiredOption("-o, --option <text>", "Poll option (repeatable)", collectValues, []).option("-j, --json", "JSON output").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(
8259
8509
  wrapAction(
8260
8510
  async (groupId, opts, command) => {
8261
8511
  const pollOptions = buildCreatePollOptions(opts);
8262
8512
  const { api } = await requireApi(command);
8263
- output(await api.createPoll(pollOptions, groupId), false);
8513
+ output(await api.createPoll(pollOptions, groupId), shouldOutputJson(opts));
8264
8514
  }
8265
8515
  )
8266
8516
  );
8267
- groupPoll.command("detail <pollId>").description("Get poll detail").action(
8268
- wrapAction(async (pollId, command) => {
8517
+ groupPoll.command("detail <pollId>").option("-j, --json", "JSON output").description("Get poll detail").action(
8518
+ wrapAction(async (pollId, opts, command) => {
8269
8519
  const normalizedPollId = parsePollId(pollId);
8270
8520
  const { api } = await requireApi(command);
8271
- output(await api.getPollDetail(normalizedPollId), false);
8521
+ output(await api.getPollDetail(normalizedPollId), shouldOutputJson(opts));
8272
8522
  })
8273
8523
  );
8274
- groupPoll.command("vote <pollId>").requiredOption("-o, --option <id>", "Poll option id (repeatable)", collectValues, []).description("Vote on a group poll").action(
8524
+ groupPoll.command("vote <pollId>").requiredOption("-o, --option <id>", "Poll option id (repeatable)", collectValues, []).option("-j, --json", "JSON output").description("Vote on a group poll").action(
8275
8525
  wrapAction(
8276
8526
  async (pollId, opts, command) => {
8277
8527
  const normalizedPollId = parsePollId(pollId);
@@ -8281,22 +8531,22 @@ groupPoll.command("vote <pollId>").requiredOption("-o, --option <id>", "Poll opt
8281
8531
  normalizedPollId,
8282
8532
  optionIds.length === 1 ? optionIds[0] : optionIds
8283
8533
  );
8284
- output(response, false);
8534
+ output(response, shouldOutputJson(opts));
8285
8535
  }
8286
8536
  )
8287
8537
  );
8288
- groupPoll.command("lock <pollId>").description("Close a poll").action(
8289
- wrapAction(async (pollId, command) => {
8538
+ groupPoll.command("lock <pollId>").option("-j, --json", "JSON output").description("Close a poll").action(
8539
+ wrapAction(async (pollId, opts, command) => {
8290
8540
  const normalizedPollId = parsePollId(pollId);
8291
8541
  const { api } = await requireApi(command);
8292
- output(await api.lockPoll(normalizedPollId), false);
8542
+ output(await api.lockPoll(normalizedPollId), shouldOutputJson(opts));
8293
8543
  })
8294
8544
  );
8295
- groupPoll.command("share <pollId>").description("Share a poll").action(
8296
- wrapAction(async (pollId, command) => {
8545
+ groupPoll.command("share <pollId>").option("-j, --json", "JSON output").description("Share a poll").action(
8546
+ wrapAction(async (pollId, opts, command) => {
8297
8547
  const normalizedPollId = parsePollId(pollId);
8298
8548
  const { api } = await requireApi(command);
8299
- output(await api.sharePoll(normalizedPollId), false);
8549
+ output(await api.sharePoll(normalizedPollId), shouldOutputJson(opts));
8300
8550
  })
8301
8551
  );
8302
8552
  group.command("rename <groupId> <name>").description("Rename group").action(
@@ -8751,7 +9001,7 @@ me.command("last-online <userId>").description("Get last online of a user").acti
8751
9001
  output(await api.lastOnline(userId), false);
8752
9002
  })
8753
9003
  );
8754
- program.command("listen").description("Listen for real-time incoming messages").option("-e, --echo", "Echo incoming text message").option("-p, --prefix <prefix>", "Only process text starting with prefix").option("-w, --webhook <url>", "POST message payload to webhook").option("-r, --raw", "Output JSON line payload").option("--db", "Force DB persistence for this listener session").option("--no-db", "Disable DB persistence for this listener session").option("-k, --keep-alive", "Auto restart listener on disconnect").option(
9004
+ program.command("listen").description("Listen for real-time incoming messages").option("-e, --echo", "Echo incoming text message").option("-p, --prefix <prefix>", "Only process text starting with prefix").option("-w, --webhook <url>", "POST message payload to webhook").option("-r, --raw", "Output JSON line payload").option("--self", "Include events produced by the logged-in account").option("--db", "Force DB persistence for this listener session").option("--no-db", "Disable DB persistence for this listener session").option("-k, --keep-alive", "Auto restart listener on disconnect").option(
8755
9005
  "--supervised",
8756
9006
  "Supervisor mode (disable internal retry ownership; emit lifecycle events in --raw)"
8757
9007
  ).option(
@@ -8763,7 +9013,8 @@ program.command("listen").description("Listen for real-time incoming messages").
8763
9013
  ).action(
8764
9014
  wrapAction(
8765
9015
  async (opts, command) => {
8766
- const { profile, api } = await requireApi(command);
9016
+ const selfListen = Boolean(opts.self);
9017
+ const { profile, api } = await requireApi(command, { selfListen });
8767
9018
  const supervised = Boolean(opts.supervised);
8768
9019
  const defaultRecycleMs = 30 * 60 * 1e3;
8769
9020
  const recycleMs = parseNonNegativeIntOption("--recycle-ms", opts.recycleMs) ?? parseNonNegativeIntOption(
@@ -8856,6 +9107,7 @@ program.command("listen").description("Listen for real-time incoming messages").
8856
9107
  maxMediaFiles: parseMaxInboundMediaFiles(),
8857
9108
  includeMediaUrl: process.env.OPENZCA_LISTEN_INCLUDE_MEDIA_URL?.trim() ?? null,
8858
9109
  keepAlive: Boolean(opts.keepAlive),
9110
+ selfListen,
8859
9111
  keepAliveRestartDelayMs: Boolean(opts.keepAlive) ? keepAliveRestartDelayMs : void 0,
8860
9112
  keepAliveRestartOnAnyClose: Boolean(opts.keepAlive) ? keepAliveRestartOnAnyClose : void 0,
8861
9113
  supervised,
@@ -9020,6 +9272,7 @@ ${replyContextText}` : replyContextText;
9020
9272
  rawText
9021
9273
  });
9022
9274
  const mentionIds = mentions.map((item) => item.uid);
9275
+ const poll = extractInboundPollInfo(messageData, parsedContent);
9023
9276
  const timestamp = toEpochSeconds(message.data.ts);
9024
9277
  const timestampMs = toEpochMs(message.data.ts);
9025
9278
  const payload = {
@@ -9048,6 +9301,11 @@ ${replyContextText}` : replyContextText;
9048
9301
  mediaKind: mediaKind ?? void 0,
9049
9302
  mentions: mentions.length > 0 ? mentions : void 0,
9050
9303
  mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
9304
+ poll: poll ?? void 0,
9305
+ pollId: poll?.pollId,
9306
+ pollTitle: poll?.title,
9307
+ pollOptionIds: poll?.optionIds,
9308
+ rawMessage: poll ? message.data : void 0,
9051
9309
  metadata: {
9052
9310
  isGroup: message.type === ThreadType3.Group,
9053
9311
  chatType,
@@ -9077,7 +9335,12 @@ ${replyContextText}` : replyContextText;
9077
9335
  mediaKind: mediaKind ?? void 0,
9078
9336
  mentions: mentions.length > 0 ? mentions : void 0,
9079
9337
  mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
9080
- mentionCount: mentions.length > 0 ? mentions.length : void 0
9338
+ mentionCount: mentions.length > 0 ? mentions.length : void 0,
9339
+ poll: poll ?? void 0,
9340
+ pollId: poll?.pollId,
9341
+ pollTitle: poll?.title,
9342
+ pollOptionIds: poll?.optionIds,
9343
+ rawMessage: poll ? message.data : void 0
9081
9344
  },
9082
9345
  // Backward-compatible convenience fields.
9083
9346
  chatType,
@@ -9180,6 +9443,69 @@ ${replyContextText}` : replyContextText;
9180
9443
  }
9181
9444
  }
9182
9445
  });
9446
+ api.listener.on("group_event", async (event) => {
9447
+ const poll = extractInboundPollInfo(event);
9448
+ if (!poll) return;
9449
+ const eventData = asObject(event.data);
9450
+ const groupTopic = asObject(eventData?.groupTopic);
9451
+ const actorId = getStringCandidate(eventData ?? {}, ["sourceId", "creatorId", "actorId", "editorId"]) || getStringCandidate(groupTopic ?? {}, ["creatorId", "editorId"]);
9452
+ const threadName = getStringCandidate(eventData ?? {}, ["groupName"]);
9453
+ const timestampSource = eventData?.time ?? groupTopic?.createTime ?? groupTopic?.editTime ?? Date.now();
9454
+ const timestamp = toEpochSeconds(timestampSource);
9455
+ const payload = {
9456
+ kind: "group_event",
9457
+ event: "poll",
9458
+ threadId: event.threadId,
9459
+ targetId: event.threadId,
9460
+ conversationId: event.threadId,
9461
+ type: ThreadType3.Group,
9462
+ timestamp,
9463
+ groupEventType: event.type,
9464
+ act: event.act,
9465
+ poll,
9466
+ pollId: poll.pollId,
9467
+ pollTitle: poll.title,
9468
+ pollOptionIds: poll.optionIds,
9469
+ rawGroupEvent: event,
9470
+ metadata: {
9471
+ isGroup: true,
9472
+ chatType: "group",
9473
+ threadId: event.threadId,
9474
+ targetId: event.threadId,
9475
+ threadName: threadName || void 0,
9476
+ senderId: actorId || void 0,
9477
+ fromId: actorId || void 0,
9478
+ timestamp,
9479
+ groupEventType: event.type,
9480
+ act: event.act,
9481
+ poll,
9482
+ pollId: poll.pollId,
9483
+ pollTitle: poll.title,
9484
+ pollOptionIds: poll.optionIds,
9485
+ rawGroupEvent: event
9486
+ },
9487
+ chatType: "group",
9488
+ senderId: actorId || void 0,
9489
+ senderName: void 0,
9490
+ senderDisplayName: void 0
9491
+ };
9492
+ writeDebugLine(
9493
+ "listen.group_event.poll",
9494
+ {
9495
+ profile,
9496
+ threadId: event.threadId,
9497
+ groupEventType: event.type,
9498
+ act: event.act,
9499
+ pollId: poll.pollId,
9500
+ pollTitle: poll.title,
9501
+ sessionId
9502
+ },
9503
+ command
9504
+ );
9505
+ if (opts.raw) {
9506
+ console.log(JSON.stringify(payload));
9507
+ }
9508
+ });
9183
9509
  api.listener.on("error", (error) => {
9184
9510
  writeDebugLine(
9185
9511
  "listen.error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {