openzca 0.1.12 → 0.1.14

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 +37 -0
  2. package/dist/cli.js +380 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -192,6 +192,8 @@ For media debugging, grep these events in the debug log:
192
192
  | `openzca listen --webhook <url>` | POST message payload to a webhook URL |
193
193
  | `openzca listen --raw` | Output raw JSON per line |
194
194
  | `openzca listen --keep-alive` | Auto-reconnect on disconnect |
195
+ | `openzca listen --supervised --raw` | Supervisor mode with lifecycle JSON events (`session_id`, `connected`, `heartbeat`, `error`, `closed`) |
196
+ | `openzca listen --keep-alive --recycle-ms <ms>` | Periodically recycle listener process to avoid stale sessions |
195
197
 
196
198
  `listen --raw` includes inbound media metadata when available:
197
199
 
@@ -205,9 +207,21 @@ It also includes stable routing fields for downstream tools:
205
207
  - `threadId`, `targetId`, `conversationId`
206
208
  - `senderId`, `toId`, `chatType`, `msgType`, `timestamp`
207
209
  - `metadata.threadId`, `metadata.targetId`, `metadata.senderId`, `metadata.toId`
210
+ - `quote` and `metadata.quote` when the inbound message is a reply to a previous message
211
+ - Includes parsed `quote.attach` and extracted `quote.mediaUrls` when attachment URLs are present.
212
+ - `quoteMediaPath`, `quoteMediaPaths`, `quoteMediaUrl`, `quoteMediaUrls`, `quoteMediaType`, `quoteMediaTypes`
213
+ - Present when quoted attachment URLs can be resolved/downloaded.
208
214
 
209
215
  For direct messages, `metadata.senderName` is intentionally omitted so consumers can prefer numeric IDs for routing instead of display-name targets.
210
216
 
217
+ When a reply/quoted message is detected, `content` also appends a compact line:
218
+
219
+ ```text
220
+ [reply context: <sender-or-owner-id>: <quoted summary>]
221
+ ```
222
+
223
+ This helps downstream consumers that only read `content` (without parsing `quote`) still see reply context.
224
+
211
225
  `listen` also normalizes JSON-string message payloads (common for `chat.voice` and `share.file`) so media URLs are extracted/cached instead of being forwarded as raw JSON text.
212
226
 
213
227
  For non-text inbound messages (image/video/audio/file), `content` is emitted as a media note:
@@ -241,8 +255,31 @@ Optional overrides:
241
255
  - `OPENZCA_LISTEN_MEDIA_DIR`: explicit inbound media cache directory
242
256
  - `OPENZCA_LISTEN_MEDIA_MAX_BYTES`: max bytes per inbound media file (default `20971520`, 20MB)
243
257
  - `OPENZCA_LISTEN_MEDIA_MAX_FILES`: max inbound media files extracted per message (default `4`, max `16`)
258
+ - `OPENZCA_LISTEN_MEDIA_FETCH_TIMEOUT_MS`: max download time per inbound media URL (default `10000`)
259
+ - Set to `0` to disable timeout.
244
260
  - `OPENZCA_LISTEN_MEDIA_LEGACY_DIR=1`: use legacy storage at `~/.openzca/profiles/<profile>/inbound-media`
245
261
 
262
+ Listener resilience override:
263
+
264
+ - `OPENZCA_LISTEN_RECYCLE_MS`: when `listen --keep-alive` is used, force listener recycle after N milliseconds.
265
+ - Default: `1800000` (30 minutes) if not set.
266
+ - Set to `0` to disable auto recycle.
267
+ - On recycle, `openzca` exits with code `75` so external supervisors (like OpenClaw Gateway) can auto-restart it.
268
+ - `OPENZCA_LISTEN_HEARTBEAT_MS`: heartbeat interval for `listen --supervised --raw` lifecycle events.
269
+ - Default: `30000` (30 seconds).
270
+ - Set to `0` to disable heartbeat events.
271
+ - `OPENZCA_LISTEN_INCLUDE_QUOTE_CONTEXT`: include reply context/quoted-media helper lines in `content`.
272
+ - Default: enabled.
273
+ - Set to `0` to disable.
274
+ - `OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA`: download quoted attachment URLs (if present) into inbound media cache.
275
+ - Default: enabled.
276
+ - Set to `0` to keep only quote metadata/URLs without downloading.
277
+
278
+ Supervised mode notes:
279
+
280
+ - Use `listen --supervised --raw` when an external process manager owns restart logic.
281
+ - In supervised mode, internal websocket retry ownership is disabled (equivalent to forcing `retryOnClose=false`).
282
+
246
283
  ### account — Multi-account profiles
247
284
 
248
285
  | Command | Description |
package/dist/cli.js CHANGED
@@ -1207,6 +1207,13 @@ function parseMaxInboundMediaFiles() {
1207
1207
  if (!Number.isFinite(parsed) || parsed <= 0) return 4;
1208
1208
  return Math.min(Math.max(Math.trunc(parsed), 1), 16);
1209
1209
  }
1210
+ function parseInboundMediaFetchTimeoutMs() {
1211
+ const raw = process.env.OPENZCA_LISTEN_MEDIA_FETCH_TIMEOUT_MS?.trim();
1212
+ if (!raw) return 1e4;
1213
+ const parsed = Number(raw);
1214
+ if (!Number.isFinite(parsed) || parsed < 0) return 1e4;
1215
+ return Math.trunc(parsed);
1216
+ }
1210
1217
  function resolveOpenClawMediaDir() {
1211
1218
  const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path4.join(os3.homedir(), ".openclaw");
1212
1219
  return path4.join(stateDir, "media");
@@ -1225,7 +1232,22 @@ function resolveInboundMediaDir(profile) {
1225
1232
  }
1226
1233
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
1227
1234
  const maxBytes = parseMaxInboundMediaBytes();
1228
- const response = await fetch(mediaUrl);
1235
+ const timeoutMs = parseInboundMediaFetchTimeoutMs();
1236
+ const controller = timeoutMs > 0 ? new AbortController() : void 0;
1237
+ const timeoutId = controller && timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
1238
+ let response;
1239
+ try {
1240
+ response = await fetch(mediaUrl, controller ? { signal: controller.signal } : void 0);
1241
+ } catch (error) {
1242
+ if (error instanceof Error && error.name === "AbortError") {
1243
+ throw new Error(`Timed out downloading inbound media: ${mediaUrl} (${timeoutMs}ms)`);
1244
+ }
1245
+ throw error;
1246
+ } finally {
1247
+ if (timeoutId) {
1248
+ clearTimeout(timeoutId);
1249
+ }
1250
+ }
1229
1251
  if (!response.ok) {
1230
1252
  throw new Error(`Failed to download inbound media: ${mediaUrl} (${response.status})`);
1231
1253
  }
@@ -1247,6 +1269,40 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
1247
1269
  await fs4.writeFile(mediaPath, data);
1248
1270
  return { mediaPath, mediaType };
1249
1271
  }
1272
+ async function cacheRemoteMediaEntries(params) {
1273
+ if (params.urls.length === 0) return [];
1274
+ return Promise.all(
1275
+ params.urls.map(async (mediaUrl) => {
1276
+ let mediaPath;
1277
+ let mediaType = null;
1278
+ try {
1279
+ const cached = await cacheInboundMediaToProfile(params.profile, mediaUrl, params.kind);
1280
+ if (cached) {
1281
+ mediaPath = cached.mediaPath;
1282
+ mediaType = cached.mediaType;
1283
+ }
1284
+ } catch (error) {
1285
+ console.error(
1286
+ `Warning: failed to cache ${params.warningLabel} (${error instanceof Error ? error.message : String(error)})`
1287
+ );
1288
+ writeDebugLine(
1289
+ params.debugErrorEvent,
1290
+ {
1291
+ profile: params.profile,
1292
+ [params.debugUrlKey]: mediaUrl,
1293
+ message: error instanceof Error ? error.message : String(error)
1294
+ },
1295
+ params.command
1296
+ );
1297
+ }
1298
+ return {
1299
+ mediaPath,
1300
+ mediaUrl,
1301
+ mediaType: mediaType ?? void 0
1302
+ };
1303
+ })
1304
+ );
1305
+ }
1250
1306
  function summarizeStructuredContent(msgType, content) {
1251
1307
  const normalizedType = normalizeMessageType(msgType);
1252
1308
  const record = asObject(content);
@@ -1303,6 +1359,106 @@ ${params.caption.trim()}`;
1303
1359
  }
1304
1360
  return mediaNote;
1305
1361
  }
1362
+ function parseToggleDefaultTrue(value) {
1363
+ if (value === void 0) return true;
1364
+ const normalized = value.trim().toLowerCase();
1365
+ if (!normalized) return true;
1366
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
1367
+ return true;
1368
+ }
1369
+ function truncatePreview(value, maxLength = 220) {
1370
+ const normalized = value.trim();
1371
+ if (normalized.length <= maxLength) return normalized;
1372
+ return `${normalized.slice(0, Math.max(maxLength - 3, 1))}...`;
1373
+ }
1374
+ function normalizeQuoteContext(value) {
1375
+ const normalized = normalizeStructuredContent(value);
1376
+ const record = asObject(normalized);
1377
+ if (!record) return null;
1378
+ const ownerId = getStringCandidate(record, [
1379
+ "ownerId",
1380
+ "uidFrom",
1381
+ "fromId",
1382
+ "senderId",
1383
+ "uid"
1384
+ ]);
1385
+ const senderName = getStringCandidate(record, [
1386
+ "fromD",
1387
+ "senderName",
1388
+ "dName",
1389
+ "displayName",
1390
+ "name"
1391
+ ]);
1392
+ const msg2 = getStringCandidate(record, [
1393
+ "msg",
1394
+ "message",
1395
+ "text",
1396
+ "content",
1397
+ "title",
1398
+ "description"
1399
+ ]);
1400
+ const cliMsgId = getStringCandidate(record, ["cliMsgId"]);
1401
+ const globalMsgId = getStringCandidate(record, ["globalMsgId", "msgId", "realMsgId"]);
1402
+ const cliMsgType = typeof record.cliMsgType === "number" && Number.isFinite(record.cliMsgType) ? Math.trunc(record.cliMsgType) : void 0;
1403
+ const attach = record.attach === void 0 ? void 0 : normalizeStructuredContent(record.attach);
1404
+ const mediaUrlSet = /* @__PURE__ */ new Set();
1405
+ if (attach !== void 0) {
1406
+ collectHttpUrls(attach, mediaUrlSet);
1407
+ }
1408
+ const tsRaw = record.ts;
1409
+ const tsNumeric = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Number(tsRaw) : Number.NaN;
1410
+ const ts = Number.isFinite(tsNumeric) ? Math.trunc(tsNumeric) : void 0;
1411
+ if (!ownerId && !senderName && !msg2 && !cliMsgId && !globalMsgId && attach === void 0) {
1412
+ return null;
1413
+ }
1414
+ return {
1415
+ ownerId: ownerId || void 0,
1416
+ senderName: senderName || void 0,
1417
+ msg: msg2 || void 0,
1418
+ attach,
1419
+ mediaUrls: mediaUrlSet.size > 0 ? [...mediaUrlSet] : void 0,
1420
+ ts,
1421
+ cliMsgId: cliMsgId || void 0,
1422
+ globalMsgId: globalMsgId || void 0,
1423
+ cliMsgType
1424
+ };
1425
+ }
1426
+ function buildReplyContextText(quote) {
1427
+ const from = quote.senderName || quote.ownerId || "unknown";
1428
+ const messageText = quote.msg?.trim() || "";
1429
+ const attachText = quote.attach !== void 0 ? summarizeStructuredContent("quote", quote.attach) : "";
1430
+ let summary = messageText || attachText;
1431
+ if (!summary || summary === "<non-text:quote>" || summary === "<non-text-message>") {
1432
+ if (quote.mediaUrls && quote.mediaUrls.length > 0) {
1433
+ summary = quote.mediaUrls.length > 1 ? `${quote.mediaUrls[0]} (+${quote.mediaUrls.length - 1} more)` : quote.mediaUrls[0];
1434
+ } else {
1435
+ summary = "<quoted-message>";
1436
+ }
1437
+ }
1438
+ return `[reply context: ${from}: ${truncatePreview(summary.replace(/\s+/g, " "))}]`;
1439
+ }
1440
+ function buildReplyMediaAttachedText(params) {
1441
+ const entries = params.mediaEntries.map((entry) => ({
1442
+ pathOrUrl: entry.mediaPath ?? entry.mediaUrl,
1443
+ mediaPath: entry.mediaPath,
1444
+ mediaUrl: entry.mediaUrl,
1445
+ mediaType: entry.mediaType
1446
+ })).filter((entry) => Boolean(entry.pathOrUrl));
1447
+ if (entries.length === 0) return "";
1448
+ const multiple = entries.length > 1;
1449
+ const lines = [];
1450
+ if (multiple) {
1451
+ lines.push(`[reply media attached: ${entries.length} files]`);
1452
+ }
1453
+ for (let index = 0; index < entries.length; index += 1) {
1454
+ const entry = entries[index];
1455
+ const typePart = entry.mediaType?.trim() ? ` (${entry.mediaType.trim()})` : "";
1456
+ const urlPart = entry.mediaPath && entry.mediaUrl ? ` | ${entry.mediaUrl}` : "";
1457
+ const prefix = multiple ? `[reply media attached ${index + 1}/${entries.length}: ` : "[reply media attached: ";
1458
+ lines.push(`${prefix}${entry.pathOrUrl}${typePart}${urlPart}]`);
1459
+ }
1460
+ return lines.join("\n");
1461
+ }
1306
1462
  function normalizeFriendLookupRows(value) {
1307
1463
  const queue = [value];
1308
1464
  const rows = [];
@@ -1384,6 +1540,14 @@ function toEpochSeconds(input) {
1384
1540
  }
1385
1541
  return Math.floor(numeric);
1386
1542
  }
1543
+ function parseNonNegativeIntOption(label, value) {
1544
+ if (!value || !value.trim()) return void 0;
1545
+ const parsed = Number(value.trim());
1546
+ if (!Number.isFinite(parsed) || parsed < 0) {
1547
+ throw new Error(`${label} must be a non-negative number.`);
1548
+ }
1549
+ return Math.trunc(parsed);
1550
+ }
1387
1551
  program.name("openzca").description("Open-source zca-cli compatible wrapper powered by zca-js").version(PKG_VERSION).option("-p, --profile <name>", "Profile name").option("--debug", "Enable debug logging").option("--debug-file <path>", "Debug log file path").showHelpAfterError();
1388
1552
  program.hook("preAction", (_parent, actionCommand) => {
1389
1553
  if (!resolveDebugEnabled(actionCommand)) {
@@ -2419,11 +2583,56 @@ me.command("last-online <userId>").description("Get last online of a user").acti
2419
2583
  output(await api.lastOnline(userId), false);
2420
2584
  })
2421
2585
  );
2422
- 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("-k, --keep-alive", "Auto restart listener on disconnect").action(
2586
+ 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("-k, --keep-alive", "Auto restart listener on disconnect").option(
2587
+ "--supervised",
2588
+ "Supervisor mode (disable internal retry ownership; emit lifecycle events in --raw)"
2589
+ ).option(
2590
+ "--heartbeat-ms <ms>",
2591
+ "Lifecycle heartbeat interval in --supervised mode (default: 30000, 0 disables)"
2592
+ ).option(
2593
+ "--recycle-ms <ms>",
2594
+ "Force recycle listener after N ms (or use OPENZCA_LISTEN_RECYCLE_MS)"
2595
+ ).action(
2423
2596
  wrapAction(
2424
2597
  async (opts, command) => {
2425
2598
  const { profile, api } = await requireApi(command);
2599
+ const supervised = Boolean(opts.supervised);
2600
+ const defaultRecycleMs = 30 * 60 * 1e3;
2601
+ const recycleMs = parseNonNegativeIntOption("--recycle-ms", opts.recycleMs) ?? parseNonNegativeIntOption(
2602
+ "OPENZCA_LISTEN_RECYCLE_MS",
2603
+ process.env.OPENZCA_LISTEN_RECYCLE_MS
2604
+ ) ?? defaultRecycleMs;
2605
+ const heartbeatMs = parseNonNegativeIntOption("--heartbeat-ms", opts.heartbeatMs) ?? parseNonNegativeIntOption(
2606
+ "OPENZCA_LISTEN_HEARTBEAT_MS",
2607
+ process.env.OPENZCA_LISTEN_HEARTBEAT_MS
2608
+ ) ?? 3e4;
2609
+ const lifecycleEventsEnabled = supervised && Boolean(opts.raw);
2610
+ const recycleEnabled = !supervised && Boolean(opts.keepAlive) && recycleMs > 0;
2611
+ const recycleExitCode = 75;
2612
+ const includeReplyContext = parseToggleDefaultTrue(
2613
+ process.env.OPENZCA_LISTEN_INCLUDE_QUOTE_CONTEXT
2614
+ );
2615
+ const downloadQuoteMedia = parseToggleDefaultTrue(
2616
+ process.env.OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA
2617
+ );
2618
+ const sessionId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
2619
+ const emitLifecycle = (event, fields) => {
2620
+ if (!lifecycleEventsEnabled) return;
2621
+ console.log(
2622
+ JSON.stringify({
2623
+ kind: "lifecycle",
2624
+ event,
2625
+ session_id: sessionId,
2626
+ profile,
2627
+ timestamp: Math.floor(Date.now() / 1e3),
2628
+ ...fields
2629
+ })
2630
+ );
2631
+ };
2426
2632
  console.log("Listening... Press Ctrl+C to stop.");
2633
+ if (supervised && opts.keepAlive) {
2634
+ console.error("Warning: --supervised ignores internal --keep-alive reconnect ownership.");
2635
+ }
2427
2636
  writeDebugLine(
2428
2637
  "listen.start",
2429
2638
  {
@@ -2431,10 +2640,19 @@ program.command("listen").description("Listen for real-time incoming messages").
2431
2640
  mediaDir: resolveInboundMediaDir(profile),
2432
2641
  maxMediaBytes: parseMaxInboundMediaBytes(),
2433
2642
  maxMediaFiles: parseMaxInboundMediaFiles(),
2434
- includeMediaUrl: process.env.OPENZCA_LISTEN_INCLUDE_MEDIA_URL?.trim() ?? null
2643
+ includeMediaUrl: process.env.OPENZCA_LISTEN_INCLUDE_MEDIA_URL?.trim() ?? null,
2644
+ keepAlive: Boolean(opts.keepAlive),
2645
+ supervised,
2646
+ lifecycleEventsEnabled,
2647
+ heartbeatMs: lifecycleEventsEnabled ? heartbeatMs : void 0,
2648
+ recycleMs: recycleEnabled ? recycleMs : void 0,
2649
+ includeReplyContext,
2650
+ downloadQuoteMedia,
2651
+ sessionId
2435
2652
  },
2436
2653
  command
2437
2654
  );
2655
+ emitLifecycle("session_id");
2438
2656
  async function emitWebhook(payload) {
2439
2657
  if (!opts.webhook) return;
2440
2658
  try {
@@ -2456,17 +2674,28 @@ program.command("listen").description("Listen for real-time incoming messages").
2456
2674
  }
2457
2675
  api.listener.on("connected", () => {
2458
2676
  console.log("Connected to Zalo websocket.");
2677
+ writeDebugLine(
2678
+ "listen.connected",
2679
+ {
2680
+ profile,
2681
+ sessionId
2682
+ },
2683
+ command
2684
+ );
2685
+ emitLifecycle("connected");
2459
2686
  });
2460
2687
  api.listener.on("message", async (message) => {
2461
2688
  const messageData = message.data;
2462
2689
  const rawContent = messageData.content;
2463
2690
  const msgType = getStringCandidate(messageData, ["msgType"]);
2691
+ let quote = normalizeQuoteContext(messageData.quote);
2464
2692
  const parsedContent = normalizeStructuredContent(rawContent);
2465
2693
  const hasParsedStructuredContent = parsedContent !== rawContent;
2466
2694
  const rawText = typeof rawContent === "string" ? rawContent : "";
2467
2695
  const mediaKind = detectInboundMediaKind(msgType, parsedContent);
2468
2696
  const maxMediaFiles = parseMaxInboundMediaFiles();
2469
2697
  const remoteMediaUrls = mediaKind && maxMediaFiles > 0 ? resolvePreferredMediaUrls(mediaKind, parsedContent).slice(0, maxMediaFiles) : [];
2698
+ const quoteRemoteMediaUrls = quote && downloadQuoteMedia && maxMediaFiles > 0 ? (quote.mediaUrls ?? []).slice(0, maxMediaFiles) : [];
2470
2699
  writeDebugLine(
2471
2700
  "listen.media.detected",
2472
2701
  {
@@ -2475,42 +2704,35 @@ program.command("listen").description("Listen for real-time incoming messages").
2475
2704
  msgType: msgType || void 0,
2476
2705
  mediaKind,
2477
2706
  hasParsedStructuredContent,
2478
- remoteMediaUrls
2707
+ remoteMediaUrls,
2708
+ hasQuote: Boolean(quote),
2709
+ quoteOwnerId: quote?.ownerId,
2710
+ quoteGlobalMsgId: quote?.globalMsgId,
2711
+ quoteCliMsgId: quote?.cliMsgId,
2712
+ quoteRemoteMediaUrls
2479
2713
  },
2480
2714
  command
2481
2715
  );
2482
- const mediaEntries = [];
2483
- for (const mediaUrl2 of remoteMediaUrls) {
2484
- let mediaPath2;
2485
- let mediaType2 = null;
2486
- try {
2487
- if (mediaKind) {
2488
- const cached = await cacheInboundMediaToProfile(profile, mediaUrl2, mediaKind);
2489
- if (cached) {
2490
- mediaPath2 = cached.mediaPath;
2491
- mediaType2 = cached.mediaType;
2492
- }
2493
- }
2494
- } catch (error) {
2495
- console.error(
2496
- `Warning: failed to cache inbound media (${error instanceof Error ? error.message : String(error)})`
2497
- );
2498
- writeDebugLine(
2499
- "listen.media.cache_error",
2500
- {
2501
- profile,
2502
- mediaUrl: mediaUrl2,
2503
- message: error instanceof Error ? error.message : String(error)
2504
- },
2505
- command
2506
- );
2507
- }
2508
- mediaEntries.push({
2509
- mediaPath: mediaPath2,
2510
- mediaUrl: mediaUrl2,
2511
- mediaType: mediaType2 ?? void 0
2512
- });
2513
- }
2716
+ const [mediaEntries, quoteMediaEntries] = await Promise.all([
2717
+ mediaKind ? cacheRemoteMediaEntries({
2718
+ profile,
2719
+ urls: remoteMediaUrls,
2720
+ kind: mediaKind,
2721
+ command,
2722
+ warningLabel: "inbound media",
2723
+ debugErrorEvent: "listen.media.cache_error",
2724
+ debugUrlKey: "mediaUrl"
2725
+ }) : Promise.resolve([]),
2726
+ cacheRemoteMediaEntries({
2727
+ profile,
2728
+ urls: quoteRemoteMediaUrls,
2729
+ kind: "file",
2730
+ command,
2731
+ warningLabel: "quoted media",
2732
+ debugErrorEvent: "listen.quote_media.cache_error",
2733
+ debugUrlKey: "quoteMediaUrl"
2734
+ })
2735
+ ]);
2514
2736
  const localEntries = mediaEntries.filter((entry) => Boolean(entry.mediaPath));
2515
2737
  const mediaPaths = localEntries.map((entry) => entry.mediaPath);
2516
2738
  const mediaUrls = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
@@ -2518,17 +2740,45 @@ program.command("listen").description("Listen for real-time incoming messages").
2518
2740
  const mediaPath = mediaPaths[0];
2519
2741
  const mediaUrl = mediaUrls[0];
2520
2742
  const mediaType = mediaTypes[0];
2743
+ const quoteLocalEntries = quoteMediaEntries.filter((entry) => Boolean(entry.mediaPath));
2744
+ const quoteMediaPaths = quoteLocalEntries.map((entry) => entry.mediaPath);
2745
+ const quoteMediaUrls = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
2746
+ const quoteMediaTypes = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
2747
+ const quoteMediaPath = quoteMediaPaths[0];
2748
+ const quoteMediaUrl = quoteMediaUrls[0];
2749
+ const quoteMediaType = quoteMediaTypes[0];
2750
+ if (quote) {
2751
+ quote = {
2752
+ ...quote,
2753
+ mediaPath: quoteMediaPath,
2754
+ mediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
2755
+ mediaUrl: quoteMediaUrl,
2756
+ mediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : quote.mediaUrls,
2757
+ mediaType: quoteMediaType,
2758
+ mediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0
2759
+ };
2760
+ }
2761
+ const replyContextText = includeReplyContext && quote ? buildReplyContextText(quote) : "";
2762
+ const replyMediaText = includeReplyContext && quoteMediaEntries.length > 0 ? buildReplyMediaAttachedText({ mediaEntries: quoteMediaEntries }) : "";
2521
2763
  const caption = rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText.trim() : summarizeStructuredContent(msgType, parsedContent);
2522
2764
  let processedText = mediaEntries.length ? buildMediaAttachedText({
2523
2765
  mediaEntries,
2524
2766
  fallbackKind: mediaKind,
2525
2767
  caption
2526
2768
  }) : rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText : summarizeStructuredContent(msgType, parsedContent);
2527
- if (!processedText.trim()) return;
2528
- if (opts.prefix) {
2769
+ if (!processedText.trim() && !replyContextText && !replyMediaText) return;
2770
+ if (opts.prefix && processedText.trim().length > 0) {
2529
2771
  if (!processedText.startsWith(opts.prefix)) return;
2530
2772
  processedText = processedText.slice(opts.prefix.length).trimStart();
2531
2773
  }
2774
+ if (replyMediaText) {
2775
+ processedText = processedText.trim() ? `${processedText}
2776
+ ${replyMediaText}` : replyMediaText;
2777
+ }
2778
+ if (replyContextText) {
2779
+ processedText = processedText.trim() ? `${processedText}
2780
+ ${replyContextText}` : replyContextText;
2781
+ }
2532
2782
  const chatType = message.type === ThreadType.Group ? "group" : "user";
2533
2783
  const senderId = getStringCandidate(messageData, ["uidFrom"]) || message.data.uidFrom;
2534
2784
  const senderDisplayNameRaw = getStringCandidate(messageData, ["dName"]);
@@ -2547,6 +2797,13 @@ program.command("listen").description("Listen for real-time incoming messages").
2547
2797
  type: message.type,
2548
2798
  timestamp,
2549
2799
  msgType: msgType || void 0,
2800
+ quote: quote ?? void 0,
2801
+ quoteMediaPath,
2802
+ quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
2803
+ quoteMediaUrl,
2804
+ quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
2805
+ quoteMediaType,
2806
+ quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
2550
2807
  mediaPath,
2551
2808
  mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
2552
2809
  mediaUrl,
@@ -2566,6 +2823,13 @@ program.command("listen").description("Listen for real-time incoming messages").
2566
2823
  fromId: senderId,
2567
2824
  toId,
2568
2825
  msgType: msgType || void 0,
2826
+ quote: quote ?? void 0,
2827
+ quoteMediaPath,
2828
+ quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
2829
+ quoteMediaUrl,
2830
+ quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
2831
+ quoteMediaType,
2832
+ quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
2569
2833
  timestamp,
2570
2834
  mediaPath,
2571
2835
  mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
@@ -2606,20 +2870,62 @@ program.command("listen").description("Listen for real-time incoming messages").
2606
2870
  }
2607
2871
  });
2608
2872
  api.listener.on("error", (error) => {
2873
+ writeDebugLine(
2874
+ "listen.error",
2875
+ {
2876
+ profile,
2877
+ message: error instanceof Error ? error.message : String(error),
2878
+ sessionId
2879
+ },
2880
+ command
2881
+ );
2882
+ emitLifecycle("error", {
2883
+ message: error instanceof Error ? error.message : String(error)
2884
+ });
2609
2885
  console.error(
2610
2886
  `Listener error: ${error instanceof Error ? error.message : String(error)}`
2611
2887
  );
2612
2888
  });
2613
2889
  await new Promise((resolve) => {
2614
2890
  let settled = false;
2891
+ let recycleTimer = null;
2892
+ let recycleForceExitTimer = null;
2893
+ let heartbeatTimer = null;
2894
+ let recyclePendingExit = false;
2615
2895
  const finish = () => {
2616
2896
  if (settled) return;
2617
2897
  settled = true;
2898
+ if (recycleTimer) {
2899
+ clearTimeout(recycleTimer);
2900
+ recycleTimer = null;
2901
+ }
2902
+ if (recycleForceExitTimer && !recyclePendingExit) {
2903
+ clearTimeout(recycleForceExitTimer);
2904
+ recycleForceExitTimer = null;
2905
+ }
2906
+ if (heartbeatTimer) {
2907
+ clearInterval(heartbeatTimer);
2908
+ heartbeatTimer = null;
2909
+ }
2618
2910
  resolve();
2619
2911
  };
2620
2912
  api.listener.on("closed", (code, reason) => {
2621
2913
  console.log(`Listener closed (${code}) ${reason || ""}`);
2622
- if (!opts.keepAlive) {
2914
+ writeDebugLine(
2915
+ "listen.closed",
2916
+ {
2917
+ profile,
2918
+ code,
2919
+ reason: reason || void 0,
2920
+ sessionId
2921
+ },
2922
+ command
2923
+ );
2924
+ emitLifecycle("closed", {
2925
+ code,
2926
+ reason: reason || void 0
2927
+ });
2928
+ if (!opts.keepAlive || supervised) {
2623
2929
  finish();
2624
2930
  }
2625
2931
  });
@@ -2630,8 +2936,42 @@ program.command("listen").description("Listen for real-time incoming messages").
2630
2936
  }
2631
2937
  finish();
2632
2938
  };
2939
+ if (lifecycleEventsEnabled && heartbeatMs > 0) {
2940
+ heartbeatTimer = setInterval(() => {
2941
+ emitLifecycle("heartbeat");
2942
+ }, heartbeatMs);
2943
+ }
2944
+ if (recycleEnabled) {
2945
+ recycleTimer = setTimeout(() => {
2946
+ console.error(
2947
+ `Listener recycle triggered after ${recycleMs}ms to prevent stale session.`
2948
+ );
2949
+ writeDebugLine(
2950
+ "listen.recycle",
2951
+ {
2952
+ profile,
2953
+ recycleMs,
2954
+ exitCode: recycleExitCode,
2955
+ sessionId
2956
+ },
2957
+ command
2958
+ );
2959
+ process.exitCode = recycleExitCode;
2960
+ recyclePendingExit = true;
2961
+ recycleForceExitTimer = setTimeout(() => {
2962
+ recycleForceExitTimer = null;
2963
+ process.exit(recycleExitCode);
2964
+ }, 3e3);
2965
+ recycleForceExitTimer.unref();
2966
+ try {
2967
+ api.listener.stop();
2968
+ } catch {
2969
+ }
2970
+ finish();
2971
+ }, recycleMs);
2972
+ }
2633
2973
  process.once("SIGINT", onSigint);
2634
- api.listener.start({ retryOnClose: Boolean(opts.keepAlive) });
2974
+ api.listener.start({ retryOnClose: supervised ? false : Boolean(opts.keepAlive) });
2635
2975
  });
2636
2976
  }
2637
2977
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {