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.
- package/README.md +37 -0
- package/dist/cli.js +380 -40
- 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
|
|
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").
|
|
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
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
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
|
-
|
|
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
|
)
|