openzca 0.1.13 → 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 +20 -0
  2. package/dist/cli.js +237 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -207,9 +207,21 @@ It also includes stable routing fields for downstream tools:
207
207
  - `threadId`, `targetId`, `conversationId`
208
208
  - `senderId`, `toId`, `chatType`, `msgType`, `timestamp`
209
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.
210
214
 
211
215
  For direct messages, `metadata.senderName` is intentionally omitted so consumers can prefer numeric IDs for routing instead of display-name targets.
212
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
+
213
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.
214
226
 
215
227
  For non-text inbound messages (image/video/audio/file), `content` is emitted as a media note:
@@ -243,6 +255,8 @@ Optional overrides:
243
255
  - `OPENZCA_LISTEN_MEDIA_DIR`: explicit inbound media cache directory
244
256
  - `OPENZCA_LISTEN_MEDIA_MAX_BYTES`: max bytes per inbound media file (default `20971520`, 20MB)
245
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.
246
260
  - `OPENZCA_LISTEN_MEDIA_LEGACY_DIR=1`: use legacy storage at `~/.openzca/profiles/<profile>/inbound-media`
247
261
 
248
262
  Listener resilience override:
@@ -254,6 +268,12 @@ Listener resilience override:
254
268
  - `OPENZCA_LISTEN_HEARTBEAT_MS`: heartbeat interval for `listen --supervised --raw` lifecycle events.
255
269
  - Default: `30000` (30 seconds).
256
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.
257
277
 
258
278
  Supervised mode notes:
259
279
 
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 = [];
@@ -2453,6 +2609,12 @@ program.command("listen").description("Listen for real-time incoming messages").
2453
2609
  const lifecycleEventsEnabled = supervised && Boolean(opts.raw);
2454
2610
  const recycleEnabled = !supervised && Boolean(opts.keepAlive) && recycleMs > 0;
2455
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
+ );
2456
2618
  const sessionId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
2457
2619
  const emitLifecycle = (event, fields) => {
2458
2620
  if (!lifecycleEventsEnabled) return;
@@ -2484,6 +2646,8 @@ program.command("listen").description("Listen for real-time incoming messages").
2484
2646
  lifecycleEventsEnabled,
2485
2647
  heartbeatMs: lifecycleEventsEnabled ? heartbeatMs : void 0,
2486
2648
  recycleMs: recycleEnabled ? recycleMs : void 0,
2649
+ includeReplyContext,
2650
+ downloadQuoteMedia,
2487
2651
  sessionId
2488
2652
  },
2489
2653
  command
@@ -2524,12 +2688,14 @@ program.command("listen").description("Listen for real-time incoming messages").
2524
2688
  const messageData = message.data;
2525
2689
  const rawContent = messageData.content;
2526
2690
  const msgType = getStringCandidate(messageData, ["msgType"]);
2691
+ let quote = normalizeQuoteContext(messageData.quote);
2527
2692
  const parsedContent = normalizeStructuredContent(rawContent);
2528
2693
  const hasParsedStructuredContent = parsedContent !== rawContent;
2529
2694
  const rawText = typeof rawContent === "string" ? rawContent : "";
2530
2695
  const mediaKind = detectInboundMediaKind(msgType, parsedContent);
2531
2696
  const maxMediaFiles = parseMaxInboundMediaFiles();
2532
2697
  const remoteMediaUrls = mediaKind && maxMediaFiles > 0 ? resolvePreferredMediaUrls(mediaKind, parsedContent).slice(0, maxMediaFiles) : [];
2698
+ const quoteRemoteMediaUrls = quote && downloadQuoteMedia && maxMediaFiles > 0 ? (quote.mediaUrls ?? []).slice(0, maxMediaFiles) : [];
2533
2699
  writeDebugLine(
2534
2700
  "listen.media.detected",
2535
2701
  {
@@ -2538,42 +2704,35 @@ program.command("listen").description("Listen for real-time incoming messages").
2538
2704
  msgType: msgType || void 0,
2539
2705
  mediaKind,
2540
2706
  hasParsedStructuredContent,
2541
- remoteMediaUrls
2707
+ remoteMediaUrls,
2708
+ hasQuote: Boolean(quote),
2709
+ quoteOwnerId: quote?.ownerId,
2710
+ quoteGlobalMsgId: quote?.globalMsgId,
2711
+ quoteCliMsgId: quote?.cliMsgId,
2712
+ quoteRemoteMediaUrls
2542
2713
  },
2543
2714
  command
2544
2715
  );
2545
- const mediaEntries = [];
2546
- for (const mediaUrl2 of remoteMediaUrls) {
2547
- let mediaPath2;
2548
- let mediaType2 = null;
2549
- try {
2550
- if (mediaKind) {
2551
- const cached = await cacheInboundMediaToProfile(profile, mediaUrl2, mediaKind);
2552
- if (cached) {
2553
- mediaPath2 = cached.mediaPath;
2554
- mediaType2 = cached.mediaType;
2555
- }
2556
- }
2557
- } catch (error) {
2558
- console.error(
2559
- `Warning: failed to cache inbound media (${error instanceof Error ? error.message : String(error)})`
2560
- );
2561
- writeDebugLine(
2562
- "listen.media.cache_error",
2563
- {
2564
- profile,
2565
- mediaUrl: mediaUrl2,
2566
- message: error instanceof Error ? error.message : String(error)
2567
- },
2568
- command
2569
- );
2570
- }
2571
- mediaEntries.push({
2572
- mediaPath: mediaPath2,
2573
- mediaUrl: mediaUrl2,
2574
- mediaType: mediaType2 ?? void 0
2575
- });
2576
- }
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
+ ]);
2577
2736
  const localEntries = mediaEntries.filter((entry) => Boolean(entry.mediaPath));
2578
2737
  const mediaPaths = localEntries.map((entry) => entry.mediaPath);
2579
2738
  const mediaUrls = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
@@ -2581,17 +2740,45 @@ program.command("listen").description("Listen for real-time incoming messages").
2581
2740
  const mediaPath = mediaPaths[0];
2582
2741
  const mediaUrl = mediaUrls[0];
2583
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 }) : "";
2584
2763
  const caption = rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText.trim() : summarizeStructuredContent(msgType, parsedContent);
2585
2764
  let processedText = mediaEntries.length ? buildMediaAttachedText({
2586
2765
  mediaEntries,
2587
2766
  fallbackKind: mediaKind,
2588
2767
  caption
2589
2768
  }) : rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText : summarizeStructuredContent(msgType, parsedContent);
2590
- if (!processedText.trim()) return;
2591
- if (opts.prefix) {
2769
+ if (!processedText.trim() && !replyContextText && !replyMediaText) return;
2770
+ if (opts.prefix && processedText.trim().length > 0) {
2592
2771
  if (!processedText.startsWith(opts.prefix)) return;
2593
2772
  processedText = processedText.slice(opts.prefix.length).trimStart();
2594
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
+ }
2595
2782
  const chatType = message.type === ThreadType.Group ? "group" : "user";
2596
2783
  const senderId = getStringCandidate(messageData, ["uidFrom"]) || message.data.uidFrom;
2597
2784
  const senderDisplayNameRaw = getStringCandidate(messageData, ["dName"]);
@@ -2610,6 +2797,13 @@ program.command("listen").description("Listen for real-time incoming messages").
2610
2797
  type: message.type,
2611
2798
  timestamp,
2612
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,
2613
2807
  mediaPath,
2614
2808
  mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
2615
2809
  mediaUrl,
@@ -2629,6 +2823,13 @@ program.command("listen").description("Listen for real-time incoming messages").
2629
2823
  fromId: senderId,
2630
2824
  toId,
2631
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,
2632
2833
  timestamp,
2633
2834
  mediaPath,
2634
2835
  mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.13",
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": {