openzca 0.1.48 → 0.1.49
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 +78 -1
- package/dist/cli.js +2793 -97
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
import { createRequire } from "module";
|
|
5
5
|
import { spawn as spawn2 } from "child_process";
|
|
6
6
|
import fsSync from "fs";
|
|
7
|
-
import
|
|
7
|
+
import fs6 from "fs/promises";
|
|
8
8
|
import net from "net";
|
|
9
9
|
import os4 from "os";
|
|
10
|
-
import
|
|
10
|
+
import path6 from "path";
|
|
11
|
+
import readline from "readline/promises";
|
|
11
12
|
import util from "util";
|
|
12
13
|
import { Command } from "commander";
|
|
13
14
|
import {
|
|
@@ -210,10 +211,1232 @@ async function clearCache(profileName) {
|
|
|
210
211
|
await fs.rm(getCacheMetaPath(profileName), { force: true });
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// src/lib/
|
|
214
|
+
// src/lib/db.ts
|
|
215
|
+
import crypto from "crypto";
|
|
214
216
|
import fs2 from "fs/promises";
|
|
215
|
-
import { spawn } from "child_process";
|
|
216
217
|
import path2 from "path";
|
|
218
|
+
import { open } from "sqlite";
|
|
219
|
+
import sqlite3 from "sqlite3";
|
|
220
|
+
var DB_CONFIG_FILE = "db.json";
|
|
221
|
+
var DB_FILENAME = "messages.sqlite";
|
|
222
|
+
var connections = /* @__PURE__ */ new Map();
|
|
223
|
+
var writeQueues = /* @__PURE__ */ new Map();
|
|
224
|
+
function nowIso2() {
|
|
225
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
226
|
+
}
|
|
227
|
+
function normalizeId(value) {
|
|
228
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
229
|
+
return String(Math.trunc(value));
|
|
230
|
+
}
|
|
231
|
+
if (typeof value !== "string") {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
return value.trim();
|
|
235
|
+
}
|
|
236
|
+
function normalizeOptionalText(value) {
|
|
237
|
+
if (typeof value !== "string") {
|
|
238
|
+
return void 0;
|
|
239
|
+
}
|
|
240
|
+
const trimmed = value.trim();
|
|
241
|
+
return trimmed || void 0;
|
|
242
|
+
}
|
|
243
|
+
function normalizeSearchText(value) {
|
|
244
|
+
return value.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().trim();
|
|
245
|
+
}
|
|
246
|
+
function globToRegex(pattern) {
|
|
247
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
248
|
+
const regexSource = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
249
|
+
return new RegExp(`^${regexSource}$`);
|
|
250
|
+
}
|
|
251
|
+
function matchesSearchPattern(value, query) {
|
|
252
|
+
const normalizedValue = normalizeSearchText(value);
|
|
253
|
+
if (query.includes("*") || query.includes("?")) {
|
|
254
|
+
return globToRegex(query).test(normalizedValue);
|
|
255
|
+
}
|
|
256
|
+
return normalizedValue.includes(query);
|
|
257
|
+
}
|
|
258
|
+
function safeJsonStringify(value) {
|
|
259
|
+
if (value === void 0) {
|
|
260
|
+
return void 0;
|
|
261
|
+
}
|
|
262
|
+
return JSON.stringify(value);
|
|
263
|
+
}
|
|
264
|
+
function defaultDbPath(profile) {
|
|
265
|
+
return path2.join(getProfileDir(profile), DB_FILENAME);
|
|
266
|
+
}
|
|
267
|
+
function getDbConfigPath(profile) {
|
|
268
|
+
return path2.join(getProfileDir(profile), DB_CONFIG_FILE);
|
|
269
|
+
}
|
|
270
|
+
async function readDbConfig(profile) {
|
|
271
|
+
const configPath = getDbConfigPath(profile);
|
|
272
|
+
try {
|
|
273
|
+
const raw = await fs2.readFile(configPath, "utf8");
|
|
274
|
+
const parsed = JSON.parse(raw);
|
|
275
|
+
return {
|
|
276
|
+
enabled: Boolean(parsed.enabled),
|
|
277
|
+
path: normalizeOptionalText(parsed.path),
|
|
278
|
+
updatedAt: normalizeOptionalText(parsed.updatedAt) ?? nowIso2()
|
|
279
|
+
};
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const code = error.code;
|
|
282
|
+
if (code === "ENOENT") {
|
|
283
|
+
return {
|
|
284
|
+
enabled: false,
|
|
285
|
+
updatedAt: nowIso2()
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function writeDbConfig(profile, config) {
|
|
292
|
+
const configPath = getDbConfigPath(profile);
|
|
293
|
+
await fs2.mkdir(path2.dirname(configPath), { recursive: true });
|
|
294
|
+
await fs2.writeFile(configPath, `${JSON.stringify(config, null, 2)}
|
|
295
|
+
`, "utf8");
|
|
296
|
+
}
|
|
297
|
+
async function enableDb(profile, customPath) {
|
|
298
|
+
const config = {
|
|
299
|
+
enabled: true,
|
|
300
|
+
path: normalizeOptionalText(customPath),
|
|
301
|
+
updatedAt: nowIso2()
|
|
302
|
+
};
|
|
303
|
+
await writeDbConfig(profile, config);
|
|
304
|
+
await getDb(profile);
|
|
305
|
+
return config;
|
|
306
|
+
}
|
|
307
|
+
async function disableDb(profile) {
|
|
308
|
+
const existing = await readDbConfig(profile);
|
|
309
|
+
const config = {
|
|
310
|
+
enabled: false,
|
|
311
|
+
path: existing.path,
|
|
312
|
+
updatedAt: nowIso2()
|
|
313
|
+
};
|
|
314
|
+
await writeDbConfig(profile, config);
|
|
315
|
+
return config;
|
|
316
|
+
}
|
|
317
|
+
async function isDbEnabled(profile) {
|
|
318
|
+
const config = await readDbConfig(profile);
|
|
319
|
+
return config.enabled;
|
|
320
|
+
}
|
|
321
|
+
async function resolveDbPath(profile) {
|
|
322
|
+
const config = await readDbConfig(profile);
|
|
323
|
+
const configured = normalizeOptionalText(config.path);
|
|
324
|
+
if (!configured) {
|
|
325
|
+
return defaultDbPath(profile);
|
|
326
|
+
}
|
|
327
|
+
return path2.isAbsolute(configured) ? configured : path2.resolve(getProfileDir(profile), configured);
|
|
328
|
+
}
|
|
329
|
+
async function migrateDb(db) {
|
|
330
|
+
await db.exec(`
|
|
331
|
+
PRAGMA journal_mode = WAL;
|
|
332
|
+
PRAGMA foreign_keys = ON;
|
|
333
|
+
|
|
334
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
335
|
+
profile TEXT NOT NULL,
|
|
336
|
+
scope_thread_id TEXT NOT NULL,
|
|
337
|
+
raw_thread_id TEXT NOT NULL,
|
|
338
|
+
thread_type TEXT NOT NULL,
|
|
339
|
+
peer_id TEXT,
|
|
340
|
+
title TEXT,
|
|
341
|
+
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
342
|
+
is_hidden INTEGER NOT NULL DEFAULT 0,
|
|
343
|
+
is_archived INTEGER NOT NULL DEFAULT 0,
|
|
344
|
+
raw_json TEXT,
|
|
345
|
+
created_at TEXT NOT NULL,
|
|
346
|
+
updated_at TEXT NOT NULL,
|
|
347
|
+
PRIMARY KEY (profile, scope_thread_id)
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
CREATE TABLE IF NOT EXISTS thread_members (
|
|
351
|
+
profile TEXT NOT NULL,
|
|
352
|
+
scope_thread_id TEXT NOT NULL,
|
|
353
|
+
user_id TEXT NOT NULL,
|
|
354
|
+
display_name TEXT,
|
|
355
|
+
zalo_name TEXT,
|
|
356
|
+
avatar TEXT,
|
|
357
|
+
account_status INTEGER,
|
|
358
|
+
member_type INTEGER,
|
|
359
|
+
raw_json TEXT,
|
|
360
|
+
snapshot_at_ms INTEGER NOT NULL,
|
|
361
|
+
created_at TEXT NOT NULL,
|
|
362
|
+
updated_at TEXT NOT NULL,
|
|
363
|
+
PRIMARY KEY (profile, scope_thread_id, user_id)
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
CREATE TABLE IF NOT EXISTS friends (
|
|
367
|
+
profile TEXT NOT NULL,
|
|
368
|
+
user_id TEXT NOT NULL,
|
|
369
|
+
display_name TEXT,
|
|
370
|
+
zalo_name TEXT,
|
|
371
|
+
avatar TEXT,
|
|
372
|
+
account_status INTEGER,
|
|
373
|
+
raw_json TEXT,
|
|
374
|
+
created_at TEXT NOT NULL,
|
|
375
|
+
updated_at TEXT NOT NULL,
|
|
376
|
+
PRIMARY KEY (profile, user_id)
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
CREATE TABLE IF NOT EXISTS self_profiles (
|
|
380
|
+
profile TEXT NOT NULL,
|
|
381
|
+
user_id TEXT NOT NULL,
|
|
382
|
+
display_name TEXT,
|
|
383
|
+
info_json TEXT,
|
|
384
|
+
created_at TEXT NOT NULL,
|
|
385
|
+
updated_at TEXT NOT NULL,
|
|
386
|
+
PRIMARY KEY (profile)
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
390
|
+
profile TEXT NOT NULL,
|
|
391
|
+
message_uid TEXT NOT NULL,
|
|
392
|
+
scope_thread_id TEXT NOT NULL,
|
|
393
|
+
raw_thread_id TEXT NOT NULL,
|
|
394
|
+
thread_type TEXT NOT NULL,
|
|
395
|
+
msg_id TEXT,
|
|
396
|
+
cli_msg_id TEXT,
|
|
397
|
+
action_id TEXT,
|
|
398
|
+
sender_id TEXT,
|
|
399
|
+
sender_name TEXT,
|
|
400
|
+
to_id TEXT,
|
|
401
|
+
timestamp_ms INTEGER NOT NULL,
|
|
402
|
+
msg_type TEXT,
|
|
403
|
+
content_text TEXT,
|
|
404
|
+
content_json TEXT,
|
|
405
|
+
quote_msg_id TEXT,
|
|
406
|
+
quote_cli_msg_id TEXT,
|
|
407
|
+
quote_owner_id TEXT,
|
|
408
|
+
quote_text TEXT,
|
|
409
|
+
source TEXT NOT NULL,
|
|
410
|
+
raw_message_json TEXT,
|
|
411
|
+
raw_payload_json TEXT,
|
|
412
|
+
created_at TEXT NOT NULL,
|
|
413
|
+
updated_at TEXT NOT NULL,
|
|
414
|
+
PRIMARY KEY (profile, message_uid)
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
CREATE TABLE IF NOT EXISTS message_media (
|
|
418
|
+
profile TEXT NOT NULL,
|
|
419
|
+
message_uid TEXT NOT NULL,
|
|
420
|
+
item_index INTEGER NOT NULL,
|
|
421
|
+
media_kind TEXT,
|
|
422
|
+
media_url TEXT,
|
|
423
|
+
media_path TEXT,
|
|
424
|
+
media_type TEXT,
|
|
425
|
+
raw_json TEXT,
|
|
426
|
+
created_at TEXT NOT NULL,
|
|
427
|
+
updated_at TEXT NOT NULL,
|
|
428
|
+
PRIMARY KEY (profile, message_uid, item_index)
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
432
|
+
profile TEXT NOT NULL,
|
|
433
|
+
message_uid TEXT NOT NULL,
|
|
434
|
+
item_index INTEGER NOT NULL,
|
|
435
|
+
target_user_id TEXT NOT NULL,
|
|
436
|
+
pos INTEGER,
|
|
437
|
+
len INTEGER,
|
|
438
|
+
mention_type INTEGER,
|
|
439
|
+
raw_json TEXT,
|
|
440
|
+
created_at TEXT NOT NULL,
|
|
441
|
+
updated_at TEXT NOT NULL,
|
|
442
|
+
PRIMARY KEY (profile, message_uid, item_index)
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
446
|
+
profile TEXT NOT NULL,
|
|
447
|
+
scope TEXT NOT NULL,
|
|
448
|
+
scope_thread_id TEXT NOT NULL,
|
|
449
|
+
thread_type TEXT NOT NULL,
|
|
450
|
+
status TEXT NOT NULL,
|
|
451
|
+
completeness TEXT,
|
|
452
|
+
cursor TEXT,
|
|
453
|
+
last_sync_at TEXT,
|
|
454
|
+
error TEXT,
|
|
455
|
+
created_at TEXT NOT NULL,
|
|
456
|
+
updated_at TEXT NOT NULL,
|
|
457
|
+
PRIMARY KEY (profile, scope)
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread_time
|
|
461
|
+
ON messages (profile, scope_thread_id, timestamp_ms DESC);
|
|
462
|
+
CREATE INDEX IF NOT EXISTS idx_messages_msg_id
|
|
463
|
+
ON messages (profile, msg_id);
|
|
464
|
+
CREATE INDEX IF NOT EXISTS idx_messages_cli_msg_id
|
|
465
|
+
ON messages (profile, cli_msg_id);
|
|
466
|
+
CREATE INDEX IF NOT EXISTS idx_threads_type
|
|
467
|
+
ON threads (profile, thread_type, updated_at DESC);
|
|
468
|
+
CREATE INDEX IF NOT EXISTS idx_members_thread
|
|
469
|
+
ON thread_members (profile, scope_thread_id);
|
|
470
|
+
CREATE INDEX IF NOT EXISTS idx_friends_name
|
|
471
|
+
ON friends (profile, display_name, zalo_name, user_id);
|
|
472
|
+
`);
|
|
473
|
+
}
|
|
474
|
+
async function openDb(profile) {
|
|
475
|
+
const filename = await resolveDbPath(profile);
|
|
476
|
+
await fs2.mkdir(path2.dirname(filename), { recursive: true });
|
|
477
|
+
const db = await open({
|
|
478
|
+
filename,
|
|
479
|
+
driver: sqlite3.Database
|
|
480
|
+
});
|
|
481
|
+
await migrateDb(db);
|
|
482
|
+
return db;
|
|
483
|
+
}
|
|
484
|
+
async function getDb(profile) {
|
|
485
|
+
const existing = connections.get(profile);
|
|
486
|
+
if (existing) {
|
|
487
|
+
return existing;
|
|
488
|
+
}
|
|
489
|
+
const created = openDb(profile).catch((error) => {
|
|
490
|
+
connections.delete(profile);
|
|
491
|
+
throw error;
|
|
492
|
+
});
|
|
493
|
+
connections.set(profile, created);
|
|
494
|
+
return created;
|
|
495
|
+
}
|
|
496
|
+
async function closeDb(profile) {
|
|
497
|
+
const existing = connections.get(profile);
|
|
498
|
+
if (!existing) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
connections.delete(profile);
|
|
502
|
+
const db = await existing;
|
|
503
|
+
await db.close();
|
|
504
|
+
}
|
|
505
|
+
function resolveDmPeerId(params) {
|
|
506
|
+
const threadId = normalizeId(params.threadId);
|
|
507
|
+
const senderId = normalizeId(params.senderId);
|
|
508
|
+
const toId = normalizeId(params.toId);
|
|
509
|
+
const selfId = normalizeId(params.selfId);
|
|
510
|
+
if (selfId) {
|
|
511
|
+
if (senderId === selfId && toId && toId !== selfId) {
|
|
512
|
+
return toId;
|
|
513
|
+
}
|
|
514
|
+
if (toId === selfId && senderId && senderId !== selfId) {
|
|
515
|
+
return senderId;
|
|
516
|
+
}
|
|
517
|
+
if (threadId && threadId !== selfId) {
|
|
518
|
+
return threadId;
|
|
519
|
+
}
|
|
520
|
+
if (toId && toId !== selfId) {
|
|
521
|
+
return toId;
|
|
522
|
+
}
|
|
523
|
+
if (senderId && senderId !== selfId) {
|
|
524
|
+
return senderId;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (senderId && toId && senderId === threadId && toId !== senderId) {
|
|
528
|
+
return toId;
|
|
529
|
+
}
|
|
530
|
+
if (senderId && toId && toId === threadId && senderId !== toId) {
|
|
531
|
+
return senderId;
|
|
532
|
+
}
|
|
533
|
+
if (threadId) {
|
|
534
|
+
return threadId;
|
|
535
|
+
}
|
|
536
|
+
if (toId && toId !== senderId) {
|
|
537
|
+
return toId;
|
|
538
|
+
}
|
|
539
|
+
return senderId;
|
|
540
|
+
}
|
|
541
|
+
function resolveScopeThreadId(params) {
|
|
542
|
+
if (params.threadType === "group") {
|
|
543
|
+
return normalizeId(params.rawThreadId);
|
|
544
|
+
}
|
|
545
|
+
return resolveDmPeerId({
|
|
546
|
+
threadId: params.rawThreadId,
|
|
547
|
+
senderId: params.senderId,
|
|
548
|
+
toId: params.toId,
|
|
549
|
+
selfId: params.selfId
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
function toMessageUid(record) {
|
|
553
|
+
const scopeThreadId = normalizeId(record.scopeThreadId);
|
|
554
|
+
const msgId = normalizeId(record.msgId);
|
|
555
|
+
const cliMsgId = normalizeId(record.cliMsgId);
|
|
556
|
+
const actionId = normalizeId(record.actionId);
|
|
557
|
+
const timestamp = String(record.timestampMs || 0);
|
|
558
|
+
if (msgId) {
|
|
559
|
+
return `${scopeThreadId}:msg:${msgId}`;
|
|
560
|
+
}
|
|
561
|
+
if (cliMsgId) {
|
|
562
|
+
return `${scopeThreadId}:cli:${cliMsgId}`;
|
|
563
|
+
}
|
|
564
|
+
if (actionId) {
|
|
565
|
+
return `${scopeThreadId}:action:${actionId}`;
|
|
566
|
+
}
|
|
567
|
+
const hash = crypto.createHash("sha256").update(
|
|
568
|
+
JSON.stringify({
|
|
569
|
+
scopeThreadId,
|
|
570
|
+
rawThreadId: record.rawThreadId,
|
|
571
|
+
senderId: record.senderId,
|
|
572
|
+
toId: record.toId,
|
|
573
|
+
timestamp,
|
|
574
|
+
msgType: record.msgType,
|
|
575
|
+
contentText: record.contentText
|
|
576
|
+
})
|
|
577
|
+
).digest("hex").slice(0, 24);
|
|
578
|
+
return `${scopeThreadId}:hash:${hash}`;
|
|
579
|
+
}
|
|
580
|
+
async function withWriteQueue(profile, task) {
|
|
581
|
+
const prior = writeQueues.get(profile) ?? Promise.resolve();
|
|
582
|
+
const next = prior.catch(() => void 0).then(task);
|
|
583
|
+
writeQueues.set(profile, next);
|
|
584
|
+
try {
|
|
585
|
+
await next;
|
|
586
|
+
} finally {
|
|
587
|
+
if (writeQueues.get(profile) === next) {
|
|
588
|
+
writeQueues.delete(profile);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function enqueueDbWrite(profile, task) {
|
|
593
|
+
void withWriteQueue(profile, task).catch(() => {
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async function persistThread(record) {
|
|
597
|
+
const db = await getDb(record.profile);
|
|
598
|
+
const now = nowIso2();
|
|
599
|
+
await db.run(
|
|
600
|
+
`
|
|
601
|
+
INSERT INTO threads (
|
|
602
|
+
profile, scope_thread_id, raw_thread_id, thread_type, peer_id, title,
|
|
603
|
+
is_pinned, is_hidden, is_archived, raw_json, created_at, updated_at
|
|
604
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
605
|
+
ON CONFLICT(profile, scope_thread_id) DO UPDATE SET
|
|
606
|
+
raw_thread_id = excluded.raw_thread_id,
|
|
607
|
+
thread_type = excluded.thread_type,
|
|
608
|
+
peer_id = COALESCE(excluded.peer_id, threads.peer_id),
|
|
609
|
+
title = COALESCE(excluded.title, threads.title),
|
|
610
|
+
is_pinned = excluded.is_pinned,
|
|
611
|
+
is_hidden = excluded.is_hidden,
|
|
612
|
+
is_archived = excluded.is_archived,
|
|
613
|
+
raw_json = COALESCE(excluded.raw_json, threads.raw_json),
|
|
614
|
+
updated_at = excluded.updated_at
|
|
615
|
+
`,
|
|
616
|
+
[
|
|
617
|
+
record.profile,
|
|
618
|
+
record.scopeThreadId,
|
|
619
|
+
record.rawThreadId,
|
|
620
|
+
record.threadType,
|
|
621
|
+
record.peerId ?? null,
|
|
622
|
+
record.title ?? null,
|
|
623
|
+
record.isPinned ? 1 : 0,
|
|
624
|
+
record.isHidden ? 1 : 0,
|
|
625
|
+
record.isArchived ? 1 : 0,
|
|
626
|
+
record.rawJson ?? null,
|
|
627
|
+
now,
|
|
628
|
+
now
|
|
629
|
+
]
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
async function replaceThreadMembers(profile, scopeThreadId, members) {
|
|
633
|
+
const db = await getDb(profile);
|
|
634
|
+
const now = nowIso2();
|
|
635
|
+
await db.exec("BEGIN");
|
|
636
|
+
try {
|
|
637
|
+
await db.run(
|
|
638
|
+
`DELETE FROM thread_members WHERE profile = ? AND scope_thread_id = ?`,
|
|
639
|
+
[profile, scopeThreadId]
|
|
640
|
+
);
|
|
641
|
+
for (const member of members) {
|
|
642
|
+
await db.run(
|
|
643
|
+
`
|
|
644
|
+
INSERT INTO thread_members (
|
|
645
|
+
profile, scope_thread_id, user_id, display_name, zalo_name, avatar,
|
|
646
|
+
account_status, member_type, raw_json, snapshot_at_ms, created_at, updated_at
|
|
647
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
648
|
+
`,
|
|
649
|
+
[
|
|
650
|
+
member.profile,
|
|
651
|
+
member.scopeThreadId,
|
|
652
|
+
member.userId,
|
|
653
|
+
member.displayName ?? null,
|
|
654
|
+
member.zaloName ?? null,
|
|
655
|
+
member.avatar ?? null,
|
|
656
|
+
member.accountStatus ?? null,
|
|
657
|
+
member.memberType ?? null,
|
|
658
|
+
member.rawJson ?? null,
|
|
659
|
+
member.snapshotAtMs,
|
|
660
|
+
now,
|
|
661
|
+
now
|
|
662
|
+
]
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
await db.exec("COMMIT");
|
|
666
|
+
} catch (error) {
|
|
667
|
+
await db.exec("ROLLBACK");
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async function persistFriend(record) {
|
|
672
|
+
const db = await getDb(record.profile);
|
|
673
|
+
const now = nowIso2();
|
|
674
|
+
await db.run(
|
|
675
|
+
`
|
|
676
|
+
INSERT INTO friends (
|
|
677
|
+
profile, user_id, display_name, zalo_name, avatar,
|
|
678
|
+
account_status, raw_json, created_at, updated_at
|
|
679
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
680
|
+
ON CONFLICT(profile, user_id) DO UPDATE SET
|
|
681
|
+
display_name = COALESCE(excluded.display_name, friends.display_name),
|
|
682
|
+
zalo_name = COALESCE(excluded.zalo_name, friends.zalo_name),
|
|
683
|
+
avatar = COALESCE(excluded.avatar, friends.avatar),
|
|
684
|
+
account_status = COALESCE(excluded.account_status, friends.account_status),
|
|
685
|
+
raw_json = COALESCE(excluded.raw_json, friends.raw_json),
|
|
686
|
+
updated_at = excluded.updated_at
|
|
687
|
+
`,
|
|
688
|
+
[
|
|
689
|
+
record.profile,
|
|
690
|
+
record.userId,
|
|
691
|
+
record.displayName ?? null,
|
|
692
|
+
record.zaloName ?? null,
|
|
693
|
+
record.avatar ?? null,
|
|
694
|
+
record.accountStatus ?? null,
|
|
695
|
+
record.rawJson ?? null,
|
|
696
|
+
now,
|
|
697
|
+
now
|
|
698
|
+
]
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
async function persistSelfProfile(params) {
|
|
702
|
+
const db = await getDb(params.profile);
|
|
703
|
+
const now = nowIso2();
|
|
704
|
+
await db.run(
|
|
705
|
+
`
|
|
706
|
+
INSERT INTO self_profiles (
|
|
707
|
+
profile, user_id, display_name, info_json, created_at, updated_at
|
|
708
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
709
|
+
ON CONFLICT(profile) DO UPDATE SET
|
|
710
|
+
user_id = excluded.user_id,
|
|
711
|
+
display_name = COALESCE(excluded.display_name, self_profiles.display_name),
|
|
712
|
+
info_json = COALESCE(excluded.info_json, self_profiles.info_json),
|
|
713
|
+
updated_at = excluded.updated_at
|
|
714
|
+
`,
|
|
715
|
+
[
|
|
716
|
+
params.profile,
|
|
717
|
+
params.userId,
|
|
718
|
+
params.displayName ?? null,
|
|
719
|
+
params.infoJson ?? null,
|
|
720
|
+
now,
|
|
721
|
+
now
|
|
722
|
+
]
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
async function persistMessage(record) {
|
|
726
|
+
const db = await getDb(record.profile);
|
|
727
|
+
const now = nowIso2();
|
|
728
|
+
const messageUid = toMessageUid(record);
|
|
729
|
+
await db.exec("BEGIN");
|
|
730
|
+
try {
|
|
731
|
+
await persistThread({
|
|
732
|
+
profile: record.profile,
|
|
733
|
+
scopeThreadId: record.scopeThreadId,
|
|
734
|
+
rawThreadId: record.rawThreadId,
|
|
735
|
+
threadType: record.threadType,
|
|
736
|
+
peerId: record.peerId,
|
|
737
|
+
title: record.title
|
|
738
|
+
});
|
|
739
|
+
await db.run(
|
|
740
|
+
`
|
|
741
|
+
INSERT INTO messages (
|
|
742
|
+
profile, message_uid, scope_thread_id, raw_thread_id, thread_type,
|
|
743
|
+
msg_id, cli_msg_id, action_id, sender_id, sender_name, to_id,
|
|
744
|
+
timestamp_ms, msg_type, content_text, content_json,
|
|
745
|
+
quote_msg_id, quote_cli_msg_id, quote_owner_id, quote_text,
|
|
746
|
+
source, raw_message_json, raw_payload_json, created_at, updated_at
|
|
747
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
748
|
+
ON CONFLICT(profile, message_uid) DO UPDATE SET
|
|
749
|
+
scope_thread_id = excluded.scope_thread_id,
|
|
750
|
+
raw_thread_id = excluded.raw_thread_id,
|
|
751
|
+
thread_type = excluded.thread_type,
|
|
752
|
+
msg_id = COALESCE(excluded.msg_id, messages.msg_id),
|
|
753
|
+
cli_msg_id = COALESCE(excluded.cli_msg_id, messages.cli_msg_id),
|
|
754
|
+
action_id = COALESCE(excluded.action_id, messages.action_id),
|
|
755
|
+
sender_id = COALESCE(excluded.sender_id, messages.sender_id),
|
|
756
|
+
sender_name = COALESCE(excluded.sender_name, messages.sender_name),
|
|
757
|
+
to_id = COALESCE(excluded.to_id, messages.to_id),
|
|
758
|
+
timestamp_ms = excluded.timestamp_ms,
|
|
759
|
+
msg_type = COALESCE(excluded.msg_type, messages.msg_type),
|
|
760
|
+
content_text = COALESCE(excluded.content_text, messages.content_text),
|
|
761
|
+
content_json = COALESCE(excluded.content_json, messages.content_json),
|
|
762
|
+
quote_msg_id = COALESCE(excluded.quote_msg_id, messages.quote_msg_id),
|
|
763
|
+
quote_cli_msg_id = COALESCE(excluded.quote_cli_msg_id, messages.quote_cli_msg_id),
|
|
764
|
+
quote_owner_id = COALESCE(excluded.quote_owner_id, messages.quote_owner_id),
|
|
765
|
+
quote_text = COALESCE(excluded.quote_text, messages.quote_text),
|
|
766
|
+
source = excluded.source,
|
|
767
|
+
raw_message_json = COALESCE(excluded.raw_message_json, messages.raw_message_json),
|
|
768
|
+
raw_payload_json = COALESCE(excluded.raw_payload_json, messages.raw_payload_json),
|
|
769
|
+
updated_at = excluded.updated_at
|
|
770
|
+
`,
|
|
771
|
+
[
|
|
772
|
+
record.profile,
|
|
773
|
+
messageUid,
|
|
774
|
+
record.scopeThreadId,
|
|
775
|
+
record.rawThreadId,
|
|
776
|
+
record.threadType,
|
|
777
|
+
record.msgId ?? null,
|
|
778
|
+
record.cliMsgId ?? null,
|
|
779
|
+
record.actionId ?? null,
|
|
780
|
+
record.senderId ?? null,
|
|
781
|
+
record.senderName ?? null,
|
|
782
|
+
record.toId ?? null,
|
|
783
|
+
record.timestampMs,
|
|
784
|
+
record.msgType ?? null,
|
|
785
|
+
record.contentText ?? null,
|
|
786
|
+
record.contentJson ?? null,
|
|
787
|
+
record.quoteMsgId ?? null,
|
|
788
|
+
record.quoteCliMsgId ?? null,
|
|
789
|
+
record.quoteOwnerId ?? null,
|
|
790
|
+
record.quoteText ?? null,
|
|
791
|
+
record.source,
|
|
792
|
+
record.rawMessageJson ?? null,
|
|
793
|
+
record.rawPayloadJson ?? null,
|
|
794
|
+
now,
|
|
795
|
+
now
|
|
796
|
+
]
|
|
797
|
+
);
|
|
798
|
+
await db.run(
|
|
799
|
+
`DELETE FROM message_media WHERE profile = ? AND message_uid = ?`,
|
|
800
|
+
[record.profile, messageUid]
|
|
801
|
+
);
|
|
802
|
+
await db.run(
|
|
803
|
+
`DELETE FROM message_mentions WHERE profile = ? AND message_uid = ?`,
|
|
804
|
+
[record.profile, messageUid]
|
|
805
|
+
);
|
|
806
|
+
for (const [index, media] of (record.media ?? []).entries()) {
|
|
807
|
+
await db.run(
|
|
808
|
+
`
|
|
809
|
+
INSERT INTO message_media (
|
|
810
|
+
profile, message_uid, item_index, media_kind, media_url,
|
|
811
|
+
media_path, media_type, raw_json, created_at, updated_at
|
|
812
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
813
|
+
`,
|
|
814
|
+
[
|
|
815
|
+
record.profile,
|
|
816
|
+
messageUid,
|
|
817
|
+
index,
|
|
818
|
+
media.mediaKind ?? null,
|
|
819
|
+
media.mediaUrl ?? null,
|
|
820
|
+
media.mediaPath ?? null,
|
|
821
|
+
media.mediaType ?? null,
|
|
822
|
+
media.rawJson ?? null,
|
|
823
|
+
now,
|
|
824
|
+
now
|
|
825
|
+
]
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
for (const [index, mention] of (record.mentions ?? []).entries()) {
|
|
829
|
+
await db.run(
|
|
830
|
+
`
|
|
831
|
+
INSERT INTO message_mentions (
|
|
832
|
+
profile, message_uid, item_index, target_user_id, pos, len,
|
|
833
|
+
mention_type, raw_json, created_at, updated_at
|
|
834
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
835
|
+
`,
|
|
836
|
+
[
|
|
837
|
+
record.profile,
|
|
838
|
+
messageUid,
|
|
839
|
+
index,
|
|
840
|
+
mention.uid,
|
|
841
|
+
mention.pos ?? null,
|
|
842
|
+
mention.len ?? null,
|
|
843
|
+
mention.type ?? null,
|
|
844
|
+
mention.rawJson ?? null,
|
|
845
|
+
now,
|
|
846
|
+
now
|
|
847
|
+
]
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
await db.exec("COMMIT");
|
|
851
|
+
} catch (error) {
|
|
852
|
+
await db.exec("ROLLBACK");
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async function setSyncState(params) {
|
|
857
|
+
const db = await getDb(params.profile);
|
|
858
|
+
const now = nowIso2();
|
|
859
|
+
const scope = `${params.threadType}:${params.scopeThreadId}`;
|
|
860
|
+
await db.run(
|
|
861
|
+
`
|
|
862
|
+
INSERT INTO sync_state (
|
|
863
|
+
profile, scope, scope_thread_id, thread_type, status, completeness,
|
|
864
|
+
cursor, last_sync_at, error, created_at, updated_at
|
|
865
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
866
|
+
ON CONFLICT(profile, scope) DO UPDATE SET
|
|
867
|
+
status = excluded.status,
|
|
868
|
+
completeness = excluded.completeness,
|
|
869
|
+
cursor = COALESCE(excluded.cursor, sync_state.cursor),
|
|
870
|
+
last_sync_at = excluded.last_sync_at,
|
|
871
|
+
error = excluded.error,
|
|
872
|
+
updated_at = excluded.updated_at
|
|
873
|
+
`,
|
|
874
|
+
[
|
|
875
|
+
params.profile,
|
|
876
|
+
scope,
|
|
877
|
+
params.scopeThreadId,
|
|
878
|
+
params.threadType,
|
|
879
|
+
params.status,
|
|
880
|
+
params.completeness ?? null,
|
|
881
|
+
params.cursor ?? null,
|
|
882
|
+
now,
|
|
883
|
+
params.error ?? null,
|
|
884
|
+
now,
|
|
885
|
+
now
|
|
886
|
+
]
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
async function listRecentMessages(params) {
|
|
890
|
+
const db = await getDb(params.profile);
|
|
891
|
+
const rows = await db.all(
|
|
892
|
+
`
|
|
893
|
+
SELECT
|
|
894
|
+
msg_id,
|
|
895
|
+
cli_msg_id,
|
|
896
|
+
scope_thread_id,
|
|
897
|
+
thread_type,
|
|
898
|
+
sender_id,
|
|
899
|
+
sender_name,
|
|
900
|
+
timestamp_ms,
|
|
901
|
+
msg_type,
|
|
902
|
+
content_text,
|
|
903
|
+
content_json
|
|
904
|
+
FROM messages
|
|
905
|
+
WHERE profile = ? AND scope_thread_id = ? AND thread_type = ?
|
|
906
|
+
ORDER BY timestamp_ms DESC, COALESCE(msg_id, ''), COALESCE(cli_msg_id, '')
|
|
907
|
+
LIMIT ?
|
|
908
|
+
`,
|
|
909
|
+
[params.profile, params.threadId, params.threadType, params.count]
|
|
910
|
+
);
|
|
911
|
+
return rows.map((row) => ({
|
|
912
|
+
msgId: row.msg_id ?? "",
|
|
913
|
+
cliMsgId: row.cli_msg_id ?? "",
|
|
914
|
+
threadId: row.scope_thread_id,
|
|
915
|
+
threadType: row.thread_type,
|
|
916
|
+
senderId: row.sender_id ?? "",
|
|
917
|
+
senderName: row.sender_name ?? "",
|
|
918
|
+
ts: String(row.timestamp_ms),
|
|
919
|
+
msgType: row.msg_type ?? "",
|
|
920
|
+
undo: {
|
|
921
|
+
msgId: row.msg_id ?? "",
|
|
922
|
+
cliMsgId: row.cli_msg_id ?? "",
|
|
923
|
+
threadId: row.scope_thread_id,
|
|
924
|
+
group: row.thread_type === "group"
|
|
925
|
+
},
|
|
926
|
+
content: row.content_text ?? row.content_json ?? ""
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
async function listMessages(params) {
|
|
930
|
+
const db = await getDb(params.profile);
|
|
931
|
+
const order = params.newestFirst ? "DESC" : "ASC";
|
|
932
|
+
const limit = Number.isFinite(params.limit) ? Math.max(Math.trunc(params.limit), 1) : null;
|
|
933
|
+
const rows = await db.all(
|
|
934
|
+
`
|
|
935
|
+
SELECT
|
|
936
|
+
m.raw_thread_id,
|
|
937
|
+
m.thread_type,
|
|
938
|
+
m.msg_id,
|
|
939
|
+
m.cli_msg_id,
|
|
940
|
+
m.sender_id,
|
|
941
|
+
COALESCE(
|
|
942
|
+
NULLIF(m.sender_name, ''),
|
|
943
|
+
NULLIF(tm.display_name, ''),
|
|
944
|
+
NULLIF(tm.zalo_name, ''),
|
|
945
|
+
NULLIF(f.display_name, ''),
|
|
946
|
+
NULLIF(f.zalo_name, '')
|
|
947
|
+
) AS sender_name,
|
|
948
|
+
m.to_id,
|
|
949
|
+
m.timestamp_ms,
|
|
950
|
+
m.msg_type,
|
|
951
|
+
m.content_text,
|
|
952
|
+
m.content_json,
|
|
953
|
+
m.quote_msg_id,
|
|
954
|
+
m.quote_cli_msg_id,
|
|
955
|
+
m.quote_owner_id,
|
|
956
|
+
m.quote_text,
|
|
957
|
+
m.source
|
|
958
|
+
FROM messages m
|
|
959
|
+
LEFT JOIN thread_members tm
|
|
960
|
+
ON tm.profile = m.profile
|
|
961
|
+
AND tm.scope_thread_id = m.scope_thread_id
|
|
962
|
+
AND tm.user_id = m.sender_id
|
|
963
|
+
LEFT JOIN friends f
|
|
964
|
+
ON f.profile = m.profile
|
|
965
|
+
AND f.user_id = m.sender_id
|
|
966
|
+
WHERE m.profile = ?
|
|
967
|
+
AND m.scope_thread_id = ?
|
|
968
|
+
AND m.thread_type = ?
|
|
969
|
+
AND (? IS NULL OR timestamp_ms >= ?)
|
|
970
|
+
AND (? IS NULL OR timestamp_ms < ?)
|
|
971
|
+
ORDER BY m.timestamp_ms ${order}, COALESCE(m.msg_id, ''), COALESCE(m.cli_msg_id, '')
|
|
972
|
+
${limit ? "LIMIT ?" : ""}
|
|
973
|
+
`,
|
|
974
|
+
limit ? [
|
|
975
|
+
params.profile,
|
|
976
|
+
params.threadId,
|
|
977
|
+
params.threadType,
|
|
978
|
+
params.sinceMs ?? null,
|
|
979
|
+
params.sinceMs ?? null,
|
|
980
|
+
params.untilMs ?? null,
|
|
981
|
+
params.untilMs ?? null,
|
|
982
|
+
limit
|
|
983
|
+
] : [
|
|
984
|
+
params.profile,
|
|
985
|
+
params.threadId,
|
|
986
|
+
params.threadType,
|
|
987
|
+
params.sinceMs ?? null,
|
|
988
|
+
params.sinceMs ?? null,
|
|
989
|
+
params.untilMs ?? null,
|
|
990
|
+
params.untilMs ?? null
|
|
991
|
+
]
|
|
992
|
+
);
|
|
993
|
+
return rows.map((row) => ({
|
|
994
|
+
msgId: row.msg_id ?? "",
|
|
995
|
+
cliMsgId: row.cli_msg_id ?? "",
|
|
996
|
+
threadId: params.threadId,
|
|
997
|
+
threadType: row.thread_type,
|
|
998
|
+
senderId: row.sender_id ?? "",
|
|
999
|
+
senderName: row.sender_name ?? "",
|
|
1000
|
+
ts: String(row.timestamp_ms),
|
|
1001
|
+
timestampMs: row.timestamp_ms,
|
|
1002
|
+
msgType: row.msg_type ?? "",
|
|
1003
|
+
undo: {
|
|
1004
|
+
msgId: row.msg_id ?? "",
|
|
1005
|
+
cliMsgId: row.cli_msg_id ?? "",
|
|
1006
|
+
threadId: params.threadId,
|
|
1007
|
+
group: row.thread_type === "group"
|
|
1008
|
+
},
|
|
1009
|
+
content: row.content_text ?? row.content_json ?? "",
|
|
1010
|
+
rawThreadId: row.raw_thread_id,
|
|
1011
|
+
toId: row.to_id ?? void 0,
|
|
1012
|
+
quoteMsgId: row.quote_msg_id ?? void 0,
|
|
1013
|
+
quoteCliMsgId: row.quote_cli_msg_id ?? void 0,
|
|
1014
|
+
quoteOwnerId: row.quote_owner_id ?? void 0,
|
|
1015
|
+
quoteText: row.quote_text ?? void 0,
|
|
1016
|
+
source: row.source
|
|
1017
|
+
}));
|
|
1018
|
+
}
|
|
1019
|
+
async function getMessageById(params) {
|
|
1020
|
+
const db = await getDb(params.profile);
|
|
1021
|
+
const row = await db.get(
|
|
1022
|
+
`
|
|
1023
|
+
SELECT *
|
|
1024
|
+
FROM messages
|
|
1025
|
+
WHERE profile = ?
|
|
1026
|
+
AND (
|
|
1027
|
+
msg_id = ?
|
|
1028
|
+
OR cli_msg_id = ?
|
|
1029
|
+
OR message_uid = ?
|
|
1030
|
+
)
|
|
1031
|
+
ORDER BY timestamp_ms DESC
|
|
1032
|
+
LIMIT 1
|
|
1033
|
+
`,
|
|
1034
|
+
[params.profile, params.id, params.id, params.id]
|
|
1035
|
+
);
|
|
1036
|
+
if (!row) {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
threadId: row.scope_thread_id,
|
|
1041
|
+
rawThreadId: row.raw_thread_id,
|
|
1042
|
+
threadType: row.thread_type,
|
|
1043
|
+
msgId: row.msg_id ?? void 0,
|
|
1044
|
+
cliMsgId: row.cli_msg_id ?? void 0,
|
|
1045
|
+
actionId: row.action_id ?? void 0,
|
|
1046
|
+
senderId: row.sender_id ?? void 0,
|
|
1047
|
+
senderName: row.sender_name ?? void 0,
|
|
1048
|
+
toId: row.to_id ?? void 0,
|
|
1049
|
+
timestampMs: row.timestamp_ms,
|
|
1050
|
+
msgType: row.msg_type ?? void 0,
|
|
1051
|
+
content: row.content_text ?? void 0,
|
|
1052
|
+
contentJson: row.content_json ?? void 0,
|
|
1053
|
+
quoteMsgId: row.quote_msg_id ?? void 0,
|
|
1054
|
+
quoteCliMsgId: row.quote_cli_msg_id ?? void 0,
|
|
1055
|
+
quoteOwnerId: row.quote_owner_id ?? void 0,
|
|
1056
|
+
quoteText: row.quote_text ?? void 0,
|
|
1057
|
+
source: row.source,
|
|
1058
|
+
rawMessage: row.raw_message_json ? JSON.parse(row.raw_message_json) : void 0,
|
|
1059
|
+
rawPayload: row.raw_payload_json ? JSON.parse(row.raw_payload_json) : void 0
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
async function listThreads(params) {
|
|
1063
|
+
const db = await getDb(params.profile);
|
|
1064
|
+
const rows = await db.all(
|
|
1065
|
+
`
|
|
1066
|
+
SELECT
|
|
1067
|
+
t.scope_thread_id,
|
|
1068
|
+
t.raw_thread_id,
|
|
1069
|
+
t.thread_type,
|
|
1070
|
+
t.title,
|
|
1071
|
+
t.peer_id,
|
|
1072
|
+
t.is_pinned,
|
|
1073
|
+
t.is_hidden,
|
|
1074
|
+
t.is_archived,
|
|
1075
|
+
COUNT(m.message_uid) AS message_count,
|
|
1076
|
+
MIN(m.timestamp_ms) AS first_message_at_ms,
|
|
1077
|
+
MAX(m.timestamp_ms) AS last_message_at_ms,
|
|
1078
|
+
(
|
|
1079
|
+
SELECT COUNT(*)
|
|
1080
|
+
FROM thread_members tm
|
|
1081
|
+
WHERE tm.profile = t.profile AND tm.scope_thread_id = t.scope_thread_id
|
|
1082
|
+
) AS member_count
|
|
1083
|
+
FROM threads t
|
|
1084
|
+
LEFT JOIN messages m
|
|
1085
|
+
ON m.profile = t.profile AND m.scope_thread_id = t.scope_thread_id
|
|
1086
|
+
WHERE t.profile = ?
|
|
1087
|
+
AND (? IS NULL OR t.thread_type = ?)
|
|
1088
|
+
GROUP BY
|
|
1089
|
+
t.scope_thread_id, t.raw_thread_id, t.thread_type, t.title, t.peer_id,
|
|
1090
|
+
t.is_pinned, t.is_hidden, t.is_archived
|
|
1091
|
+
ORDER BY COALESCE(MAX(m.timestamp_ms), 0) DESC, t.scope_thread_id
|
|
1092
|
+
`,
|
|
1093
|
+
[params.profile, params.threadType ?? null, params.threadType ?? null]
|
|
1094
|
+
);
|
|
1095
|
+
return rows.map((row) => ({
|
|
1096
|
+
threadId: row.scope_thread_id,
|
|
1097
|
+
rawThreadId: row.raw_thread_id,
|
|
1098
|
+
threadType: row.thread_type,
|
|
1099
|
+
title: row.title ?? void 0,
|
|
1100
|
+
peerId: row.peer_id ?? void 0,
|
|
1101
|
+
messageCount: row.message_count,
|
|
1102
|
+
firstMessageAtMs: row.first_message_at_ms ?? void 0,
|
|
1103
|
+
lastMessageAtMs: row.last_message_at_ms ?? void 0,
|
|
1104
|
+
memberCount: row.member_count,
|
|
1105
|
+
isPinned: row.is_pinned === 1,
|
|
1106
|
+
isHidden: row.is_hidden === 1,
|
|
1107
|
+
isArchived: row.is_archived === 1
|
|
1108
|
+
}));
|
|
1109
|
+
}
|
|
1110
|
+
async function listGroups(profile) {
|
|
1111
|
+
return listThreads({ profile, threadType: "group" });
|
|
1112
|
+
}
|
|
1113
|
+
async function getThreadInfo(params) {
|
|
1114
|
+
const db = await getDb(params.profile);
|
|
1115
|
+
const row = await db.get(
|
|
1116
|
+
`
|
|
1117
|
+
SELECT
|
|
1118
|
+
t.scope_thread_id,
|
|
1119
|
+
t.raw_thread_id,
|
|
1120
|
+
t.thread_type,
|
|
1121
|
+
t.title,
|
|
1122
|
+
t.peer_id,
|
|
1123
|
+
t.is_pinned,
|
|
1124
|
+
t.is_hidden,
|
|
1125
|
+
t.is_archived,
|
|
1126
|
+
t.raw_json,
|
|
1127
|
+
COUNT(m.message_uid) AS message_count,
|
|
1128
|
+
MIN(m.timestamp_ms) AS first_message_at_ms,
|
|
1129
|
+
MAX(m.timestamp_ms) AS last_message_at_ms,
|
|
1130
|
+
(
|
|
1131
|
+
SELECT COUNT(*)
|
|
1132
|
+
FROM thread_members tm
|
|
1133
|
+
WHERE tm.profile = t.profile AND tm.scope_thread_id = t.scope_thread_id
|
|
1134
|
+
) AS member_count
|
|
1135
|
+
FROM threads t
|
|
1136
|
+
LEFT JOIN messages m
|
|
1137
|
+
ON m.profile = t.profile AND m.scope_thread_id = t.scope_thread_id
|
|
1138
|
+
WHERE t.profile = ?
|
|
1139
|
+
AND (? IS NULL OR t.thread_type = ?)
|
|
1140
|
+
AND (t.scope_thread_id = ? OR t.raw_thread_id = ?)
|
|
1141
|
+
GROUP BY
|
|
1142
|
+
t.scope_thread_id, t.raw_thread_id, t.thread_type, t.title, t.peer_id,
|
|
1143
|
+
t.is_pinned, t.is_hidden, t.is_archived, t.raw_json
|
|
1144
|
+
ORDER BY
|
|
1145
|
+
CASE WHEN t.scope_thread_id = ? THEN 0 ELSE 1 END,
|
|
1146
|
+
COALESCE(MAX(m.timestamp_ms), 0) DESC
|
|
1147
|
+
LIMIT 1
|
|
1148
|
+
`,
|
|
1149
|
+
[
|
|
1150
|
+
params.profile,
|
|
1151
|
+
params.threadType ?? null,
|
|
1152
|
+
params.threadType ?? null,
|
|
1153
|
+
params.threadId,
|
|
1154
|
+
params.threadId,
|
|
1155
|
+
params.threadId
|
|
1156
|
+
]
|
|
1157
|
+
);
|
|
1158
|
+
if (!row) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
threadId: row.scope_thread_id,
|
|
1163
|
+
rawThreadId: row.raw_thread_id,
|
|
1164
|
+
threadType: row.thread_type,
|
|
1165
|
+
title: row.title ?? void 0,
|
|
1166
|
+
peerId: row.peer_id ?? void 0,
|
|
1167
|
+
messageCount: row.message_count,
|
|
1168
|
+
firstMessageAtMs: row.first_message_at_ms ?? void 0,
|
|
1169
|
+
lastMessageAtMs: row.last_message_at_ms ?? void 0,
|
|
1170
|
+
memberCount: row.member_count,
|
|
1171
|
+
isPinned: row.is_pinned === 1,
|
|
1172
|
+
isHidden: row.is_hidden === 1,
|
|
1173
|
+
isArchived: row.is_archived === 1,
|
|
1174
|
+
raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
async function listFriends(profile) {
|
|
1178
|
+
const db = await getDb(profile);
|
|
1179
|
+
const rows = await db.all(
|
|
1180
|
+
`
|
|
1181
|
+
SELECT
|
|
1182
|
+
f.user_id,
|
|
1183
|
+
f.display_name,
|
|
1184
|
+
f.zalo_name,
|
|
1185
|
+
f.avatar,
|
|
1186
|
+
f.account_status,
|
|
1187
|
+
t.scope_thread_id AS chat_id,
|
|
1188
|
+
t.title,
|
|
1189
|
+
COUNT(m.message_uid) AS message_count,
|
|
1190
|
+
MAX(m.timestamp_ms) AS last_message_at_ms
|
|
1191
|
+
FROM friends f
|
|
1192
|
+
LEFT JOIN threads t
|
|
1193
|
+
ON t.profile = f.profile
|
|
1194
|
+
AND t.thread_type = 'user'
|
|
1195
|
+
AND (t.peer_id = f.user_id OR t.scope_thread_id = f.user_id OR t.raw_thread_id = f.user_id)
|
|
1196
|
+
LEFT JOIN messages m
|
|
1197
|
+
ON m.profile = t.profile
|
|
1198
|
+
AND m.scope_thread_id = t.scope_thread_id
|
|
1199
|
+
WHERE f.profile = ?
|
|
1200
|
+
GROUP BY
|
|
1201
|
+
f.user_id, f.display_name, f.zalo_name, f.avatar, f.account_status, t.scope_thread_id, t.title
|
|
1202
|
+
ORDER BY COALESCE(f.display_name, f.zalo_name, f.user_id), f.user_id
|
|
1203
|
+
`,
|
|
1204
|
+
[profile]
|
|
1205
|
+
);
|
|
1206
|
+
return rows.map((row) => ({
|
|
1207
|
+
userId: row.user_id,
|
|
1208
|
+
displayName: row.display_name ?? void 0,
|
|
1209
|
+
zaloName: row.zalo_name ?? void 0,
|
|
1210
|
+
avatar: row.avatar ?? void 0,
|
|
1211
|
+
accountStatus: row.account_status ?? void 0,
|
|
1212
|
+
title: row.title ?? void 0,
|
|
1213
|
+
chatId: row.chat_id ?? row.user_id,
|
|
1214
|
+
messageCount: row.message_count,
|
|
1215
|
+
lastMessageAtMs: row.last_message_at_ms ?? void 0
|
|
1216
|
+
}));
|
|
1217
|
+
}
|
|
1218
|
+
async function getFriendInfo(params) {
|
|
1219
|
+
const db = await getDb(params.profile);
|
|
1220
|
+
const row = await db.get(
|
|
1221
|
+
`
|
|
1222
|
+
SELECT
|
|
1223
|
+
f.user_id,
|
|
1224
|
+
f.display_name,
|
|
1225
|
+
f.zalo_name,
|
|
1226
|
+
f.avatar,
|
|
1227
|
+
f.account_status,
|
|
1228
|
+
f.raw_json,
|
|
1229
|
+
t.scope_thread_id AS chat_id,
|
|
1230
|
+
t.title,
|
|
1231
|
+
COUNT(m.message_uid) AS message_count,
|
|
1232
|
+
MAX(m.timestamp_ms) AS last_message_at_ms
|
|
1233
|
+
FROM friends f
|
|
1234
|
+
LEFT JOIN threads t
|
|
1235
|
+
ON t.profile = f.profile
|
|
1236
|
+
AND t.thread_type = 'user'
|
|
1237
|
+
AND (t.peer_id = f.user_id OR t.scope_thread_id = f.user_id OR t.raw_thread_id = f.user_id)
|
|
1238
|
+
LEFT JOIN messages m
|
|
1239
|
+
ON m.profile = t.profile
|
|
1240
|
+
AND m.scope_thread_id = t.scope_thread_id
|
|
1241
|
+
WHERE f.profile = ?
|
|
1242
|
+
AND f.user_id = ?
|
|
1243
|
+
GROUP BY
|
|
1244
|
+
f.user_id, f.display_name, f.zalo_name, f.avatar, f.account_status,
|
|
1245
|
+
f.raw_json, t.scope_thread_id, t.title
|
|
1246
|
+
LIMIT 1
|
|
1247
|
+
`,
|
|
1248
|
+
[params.profile, params.userId]
|
|
1249
|
+
);
|
|
1250
|
+
if (!row) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
return {
|
|
1254
|
+
userId: row.user_id,
|
|
1255
|
+
displayName: row.display_name ?? void 0,
|
|
1256
|
+
zaloName: row.zalo_name ?? void 0,
|
|
1257
|
+
avatar: row.avatar ?? void 0,
|
|
1258
|
+
accountStatus: row.account_status ?? void 0,
|
|
1259
|
+
title: row.title ?? void 0,
|
|
1260
|
+
chatId: row.chat_id ?? row.user_id,
|
|
1261
|
+
messageCount: row.message_count,
|
|
1262
|
+
lastMessageAtMs: row.last_message_at_ms ?? void 0,
|
|
1263
|
+
raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
async function findFriends(params) {
|
|
1267
|
+
const query = normalizeSearchText(params.query);
|
|
1268
|
+
if (!query) {
|
|
1269
|
+
return [];
|
|
1270
|
+
}
|
|
1271
|
+
const rows = await listFriends(params.profile);
|
|
1272
|
+
return rows.filter((row) => {
|
|
1273
|
+
const haystacks = [
|
|
1274
|
+
row.userId,
|
|
1275
|
+
row.displayName ?? "",
|
|
1276
|
+
row.zaloName ?? "",
|
|
1277
|
+
row.title ?? ""
|
|
1278
|
+
];
|
|
1279
|
+
return haystacks.some((value) => matchesSearchPattern(value, query));
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
async function listChats(profile) {
|
|
1283
|
+
return listThreads({ profile });
|
|
1284
|
+
}
|
|
1285
|
+
async function getSelfProfile(profile) {
|
|
1286
|
+
const db = await getDb(profile);
|
|
1287
|
+
const row = await db.get(
|
|
1288
|
+
`
|
|
1289
|
+
SELECT user_id, display_name, info_json
|
|
1290
|
+
FROM self_profiles
|
|
1291
|
+
WHERE profile = ?
|
|
1292
|
+
LIMIT 1
|
|
1293
|
+
`,
|
|
1294
|
+
[profile]
|
|
1295
|
+
);
|
|
1296
|
+
if (!row) {
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
userId: row.user_id,
|
|
1301
|
+
displayName: row.display_name ?? void 0,
|
|
1302
|
+
info: row.info_json ? JSON.parse(row.info_json) : void 0
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
async function listThreadMembers(params) {
|
|
1306
|
+
const db = await getDb(params.profile);
|
|
1307
|
+
const rows = await db.all(
|
|
1308
|
+
`
|
|
1309
|
+
SELECT
|
|
1310
|
+
user_id, display_name, zalo_name, avatar, account_status, member_type, snapshot_at_ms
|
|
1311
|
+
FROM thread_members
|
|
1312
|
+
WHERE profile = ? AND scope_thread_id = ?
|
|
1313
|
+
ORDER BY COALESCE(display_name, zalo_name, user_id), user_id
|
|
1314
|
+
`,
|
|
1315
|
+
[params.profile, params.threadId]
|
|
1316
|
+
);
|
|
1317
|
+
return rows.map((row) => ({
|
|
1318
|
+
userId: row.user_id,
|
|
1319
|
+
displayName: row.display_name ?? void 0,
|
|
1320
|
+
zaloName: row.zalo_name ?? void 0,
|
|
1321
|
+
avatar: row.avatar ?? void 0,
|
|
1322
|
+
accountStatus: row.account_status ?? void 0,
|
|
1323
|
+
type: row.member_type ?? void 0,
|
|
1324
|
+
snapshotAtMs: row.snapshot_at_ms
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
async function getDbStatus(profile) {
|
|
1328
|
+
const filename = await resolveDbPath(profile);
|
|
1329
|
+
const exists = await fs2.access(filename).then(() => true).catch(() => false);
|
|
1330
|
+
const config = await readDbConfig(profile);
|
|
1331
|
+
if (!exists) {
|
|
1332
|
+
return {
|
|
1333
|
+
enabled: config.enabled,
|
|
1334
|
+
path: filename,
|
|
1335
|
+
exists: false,
|
|
1336
|
+
messageCount: 0,
|
|
1337
|
+
threadCount: 0,
|
|
1338
|
+
groupCount: 0,
|
|
1339
|
+
userCount: 0,
|
|
1340
|
+
updatedAt: config.updatedAt
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
const db = await getDb(profile);
|
|
1344
|
+
const row = await db.get(`
|
|
1345
|
+
SELECT
|
|
1346
|
+
(SELECT COUNT(*) FROM messages WHERE profile = ?) AS message_count,
|
|
1347
|
+
(SELECT COUNT(*) FROM threads WHERE profile = ?) AS thread_count,
|
|
1348
|
+
(SELECT COUNT(*) FROM threads WHERE profile = ? AND thread_type = 'group') AS group_count,
|
|
1349
|
+
(SELECT COUNT(*) FROM threads WHERE profile = ? AND thread_type = 'user') AS user_count,
|
|
1350
|
+
(SELECT MAX(timestamp_ms) FROM messages WHERE profile = ?) AS last_message_at_ms,
|
|
1351
|
+
(
|
|
1352
|
+
SELECT MAX(updated_at)
|
|
1353
|
+
FROM (
|
|
1354
|
+
SELECT updated_at FROM messages WHERE profile = ?
|
|
1355
|
+
UNION ALL
|
|
1356
|
+
SELECT updated_at FROM threads WHERE profile = ?
|
|
1357
|
+
)
|
|
1358
|
+
) AS last_updated_at
|
|
1359
|
+
`, [profile, profile, profile, profile, profile, profile, profile]);
|
|
1360
|
+
return {
|
|
1361
|
+
enabled: config.enabled,
|
|
1362
|
+
path: filename,
|
|
1363
|
+
exists: true,
|
|
1364
|
+
messageCount: row?.message_count ?? 0,
|
|
1365
|
+
threadCount: row?.thread_count ?? 0,
|
|
1366
|
+
groupCount: row?.group_count ?? 0,
|
|
1367
|
+
userCount: row?.user_count ?? 0,
|
|
1368
|
+
lastMessageAtMs: row?.last_message_at_ms ?? void 0,
|
|
1369
|
+
updatedAt: row?.last_updated_at ?? config.updatedAt
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
async function listSyncState(params) {
|
|
1373
|
+
const filename = await resolveDbPath(params.profile);
|
|
1374
|
+
const exists = await fs2.access(filename).then(() => true).catch(() => false);
|
|
1375
|
+
if (!exists) {
|
|
1376
|
+
return [];
|
|
1377
|
+
}
|
|
1378
|
+
const db = await getDb(params.profile);
|
|
1379
|
+
const rows = await db.all(
|
|
1380
|
+
`
|
|
1381
|
+
SELECT scope, scope_thread_id, thread_type, status, cursor, completeness, last_sync_at, error
|
|
1382
|
+
FROM sync_state
|
|
1383
|
+
WHERE profile = ? AND (? IS NULL OR thread_type = ?)
|
|
1384
|
+
ORDER BY COALESCE(last_sync_at, ''), scope
|
|
1385
|
+
`,
|
|
1386
|
+
[params.profile, params.threadType ?? null, params.threadType ?? null]
|
|
1387
|
+
);
|
|
1388
|
+
return rows.map((row) => ({
|
|
1389
|
+
scope: row.scope,
|
|
1390
|
+
scopeThreadId: row.scope_thread_id,
|
|
1391
|
+
threadType: row.thread_type,
|
|
1392
|
+
status: row.status,
|
|
1393
|
+
cursor: row.cursor ?? void 0,
|
|
1394
|
+
completeness: row.completeness ?? void 0,
|
|
1395
|
+
lastSyncAt: row.last_sync_at ?? void 0,
|
|
1396
|
+
error: row.error ?? void 0
|
|
1397
|
+
}));
|
|
1398
|
+
}
|
|
1399
|
+
function normalizeInboundListenRecord(params) {
|
|
1400
|
+
const scopeThreadId = resolveScopeThreadId({
|
|
1401
|
+
threadType: params.threadType,
|
|
1402
|
+
rawThreadId: params.rawThreadId,
|
|
1403
|
+
senderId: params.senderId,
|
|
1404
|
+
toId: params.toId,
|
|
1405
|
+
selfId: params.selfId
|
|
1406
|
+
});
|
|
1407
|
+
return {
|
|
1408
|
+
profile: params.profile,
|
|
1409
|
+
scopeThreadId,
|
|
1410
|
+
rawThreadId: params.rawThreadId,
|
|
1411
|
+
threadType: params.threadType,
|
|
1412
|
+
peerId: params.threadType === "user" ? scopeThreadId : void 0,
|
|
1413
|
+
title: params.title,
|
|
1414
|
+
msgId: normalizeOptionalText(params.msgId),
|
|
1415
|
+
cliMsgId: normalizeOptionalText(params.cliMsgId),
|
|
1416
|
+
actionId: normalizeOptionalText(params.actionId),
|
|
1417
|
+
senderId: normalizeOptionalText(params.senderId),
|
|
1418
|
+
senderName: normalizeOptionalText(params.senderName),
|
|
1419
|
+
toId: normalizeOptionalText(params.toId),
|
|
1420
|
+
timestampMs: params.timestampMs,
|
|
1421
|
+
msgType: normalizeOptionalText(params.msgType),
|
|
1422
|
+
contentText: params.contentText,
|
|
1423
|
+
contentJson: params.contentJson,
|
|
1424
|
+
quoteMsgId: normalizeOptionalText(params.quoteMsgId),
|
|
1425
|
+
quoteCliMsgId: normalizeOptionalText(params.quoteCliMsgId),
|
|
1426
|
+
quoteOwnerId: normalizeOptionalText(params.quoteOwnerId),
|
|
1427
|
+
quoteText: params.quoteText,
|
|
1428
|
+
media: params.media,
|
|
1429
|
+
mentions: params.mentions,
|
|
1430
|
+
source: params.source,
|
|
1431
|
+
rawMessageJson: safeJsonStringify(params.rawMessage),
|
|
1432
|
+
rawPayloadJson: safeJsonStringify(params.rawPayload)
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/lib/client.ts
|
|
1437
|
+
import fs3 from "fs/promises";
|
|
1438
|
+
import { spawn } from "child_process";
|
|
1439
|
+
import path3 from "path";
|
|
217
1440
|
import { imageSize } from "image-size";
|
|
218
1441
|
import * as qrcodeTerminal from "qrcode-terminal";
|
|
219
1442
|
import {
|
|
@@ -248,7 +1471,7 @@ function renderInlineQrPngIfSupported(pngBase64, filePath) {
|
|
|
248
1471
|
if (debug) console.error("[openzca] QR render mode: iterm");
|
|
249
1472
|
const widthEnv = Number.parseInt(process.env.OPENZCA_QR_WIDTH ?? "", 10);
|
|
250
1473
|
const widthCells = Number.isFinite(widthEnv) && widthEnv >= 16 && widthEnv <= 80 ? widthEnv : 34;
|
|
251
|
-
const encodedName = Buffer.from(
|
|
1474
|
+
const encodedName = Buffer.from(path3.basename(filePath)).toString("base64");
|
|
252
1475
|
const osc1337 = `\x1B]1337;File=name=${encodedName};inline=1;width=${widthCells};preserveAspectRatio=1:${pngBase64}\x07`;
|
|
253
1476
|
process.stdout.write(`${osc1337}
|
|
254
1477
|
`);
|
|
@@ -321,7 +1544,7 @@ function tryOpenFile(filePath) {
|
|
|
321
1544
|
}
|
|
322
1545
|
}
|
|
323
1546
|
async function imageMetadataGetter(filePath) {
|
|
324
|
-
const data = await
|
|
1547
|
+
const data = await fs3.readFile(filePath);
|
|
325
1548
|
const info = imageSize(data);
|
|
326
1549
|
if (!info.width || !info.height) {
|
|
327
1550
|
throw new Error(`Cannot read image size: ${filePath}`);
|
|
@@ -376,7 +1599,7 @@ async function loginWithQrAndPersist(profileName, qrPath, opts) {
|
|
|
376
1599
|
console.log("\nScan this QR in your Zalo app:\n");
|
|
377
1600
|
const targetPath = qrPath ?? "qr.png";
|
|
378
1601
|
await event.actions.saveToFile(targetPath);
|
|
379
|
-
const absolutePath =
|
|
1602
|
+
const absolutePath = path3.resolve(targetPath);
|
|
380
1603
|
const rendered = renderInlineQrPngIfSupported(
|
|
381
1604
|
event.data.image,
|
|
382
1605
|
targetPath
|
|
@@ -452,9 +1675,9 @@ async function loginWithQrAndPersist(profileName, qrPath, opts) {
|
|
|
452
1675
|
}
|
|
453
1676
|
|
|
454
1677
|
// src/lib/media.ts
|
|
455
|
-
import
|
|
1678
|
+
import fs4 from "fs/promises";
|
|
456
1679
|
import os2 from "os";
|
|
457
|
-
import
|
|
1680
|
+
import path4 from "path";
|
|
458
1681
|
import { fileURLToPath } from "url";
|
|
459
1682
|
var CONTENT_TYPE_EXT = {
|
|
460
1683
|
"image/jpeg": ".jpg",
|
|
@@ -502,7 +1725,7 @@ function expandLeadingTilde(value) {
|
|
|
502
1725
|
return os2.homedir();
|
|
503
1726
|
}
|
|
504
1727
|
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
505
|
-
return
|
|
1728
|
+
return path4.join(os2.homedir(), value.slice(2));
|
|
506
1729
|
}
|
|
507
1730
|
return value;
|
|
508
1731
|
}
|
|
@@ -534,7 +1757,7 @@ function inferExt(url, contentType) {
|
|
|
534
1757
|
}
|
|
535
1758
|
try {
|
|
536
1759
|
const pathname = new URL(url).pathname;
|
|
537
|
-
const ext =
|
|
1760
|
+
const ext = path4.extname(pathname);
|
|
538
1761
|
if (ext) return ext;
|
|
539
1762
|
} catch {
|
|
540
1763
|
}
|
|
@@ -547,7 +1770,7 @@ async function downloadUrlsToTempFiles(urls) {
|
|
|
547
1770
|
cleanup: async () => Promise.resolve()
|
|
548
1771
|
};
|
|
549
1772
|
}
|
|
550
|
-
const dir = await
|
|
1773
|
+
const dir = await fs4.mkdtemp(path4.join(os2.tmpdir(), "openzca-"));
|
|
551
1774
|
const files = [];
|
|
552
1775
|
for (let i = 0; i < urls.length; i += 1) {
|
|
553
1776
|
const url = urls[i];
|
|
@@ -556,22 +1779,22 @@ async function downloadUrlsToTempFiles(urls) {
|
|
|
556
1779
|
throw new Error(`Failed to download URL: ${url} (${response.status})`);
|
|
557
1780
|
}
|
|
558
1781
|
const ext = inferExt(url, response.headers.get("content-type"));
|
|
559
|
-
const filePath =
|
|
1782
|
+
const filePath = path4.join(dir, `url-${i + 1}${ext}`);
|
|
560
1783
|
const data = Buffer.from(await response.arrayBuffer());
|
|
561
|
-
await
|
|
1784
|
+
await fs4.writeFile(filePath, data);
|
|
562
1785
|
files.push(filePath);
|
|
563
1786
|
}
|
|
564
1787
|
return {
|
|
565
1788
|
files,
|
|
566
1789
|
cleanup: async () => {
|
|
567
|
-
await
|
|
1790
|
+
await fs4.rm(dir, { recursive: true, force: true });
|
|
568
1791
|
}
|
|
569
1792
|
};
|
|
570
1793
|
}
|
|
571
1794
|
async function assertFilesExist(files) {
|
|
572
1795
|
for (const file of files) {
|
|
573
1796
|
try {
|
|
574
|
-
await
|
|
1797
|
+
await fs4.access(file);
|
|
575
1798
|
} catch {
|
|
576
1799
|
throw new Error(`File not found: ${file}`);
|
|
577
1800
|
}
|
|
@@ -632,6 +1855,82 @@ function buildCreatePollOptions(options) {
|
|
|
632
1855
|
};
|
|
633
1856
|
}
|
|
634
1857
|
|
|
1858
|
+
// src/lib/time-range.ts
|
|
1859
|
+
var DURATION_PART_RE = /(\d+)\s*(ms|s|m|h|d|w)/gi;
|
|
1860
|
+
function durationToMs(input) {
|
|
1861
|
+
const text = input.trim().toLowerCase();
|
|
1862
|
+
if (!text) {
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
let total = 0;
|
|
1866
|
+
let matched = 0;
|
|
1867
|
+
for (const match of text.matchAll(DURATION_PART_RE)) {
|
|
1868
|
+
const rawAmount = match[1];
|
|
1869
|
+
const unit = match[2];
|
|
1870
|
+
const amount = Number(rawAmount);
|
|
1871
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
1872
|
+
return null;
|
|
1873
|
+
}
|
|
1874
|
+
matched += match[0].length;
|
|
1875
|
+
switch (unit) {
|
|
1876
|
+
case "ms":
|
|
1877
|
+
total += amount;
|
|
1878
|
+
break;
|
|
1879
|
+
case "s":
|
|
1880
|
+
total += amount * 1e3;
|
|
1881
|
+
break;
|
|
1882
|
+
case "m":
|
|
1883
|
+
total += amount * 60 * 1e3;
|
|
1884
|
+
break;
|
|
1885
|
+
case "h":
|
|
1886
|
+
total += amount * 60 * 60 * 1e3;
|
|
1887
|
+
break;
|
|
1888
|
+
case "d":
|
|
1889
|
+
total += amount * 24 * 60 * 60 * 1e3;
|
|
1890
|
+
break;
|
|
1891
|
+
case "w":
|
|
1892
|
+
total += amount * 7 * 24 * 60 * 60 * 1e3;
|
|
1893
|
+
break;
|
|
1894
|
+
default:
|
|
1895
|
+
return null;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (matched === 0) {
|
|
1899
|
+
return null;
|
|
1900
|
+
}
|
|
1901
|
+
const normalized = text.replace(/\s+/g, "");
|
|
1902
|
+
const consumed = Array.from(normalized.matchAll(DURATION_PART_RE)).map((match) => match[0]).join("");
|
|
1903
|
+
if (consumed !== normalized) {
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
return total;
|
|
1907
|
+
}
|
|
1908
|
+
function parseDurationInput(value, nowMs = Date.now()) {
|
|
1909
|
+
if (!value || !value.trim()) {
|
|
1910
|
+
return void 0;
|
|
1911
|
+
}
|
|
1912
|
+
const durationMs = durationToMs(value.trim());
|
|
1913
|
+
if (durationMs == null) {
|
|
1914
|
+
return void 0;
|
|
1915
|
+
}
|
|
1916
|
+
return nowMs - durationMs;
|
|
1917
|
+
}
|
|
1918
|
+
function parseTimeBoundaryInput(value, _nowMs = Date.now()) {
|
|
1919
|
+
if (!value || !value.trim()) {
|
|
1920
|
+
return void 0;
|
|
1921
|
+
}
|
|
1922
|
+
const trimmed = value.trim();
|
|
1923
|
+
const numeric = Number(trimmed);
|
|
1924
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
1925
|
+
return numeric > 1e10 ? Math.trunc(numeric) : Math.trunc(numeric * 1e3);
|
|
1926
|
+
}
|
|
1927
|
+
const parsed = Date.parse(trimmed);
|
|
1928
|
+
if (Number.isFinite(parsed)) {
|
|
1929
|
+
return parsed;
|
|
1930
|
+
}
|
|
1931
|
+
return void 0;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
635
1934
|
// src/lib/text-send.ts
|
|
636
1935
|
import { ThreadType } from "zca-js";
|
|
637
1936
|
|
|
@@ -1016,9 +2315,9 @@ async function resolveGroupMentionsIfNeeded(params, text) {
|
|
|
1016
2315
|
|
|
1017
2316
|
// src/lib/video-send.ts
|
|
1018
2317
|
import { execFile } from "child_process";
|
|
1019
|
-
import
|
|
2318
|
+
import fs5 from "fs/promises";
|
|
1020
2319
|
import os3 from "os";
|
|
1021
|
-
import
|
|
2320
|
+
import path5 from "path";
|
|
1022
2321
|
import { promisify } from "util";
|
|
1023
2322
|
var execFileAsync = promisify(execFile);
|
|
1024
2323
|
function planVideoSendMode(params) {
|
|
@@ -1035,7 +2334,7 @@ function planVideoSendMode(params) {
|
|
|
1035
2334
|
reason: "native-video mode supports one video at a time"
|
|
1036
2335
|
};
|
|
1037
2336
|
}
|
|
1038
|
-
const ext =
|
|
2337
|
+
const ext = path5.extname(files[0] ?? "").toLowerCase();
|
|
1039
2338
|
if (ext !== ".mp4") {
|
|
1040
2339
|
return {
|
|
1041
2340
|
mode: "attachment",
|
|
@@ -1129,8 +2428,8 @@ async function maybeProbeVideoFile(filePath) {
|
|
|
1129
2428
|
}
|
|
1130
2429
|
}
|
|
1131
2430
|
async function generateVideoThumbnail(videoPath) {
|
|
1132
|
-
const dir = await
|
|
1133
|
-
const outputPath =
|
|
2431
|
+
const dir = await fs5.mkdtemp(path5.join(os3.tmpdir(), "openzca-video-thumb-"));
|
|
2432
|
+
const outputPath = path5.join(dir, "thumbnail.jpg");
|
|
1134
2433
|
try {
|
|
1135
2434
|
await runBinary("ffmpeg", [
|
|
1136
2435
|
"-y",
|
|
@@ -1144,15 +2443,15 @@ async function generateVideoThumbnail(videoPath) {
|
|
|
1144
2443
|
"2",
|
|
1145
2444
|
outputPath
|
|
1146
2445
|
]);
|
|
1147
|
-
await
|
|
2446
|
+
await fs5.access(outputPath);
|
|
1148
2447
|
} catch (error) {
|
|
1149
|
-
await
|
|
2448
|
+
await fs5.rm(dir, { recursive: true, force: true });
|
|
1150
2449
|
throw error;
|
|
1151
2450
|
}
|
|
1152
2451
|
return {
|
|
1153
2452
|
path: outputPath,
|
|
1154
2453
|
cleanup: async () => {
|
|
1155
|
-
await
|
|
2454
|
+
await fs5.rm(dir, { recursive: true, force: true });
|
|
1156
2455
|
}
|
|
1157
2456
|
};
|
|
1158
2457
|
}
|
|
@@ -1237,6 +2536,25 @@ function commandPathLabel(command) {
|
|
|
1237
2536
|
}
|
|
1238
2537
|
return names.join(" ");
|
|
1239
2538
|
}
|
|
2539
|
+
function readCliFlag(names) {
|
|
2540
|
+
const argv = process.argv.slice(2);
|
|
2541
|
+
return argv.some((item) => names.includes(item));
|
|
2542
|
+
}
|
|
2543
|
+
function readCliOptionValue(names) {
|
|
2544
|
+
const argv = process.argv.slice(2);
|
|
2545
|
+
for (let index = argv.length - 1; index >= 0; index -= 1) {
|
|
2546
|
+
const item = argv[index];
|
|
2547
|
+
for (const name of names) {
|
|
2548
|
+
if (item === name) {
|
|
2549
|
+
return argv[index + 1];
|
|
2550
|
+
}
|
|
2551
|
+
if (item.startsWith(`${name}=`)) {
|
|
2552
|
+
return item.slice(name.length + 1);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
return void 0;
|
|
2557
|
+
}
|
|
1240
2558
|
function getDebugOptions(command) {
|
|
1241
2559
|
if (command) {
|
|
1242
2560
|
if (typeof command.optsWithGlobals === "function") {
|
|
@@ -1257,9 +2575,9 @@ function resolveDebugEnabled(command) {
|
|
|
1257
2575
|
}
|
|
1258
2576
|
function resolveDebugFilePath(command) {
|
|
1259
2577
|
const options = getDebugOptions(command);
|
|
1260
|
-
const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() ||
|
|
2578
|
+
const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path6.join(APP_HOME, "logs", "openzca-debug.log");
|
|
1261
2579
|
const normalized = normalizeMediaInput(configured);
|
|
1262
|
-
return
|
|
2580
|
+
return path6.isAbsolute(normalized) ? normalized : path6.resolve(process.cwd(), normalized);
|
|
1263
2581
|
}
|
|
1264
2582
|
function writeDebugLine(event, details, command) {
|
|
1265
2583
|
if (!resolveDebugEnabled(command)) {
|
|
@@ -1270,7 +2588,7 @@ function writeDebugLine(event, details, command) {
|
|
|
1270
2588
|
`;
|
|
1271
2589
|
const filePath = resolveDebugFilePath(command);
|
|
1272
2590
|
try {
|
|
1273
|
-
fsSync.mkdirSync(
|
|
2591
|
+
fsSync.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
1274
2592
|
fsSync.appendFileSync(filePath, line, "utf8");
|
|
1275
2593
|
} catch {
|
|
1276
2594
|
}
|
|
@@ -1318,6 +2636,25 @@ function output(value, asJson = false) {
|
|
|
1318
2636
|
}
|
|
1319
2637
|
console.log(String(value));
|
|
1320
2638
|
}
|
|
2639
|
+
function shouldOutputJson(opts) {
|
|
2640
|
+
return Boolean(opts?.json) || readCliFlag(["--json", "-j"]);
|
|
2641
|
+
}
|
|
2642
|
+
function normalizeCommandAliases(argv) {
|
|
2643
|
+
const normalized = [...argv];
|
|
2644
|
+
const dbIndex = normalized.indexOf("db");
|
|
2645
|
+
if (dbIndex === -1 || normalized[dbIndex + 1] !== "chat") {
|
|
2646
|
+
return normalized;
|
|
2647
|
+
}
|
|
2648
|
+
const subcommandOrId = normalized[dbIndex + 2];
|
|
2649
|
+
if (!subcommandOrId || subcommandOrId.startsWith("-")) {
|
|
2650
|
+
return normalized;
|
|
2651
|
+
}
|
|
2652
|
+
if (["list", "info", "messages", "help"].includes(subcommandOrId)) {
|
|
2653
|
+
return normalized;
|
|
2654
|
+
}
|
|
2655
|
+
normalized.splice(dbIndex + 2, 0, "messages");
|
|
2656
|
+
return normalized;
|
|
2657
|
+
}
|
|
1321
2658
|
function asThreadType(groupFlag) {
|
|
1322
2659
|
return groupFlag ? ThreadType2.Group : ThreadType2.User;
|
|
1323
2660
|
}
|
|
@@ -1358,14 +2695,14 @@ function collectIdsFromCacheEntries(entries, keys) {
|
|
|
1358
2695
|
return ids;
|
|
1359
2696
|
}
|
|
1360
2697
|
function getListenerOwnerLockPath(profile) {
|
|
1361
|
-
return
|
|
2698
|
+
return path6.join(getProfileDir(profile), "listener-owner.json");
|
|
1362
2699
|
}
|
|
1363
2700
|
function getListenIpcSocketPath(profile) {
|
|
1364
2701
|
if (process.platform === "win32") {
|
|
1365
2702
|
const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
1366
2703
|
return `\\\\.\\pipe\\openzca-listen-${safe}`;
|
|
1367
2704
|
}
|
|
1368
|
-
return
|
|
2705
|
+
return path6.join(getProfileDir(profile), "listen.sock");
|
|
1369
2706
|
}
|
|
1370
2707
|
function parsePositiveIntFromUnknown(value) {
|
|
1371
2708
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
@@ -1392,7 +2729,7 @@ function isProcessAlive(pid) {
|
|
|
1392
2729
|
}
|
|
1393
2730
|
async function readListenerOwnerRecord(lockPath) {
|
|
1394
2731
|
try {
|
|
1395
|
-
const raw = await
|
|
2732
|
+
const raw = await fs6.readFile(lockPath, "utf8");
|
|
1396
2733
|
const parsed = JSON.parse(raw);
|
|
1397
2734
|
const pid = parsePositiveIntFromUnknown(parsed.pid);
|
|
1398
2735
|
if (!pid) return null;
|
|
@@ -1412,11 +2749,11 @@ async function readActiveListenerOwner(profile) {
|
|
|
1412
2749
|
const lockPath = getListenerOwnerLockPath(profile);
|
|
1413
2750
|
const record = await readListenerOwnerRecord(lockPath);
|
|
1414
2751
|
if (!record) {
|
|
1415
|
-
await
|
|
2752
|
+
await fs6.rm(lockPath, { force: true });
|
|
1416
2753
|
return null;
|
|
1417
2754
|
}
|
|
1418
2755
|
if (!isProcessAlive(record.pid)) {
|
|
1419
|
-
await
|
|
2756
|
+
await fs6.rm(lockPath, { force: true });
|
|
1420
2757
|
return null;
|
|
1421
2758
|
}
|
|
1422
2759
|
return record;
|
|
@@ -1432,7 +2769,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
|
|
|
1432
2769
|
};
|
|
1433
2770
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1434
2771
|
try {
|
|
1435
|
-
await
|
|
2772
|
+
await fs6.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
|
|
1436
2773
|
`, {
|
|
1437
2774
|
encoding: "utf8",
|
|
1438
2775
|
flag: "wx"
|
|
@@ -1445,7 +2782,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
|
|
|
1445
2782
|
released = true;
|
|
1446
2783
|
const current = await readListenerOwnerRecord(lockPath);
|
|
1447
2784
|
if (current && current.pid !== process.pid) return;
|
|
1448
|
-
await
|
|
2785
|
+
await fs6.rm(lockPath, { force: true });
|
|
1449
2786
|
writeDebugLine(
|
|
1450
2787
|
"listen.owner.released",
|
|
1451
2788
|
{
|
|
@@ -1466,7 +2803,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
|
|
|
1466
2803
|
`Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
|
|
1467
2804
|
);
|
|
1468
2805
|
}
|
|
1469
|
-
await
|
|
2806
|
+
await fs6.rm(lockPath, { force: true });
|
|
1470
2807
|
}
|
|
1471
2808
|
}
|
|
1472
2809
|
throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
|
|
@@ -1484,7 +2821,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
|
|
|
1484
2821
|
}
|
|
1485
2822
|
const socketPath = getListenIpcSocketPath(profile);
|
|
1486
2823
|
if (process.platform !== "win32") {
|
|
1487
|
-
await
|
|
2824
|
+
await fs6.rm(socketPath, { force: true });
|
|
1488
2825
|
}
|
|
1489
2826
|
const uploadTimeoutMs = parsePositiveIntFromEnv(
|
|
1490
2827
|
"OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
|
|
@@ -1656,7 +2993,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
|
|
|
1656
2993
|
server.close(() => resolve());
|
|
1657
2994
|
});
|
|
1658
2995
|
if (process.platform !== "win32") {
|
|
1659
|
-
await
|
|
2996
|
+
await fs6.rm(socketPath, { force: true });
|
|
1660
2997
|
}
|
|
1661
2998
|
writeDebugLine(
|
|
1662
2999
|
"listen.ipc.stopped",
|
|
@@ -1927,6 +3264,619 @@ async function requireApi(command) {
|
|
|
1927
3264
|
const api = await loginWithStoredCredentials(profile);
|
|
1928
3265
|
return { profile, api };
|
|
1929
3266
|
}
|
|
3267
|
+
function toDbThreadType(groupFlag) {
|
|
3268
|
+
return groupFlag ? "group" : "user";
|
|
3269
|
+
}
|
|
3270
|
+
function getDbWriteOverride(opts) {
|
|
3271
|
+
if (!opts || typeof opts.db !== "boolean") {
|
|
3272
|
+
return void 0;
|
|
3273
|
+
}
|
|
3274
|
+
return opts.db;
|
|
3275
|
+
}
|
|
3276
|
+
async function shouldWriteToDb(profile, override) {
|
|
3277
|
+
if (typeof override === "boolean") {
|
|
3278
|
+
return override;
|
|
3279
|
+
}
|
|
3280
|
+
return isDbEnabled(profile);
|
|
3281
|
+
}
|
|
3282
|
+
function scheduleDbWrite(profile, command, event, task) {
|
|
3283
|
+
enqueueDbWrite(profile, async () => {
|
|
3284
|
+
try {
|
|
3285
|
+
await task();
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
writeDebugLine(
|
|
3288
|
+
event,
|
|
3289
|
+
{
|
|
3290
|
+
profile,
|
|
3291
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3292
|
+
},
|
|
3293
|
+
command
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
function extractResponseMessageIds(value) {
|
|
3299
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3300
|
+
const visit = (item) => {
|
|
3301
|
+
if (!item) return;
|
|
3302
|
+
if (Array.isArray(item)) {
|
|
3303
|
+
for (const nested of item) {
|
|
3304
|
+
visit(nested);
|
|
3305
|
+
}
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
if (typeof item !== "object") {
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
const record = item;
|
|
3312
|
+
const msgId = normalizeCachedId(record.msgId);
|
|
3313
|
+
if (msgId) {
|
|
3314
|
+
ids.add(msgId);
|
|
3315
|
+
}
|
|
3316
|
+
for (const key of ["message", "attachment", "attachments", "results", "response"]) {
|
|
3317
|
+
if (key in record) {
|
|
3318
|
+
visit(record[key]);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
visit(value);
|
|
3323
|
+
return Array.from(ids);
|
|
3324
|
+
}
|
|
3325
|
+
async function persistOutgoingMessageBestEffort(params) {
|
|
3326
|
+
const selfId = params.api.getOwnId();
|
|
3327
|
+
const threadType = toDbThreadType(params.group);
|
|
3328
|
+
const scopeThreadId = resolveScopeThreadId({
|
|
3329
|
+
threadType,
|
|
3330
|
+
rawThreadId: params.threadId,
|
|
3331
|
+
senderId: selfId,
|
|
3332
|
+
toId: params.threadId,
|
|
3333
|
+
selfId
|
|
3334
|
+
});
|
|
3335
|
+
const messageIds = extractResponseMessageIds(params.response);
|
|
3336
|
+
const baseRecord = {
|
|
3337
|
+
profile: params.profile,
|
|
3338
|
+
scopeThreadId,
|
|
3339
|
+
rawThreadId: params.threadId,
|
|
3340
|
+
threadType,
|
|
3341
|
+
peerId: threadType === "user" ? scopeThreadId : void 0,
|
|
3342
|
+
senderId: selfId,
|
|
3343
|
+
senderName: void 0,
|
|
3344
|
+
toId: threadType === "user" ? params.threadId : void 0,
|
|
3345
|
+
timestampMs: Date.now(),
|
|
3346
|
+
msgType: params.msgType,
|
|
3347
|
+
contentText: params.text,
|
|
3348
|
+
media: params.media,
|
|
3349
|
+
source: "send",
|
|
3350
|
+
rawPayloadJson: params.rawPayload ? JSON.stringify(params.rawPayload) : void 0,
|
|
3351
|
+
rawMessageJson: JSON.stringify(params.response)
|
|
3352
|
+
};
|
|
3353
|
+
if (messageIds.length === 0) {
|
|
3354
|
+
await persistMessage(baseRecord);
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
for (const msgId of messageIds) {
|
|
3358
|
+
await persistMessage({
|
|
3359
|
+
...baseRecord,
|
|
3360
|
+
msgId
|
|
3361
|
+
});
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
async function persistGroupMembersSnapshot(profile, groupId, api) {
|
|
3365
|
+
const rows = await listGroupMemberRows(api, groupId);
|
|
3366
|
+
const snapshotAtMs = Date.now();
|
|
3367
|
+
await replaceThreadMembers(
|
|
3368
|
+
profile,
|
|
3369
|
+
groupId,
|
|
3370
|
+
rows.map((row) => ({
|
|
3371
|
+
profile,
|
|
3372
|
+
scopeThreadId: groupId,
|
|
3373
|
+
userId: row.userId,
|
|
3374
|
+
displayName: row.displayName,
|
|
3375
|
+
zaloName: row.zaloName,
|
|
3376
|
+
rawJson: JSON.stringify(row),
|
|
3377
|
+
snapshotAtMs
|
|
3378
|
+
}))
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
async function persistFriendDirectory(profile, api) {
|
|
3382
|
+
const friends = await api.getAllFriends();
|
|
3383
|
+
const nameById = /* @__PURE__ */ new Map();
|
|
3384
|
+
for (const friend2 of friends) {
|
|
3385
|
+
const record = friend2;
|
|
3386
|
+
const userId = normalizeCachedId(record.userId);
|
|
3387
|
+
if (!userId) continue;
|
|
3388
|
+
const displayName = typeof record.displayName === "string" && record.displayName.trim() ? record.displayName.trim() : void 0;
|
|
3389
|
+
const zaloName = typeof record.zaloName === "string" && record.zaloName.trim() ? record.zaloName.trim() : void 0;
|
|
3390
|
+
const avatar = typeof record.avatar === "string" && record.avatar.trim() ? record.avatar.trim() : void 0;
|
|
3391
|
+
const title = displayName || zaloName || userId;
|
|
3392
|
+
await persistFriend({
|
|
3393
|
+
profile,
|
|
3394
|
+
userId,
|
|
3395
|
+
displayName,
|
|
3396
|
+
zaloName,
|
|
3397
|
+
avatar,
|
|
3398
|
+
accountStatus: typeof record.accountStatus === "number" && Number.isFinite(record.accountStatus) ? Math.trunc(record.accountStatus) : void 0,
|
|
3399
|
+
rawJson: JSON.stringify(friend2)
|
|
3400
|
+
});
|
|
3401
|
+
await persistThread({
|
|
3402
|
+
profile,
|
|
3403
|
+
scopeThreadId: userId,
|
|
3404
|
+
rawThreadId: userId,
|
|
3405
|
+
threadType: "user",
|
|
3406
|
+
peerId: userId,
|
|
3407
|
+
title,
|
|
3408
|
+
rawJson: JSON.stringify(friend2)
|
|
3409
|
+
});
|
|
3410
|
+
nameById.set(userId, title);
|
|
3411
|
+
}
|
|
3412
|
+
return nameById;
|
|
3413
|
+
}
|
|
3414
|
+
function parseSinceDuration(label, value) {
|
|
3415
|
+
const parsed = parseDurationInput(value);
|
|
3416
|
+
if (parsed !== void 0) {
|
|
3417
|
+
return parsed;
|
|
3418
|
+
}
|
|
3419
|
+
if (!value || !value.trim()) {
|
|
3420
|
+
return void 0;
|
|
3421
|
+
}
|
|
3422
|
+
throw new Error(
|
|
3423
|
+
`${label} must be a relative duration like 30s, 7m, 24h, 7d, or 2w.`
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
function parseTimeBoundary(label, value) {
|
|
3427
|
+
const parsed = parseTimeBoundaryInput(value);
|
|
3428
|
+
if (parsed !== void 0) {
|
|
3429
|
+
return parsed;
|
|
3430
|
+
}
|
|
3431
|
+
if (!value || !value.trim()) {
|
|
3432
|
+
return void 0;
|
|
3433
|
+
}
|
|
3434
|
+
throw new Error(
|
|
3435
|
+
`${label} must be an ISO timestamp, a date, or unix seconds/ms.`
|
|
3436
|
+
);
|
|
3437
|
+
}
|
|
3438
|
+
function pickExclusiveOption(primaryLabel, primaryValue, aliasLabel, aliasValue) {
|
|
3439
|
+
if (primaryValue?.trim() && aliasValue?.trim()) {
|
|
3440
|
+
throw new Error(`Use either ${primaryLabel} or ${aliasLabel}, not both.`);
|
|
3441
|
+
}
|
|
3442
|
+
return primaryValue?.trim() ? primaryValue : aliasValue?.trim() ? aliasValue : void 0;
|
|
3443
|
+
}
|
|
3444
|
+
function resolveMessageTimeRange(opts) {
|
|
3445
|
+
const sinceValue = opts.since?.trim() ? opts.since : void 0;
|
|
3446
|
+
const fromValue = opts.from?.trim() ? opts.from : void 0;
|
|
3447
|
+
const untilValue = pickExclusiveOption("--until", opts.until, "--to", opts.to);
|
|
3448
|
+
if (sinceValue && fromValue) {
|
|
3449
|
+
throw new Error("Use either --since for a rolling window or --from/--to for an explicit range, not both.");
|
|
3450
|
+
}
|
|
3451
|
+
if (sinceValue && untilValue) {
|
|
3452
|
+
throw new Error("Do not combine --since with --to/--until. Use --from/--to for explicit ranges.");
|
|
3453
|
+
}
|
|
3454
|
+
return {
|
|
3455
|
+
sinceMs: sinceValue ? parseSinceDuration("--since", sinceValue) : parseTimeBoundary("--from", fromValue),
|
|
3456
|
+
untilMs: parseTimeBoundary("--to/--until", untilValue)
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
function resolveMessageQueryOptions(opts) {
|
|
3460
|
+
const { sinceMs, untilMs } = resolveMessageTimeRange(opts);
|
|
3461
|
+
if (opts.all && opts.limit?.trim()) {
|
|
3462
|
+
throw new Error("Use either --all or --limit, not both.");
|
|
3463
|
+
}
|
|
3464
|
+
const explicitLimit = parsePositiveIntOption("--limit", opts.limit);
|
|
3465
|
+
const hasTimeFilter = sinceMs !== void 0 || untilMs !== void 0;
|
|
3466
|
+
const limit = opts.all ? void 0 : explicitLimit ?? (hasTimeFilter ? void 0 : 20);
|
|
3467
|
+
const newestFirst = !Boolean(opts.oldestFirst);
|
|
3468
|
+
return {
|
|
3469
|
+
sinceMs,
|
|
3470
|
+
untilMs,
|
|
3471
|
+
limit,
|
|
3472
|
+
newestFirst
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
async function resolveStoredChatThreadType(profile, chatId, forceGroup) {
|
|
3476
|
+
if (forceGroup) {
|
|
3477
|
+
return "group";
|
|
3478
|
+
}
|
|
3479
|
+
const row = await getThreadInfo({ profile, threadId: chatId });
|
|
3480
|
+
return row?.threadType === "group" ? "group" : "user";
|
|
3481
|
+
}
|
|
3482
|
+
async function confirmDestructiveAction(message) {
|
|
3483
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3484
|
+
throw new Error("Refusing destructive operation without --yes in non-interactive mode.");
|
|
3485
|
+
}
|
|
3486
|
+
const rl = readline.createInterface({
|
|
3487
|
+
input: process.stdin,
|
|
3488
|
+
output: process.stdout
|
|
3489
|
+
});
|
|
3490
|
+
try {
|
|
3491
|
+
const answer = (await rl.question(`${message} [y/N] `)).trim().toLowerCase();
|
|
3492
|
+
return answer === "y" || answer === "yes";
|
|
3493
|
+
} finally {
|
|
3494
|
+
rl.close();
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
function createSyncProgressReporter() {
|
|
3498
|
+
if (!process.stderr.isTTY) {
|
|
3499
|
+
return () => {
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
return (message) => {
|
|
3503
|
+
process.stderr.write(`[db sync] ${message}
|
|
3504
|
+
`);
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
function createDbSyncSummary(profile, dbPath, count) {
|
|
3508
|
+
return {
|
|
3509
|
+
profile,
|
|
3510
|
+
dbPath,
|
|
3511
|
+
windowCount: count,
|
|
3512
|
+
groupsSynced: 0,
|
|
3513
|
+
groupMessagesImported: 0,
|
|
3514
|
+
friendsSynced: 0,
|
|
3515
|
+
chatsSynced: 0,
|
|
3516
|
+
dmMessagesImported: 0,
|
|
3517
|
+
syncState: []
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
function resolveSyncWindowCount(value) {
|
|
3521
|
+
return parsePositiveIntOption("--count", value) ?? 200;
|
|
3522
|
+
}
|
|
3523
|
+
async function collectConversationIds(api) {
|
|
3524
|
+
let pinnedIds = /* @__PURE__ */ new Set();
|
|
3525
|
+
let hiddenIds = /* @__PURE__ */ new Set();
|
|
3526
|
+
try {
|
|
3527
|
+
const pins = await api.getPinConversations();
|
|
3528
|
+
pinnedIds = new Set((pins.conversations ?? []).map((value) => String(value)));
|
|
3529
|
+
} catch {
|
|
3530
|
+
}
|
|
3531
|
+
try {
|
|
3532
|
+
const hidden = await api.getHiddenConversations();
|
|
3533
|
+
hiddenIds = new Set((hidden.threads ?? []).map((item) => String(item.thread_id)));
|
|
3534
|
+
} catch {
|
|
3535
|
+
}
|
|
3536
|
+
return { pinnedIds, hiddenIds };
|
|
3537
|
+
}
|
|
3538
|
+
async function prepareDbGroupTarget(params) {
|
|
3539
|
+
await persistThread({
|
|
3540
|
+
profile: params.profile,
|
|
3541
|
+
scopeThreadId: params.groupId,
|
|
3542
|
+
rawThreadId: params.groupId,
|
|
3543
|
+
threadType: "group",
|
|
3544
|
+
title: params.title,
|
|
3545
|
+
isPinned: params.pinnedIds.has(params.groupId),
|
|
3546
|
+
isHidden: params.hiddenIds.has(params.groupId),
|
|
3547
|
+
rawJson: params.rawJson
|
|
3548
|
+
});
|
|
3549
|
+
await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
|
|
3550
|
+
}
|
|
3551
|
+
async function syncDbGroupHistoryFull(params) {
|
|
3552
|
+
if (params.targetGroupIds.size === 0) {
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
const getStoredGroupMessageCount = async () => {
|
|
3556
|
+
let total = 0;
|
|
3557
|
+
for (const groupId of params.targetGroupIds) {
|
|
3558
|
+
const row = await getThreadInfo({
|
|
3559
|
+
profile: params.profile,
|
|
3560
|
+
threadId: groupId,
|
|
3561
|
+
threadType: "group"
|
|
3562
|
+
});
|
|
3563
|
+
const count = row && typeof row.messageCount === "number" && Number.isFinite(row.messageCount) ? row.messageCount : 0;
|
|
3564
|
+
total += count;
|
|
3565
|
+
}
|
|
3566
|
+
return total;
|
|
3567
|
+
};
|
|
3568
|
+
const persistMessages = async (messages) => {
|
|
3569
|
+
for (const message of messages) {
|
|
3570
|
+
if (!params.targetGroupIds.has(message.threadId)) {
|
|
3571
|
+
continue;
|
|
3572
|
+
}
|
|
3573
|
+
processed += 1;
|
|
3574
|
+
await persistMessage(
|
|
3575
|
+
toDbRecordFromRecentMessage({
|
|
3576
|
+
profile: params.profile,
|
|
3577
|
+
message,
|
|
3578
|
+
source: "sync_group",
|
|
3579
|
+
selfId: params.selfId,
|
|
3580
|
+
title: params.titleById.get(message.threadId)
|
|
3581
|
+
})
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
};
|
|
3585
|
+
const beforeCount = await getStoredGroupMessageCount();
|
|
3586
|
+
let processed = 0;
|
|
3587
|
+
let completeness = "complete";
|
|
3588
|
+
let stopReason = "exhausted";
|
|
3589
|
+
let pagesRequested = 0;
|
|
3590
|
+
let listenerImportedCount = 0;
|
|
3591
|
+
try {
|
|
3592
|
+
params.progress?.(`syncing full history for ${params.targetGroupIds.size} group(s)`);
|
|
3593
|
+
const result = await crawlGroupHistoryViaListener(params.api, {
|
|
3594
|
+
maxPages: Number.MAX_SAFE_INTEGER,
|
|
3595
|
+
idleTimeoutMs: 15e3,
|
|
3596
|
+
onMessages: persistMessages,
|
|
3597
|
+
onPage: ({ pagesRequested: pagesRequested2, filteredCount }) => {
|
|
3598
|
+
params.progress?.(
|
|
3599
|
+
`groups page ${pagesRequested2}: batch ${filteredCount}, processed ${processed}`
|
|
3600
|
+
);
|
|
3601
|
+
}
|
|
3602
|
+
});
|
|
3603
|
+
completeness = result.stopReason === "exhausted" ? "complete" : result.stopReason === "max_pages" || result.stopReason === "timeout" ? "partial" : "window";
|
|
3604
|
+
stopReason = result.stopReason;
|
|
3605
|
+
pagesRequested = result.pagesRequested;
|
|
3606
|
+
listenerImportedCount = await getStoredGroupMessageCount() - beforeCount;
|
|
3607
|
+
} catch (error) {
|
|
3608
|
+
stopReason = `fallback_window:${toErrorText(error)}`;
|
|
3609
|
+
completeness = "window";
|
|
3610
|
+
}
|
|
3611
|
+
const fallbackCount = 200;
|
|
3612
|
+
params.progress?.(`merging recent group API window (${fallbackCount} per group)`);
|
|
3613
|
+
const beforeApiCount = await getStoredGroupMessageCount();
|
|
3614
|
+
for (const groupId of params.targetGroupIds) {
|
|
3615
|
+
const messages = await fetchRecentGroupMessagesViaApi(params.api, groupId, fallbackCount);
|
|
3616
|
+
await persistMessages(messages);
|
|
3617
|
+
params.progress?.(`group ${groupId}: fetched ${messages.length} message(s) from group history API`);
|
|
3618
|
+
}
|
|
3619
|
+
const afterCount = await getStoredGroupMessageCount();
|
|
3620
|
+
const apiAddedCount = afterCount - beforeApiCount;
|
|
3621
|
+
if (apiAddedCount > 0) {
|
|
3622
|
+
completeness = "window";
|
|
3623
|
+
if (stopReason === "exhausted" && listenerImportedCount === 0) {
|
|
3624
|
+
stopReason = "fallback_window:empty_listener_result";
|
|
3625
|
+
} else if (stopReason === "exhausted") {
|
|
3626
|
+
stopReason = "window_topoff:listener_incomplete";
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
const imported = Math.max(afterCount - beforeCount, 0);
|
|
3630
|
+
for (const groupId of params.targetGroupIds) {
|
|
3631
|
+
await setSyncState({
|
|
3632
|
+
profile: params.profile,
|
|
3633
|
+
scopeThreadId: groupId,
|
|
3634
|
+
threadType: "group",
|
|
3635
|
+
status: "synced",
|
|
3636
|
+
completeness
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
params.summary.groupsSynced += params.targetGroupIds.size;
|
|
3640
|
+
params.summary.groupMessagesImported += imported;
|
|
3641
|
+
params.summary.syncState.push({
|
|
3642
|
+
kind: "groups",
|
|
3643
|
+
groups: params.targetGroupIds.size,
|
|
3644
|
+
imported,
|
|
3645
|
+
completeness,
|
|
3646
|
+
stopReason,
|
|
3647
|
+
pagesRequested
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
async function syncDbFriendDirectory(params) {
|
|
3651
|
+
params.progress?.("syncing friend directory");
|
|
3652
|
+
const names = await persistFriendDirectory(params.profile, params.api);
|
|
3653
|
+
params.summary.friendsSynced += names.size;
|
|
3654
|
+
params.progress?.(`friend directory synced: ${names.size} friend(s)`);
|
|
3655
|
+
params.summary.syncState.push({
|
|
3656
|
+
kind: "friends",
|
|
3657
|
+
imported: names.size
|
|
3658
|
+
});
|
|
3659
|
+
return names;
|
|
3660
|
+
}
|
|
3661
|
+
async function syncDbChatThread(params) {
|
|
3662
|
+
const scopeThreadId = resolveScopeThreadId({
|
|
3663
|
+
threadType: "user",
|
|
3664
|
+
rawThreadId: params.threadId,
|
|
3665
|
+
senderId: params.selfId,
|
|
3666
|
+
toId: params.threadId,
|
|
3667
|
+
selfId: params.selfId
|
|
3668
|
+
});
|
|
3669
|
+
await persistThread({
|
|
3670
|
+
profile: params.profile,
|
|
3671
|
+
scopeThreadId,
|
|
3672
|
+
rawThreadId: params.threadId,
|
|
3673
|
+
threadType: "user",
|
|
3674
|
+
peerId: scopeThreadId,
|
|
3675
|
+
title: params.title,
|
|
3676
|
+
isPinned: params.pinnedIds.has(params.threadId) || params.pinnedIds.has(scopeThreadId),
|
|
3677
|
+
isHidden: params.hiddenIds.has(params.threadId) || params.hiddenIds.has(scopeThreadId)
|
|
3678
|
+
});
|
|
3679
|
+
const messages = await fetchRecentUserMessagesViaListener(params.api, params.threadId, params.count);
|
|
3680
|
+
for (const message of messages) {
|
|
3681
|
+
await persistMessage(
|
|
3682
|
+
toDbRecordFromRecentMessage({
|
|
3683
|
+
profile: params.profile,
|
|
3684
|
+
message,
|
|
3685
|
+
source: "sync_dm_best_effort",
|
|
3686
|
+
selfId: params.selfId,
|
|
3687
|
+
title: params.title
|
|
3688
|
+
})
|
|
3689
|
+
);
|
|
3690
|
+
}
|
|
3691
|
+
await setSyncState({
|
|
3692
|
+
profile: params.profile,
|
|
3693
|
+
scopeThreadId,
|
|
3694
|
+
threadType: "user",
|
|
3695
|
+
status: "synced",
|
|
3696
|
+
completeness: "best_effort"
|
|
3697
|
+
});
|
|
3698
|
+
params.summary.chatsSynced += 1;
|
|
3699
|
+
params.summary.dmMessagesImported += messages.length;
|
|
3700
|
+
params.progress?.(`chat ${scopeThreadId}: imported ${messages.length} message(s)`);
|
|
3701
|
+
params.summary.syncState.push({
|
|
3702
|
+
kind: "chat",
|
|
3703
|
+
chatId: scopeThreadId,
|
|
3704
|
+
rawThreadId: params.threadId,
|
|
3705
|
+
imported: messages.length,
|
|
3706
|
+
completeness: "best_effort"
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
async function syncDbChatsBestEffort(params) {
|
|
3710
|
+
const scanLimit = Math.max(params.count * 10, 500);
|
|
3711
|
+
params.progress?.(`scanning recent DM/chat windows (target window ${params.count}, scan limit ${scanLimit})`);
|
|
3712
|
+
const messages = await fetchRecentUserMessagesAcrossThreads(params.api, scanLimit);
|
|
3713
|
+
const seenScopes = /* @__PURE__ */ new Set();
|
|
3714
|
+
for (const message of messages) {
|
|
3715
|
+
const title = params.titleById.get(message.threadId);
|
|
3716
|
+
const record = toDbRecordFromRecentMessage({
|
|
3717
|
+
profile: params.profile,
|
|
3718
|
+
message,
|
|
3719
|
+
source: "sync_dm_best_effort",
|
|
3720
|
+
selfId: params.selfId,
|
|
3721
|
+
title
|
|
3722
|
+
});
|
|
3723
|
+
await persistThread({
|
|
3724
|
+
profile: params.profile,
|
|
3725
|
+
scopeThreadId: record.scopeThreadId,
|
|
3726
|
+
rawThreadId: record.rawThreadId,
|
|
3727
|
+
threadType: "user",
|
|
3728
|
+
peerId: record.scopeThreadId,
|
|
3729
|
+
title,
|
|
3730
|
+
isPinned: params.pinnedIds.has(record.rawThreadId) || params.pinnedIds.has(record.scopeThreadId),
|
|
3731
|
+
isHidden: params.hiddenIds.has(record.rawThreadId) || params.hiddenIds.has(record.scopeThreadId)
|
|
3732
|
+
});
|
|
3733
|
+
await persistMessage(record);
|
|
3734
|
+
if (!seenScopes.has(record.scopeThreadId)) {
|
|
3735
|
+
seenScopes.add(record.scopeThreadId);
|
|
3736
|
+
await setSyncState({
|
|
3737
|
+
profile: params.profile,
|
|
3738
|
+
scopeThreadId: record.scopeThreadId,
|
|
3739
|
+
threadType: "user",
|
|
3740
|
+
status: "synced",
|
|
3741
|
+
completeness: "best_effort"
|
|
3742
|
+
});
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
params.summary.chatsSynced += seenScopes.size;
|
|
3746
|
+
params.summary.dmMessagesImported += messages.length;
|
|
3747
|
+
params.progress?.(`chat scan finished: ${messages.length} message(s) across ${seenScopes.size} chat(s)`);
|
|
3748
|
+
params.summary.syncState.push({
|
|
3749
|
+
kind: "chats",
|
|
3750
|
+
imported: messages.length,
|
|
3751
|
+
chats: seenScopes.size,
|
|
3752
|
+
completeness: "best_effort"
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
async function runDbSync(params) {
|
|
3756
|
+
const { profile, api } = await requireApi(params.command);
|
|
3757
|
+
const dbPath = await resolveDbPath(profile);
|
|
3758
|
+
params.progress?.(`starting sync for profile ${profile}`);
|
|
3759
|
+
const summary = createDbSyncSummary(
|
|
3760
|
+
profile,
|
|
3761
|
+
dbPath,
|
|
3762
|
+
params.mode === "all" || params.mode === "chats" || params.mode === "chat" ? params.count : void 0
|
|
3763
|
+
);
|
|
3764
|
+
const selfId = api.getOwnId();
|
|
3765
|
+
const selfInfo = normalizeMeInfoOutput(await api.fetchAccountInfo());
|
|
3766
|
+
await persistSelfProfile({
|
|
3767
|
+
profile,
|
|
3768
|
+
userId: selfId,
|
|
3769
|
+
displayName: typeof selfInfo.displayName === "string" && selfInfo.displayName.trim() ? selfInfo.displayName.trim() : void 0,
|
|
3770
|
+
infoJson: JSON.stringify(selfInfo)
|
|
3771
|
+
});
|
|
3772
|
+
const { pinnedIds, hiddenIds } = await collectConversationIds(api);
|
|
3773
|
+
let friendNames = /* @__PURE__ */ new Map();
|
|
3774
|
+
if (params.mode === "all" || params.mode === "friends" || params.mode === "chats") {
|
|
3775
|
+
friendNames = await syncDbFriendDirectory({
|
|
3776
|
+
profile,
|
|
3777
|
+
api,
|
|
3778
|
+
summary,
|
|
3779
|
+
progress: params.progress
|
|
3780
|
+
});
|
|
3781
|
+
}
|
|
3782
|
+
if (params.mode === "all" || params.mode === "groups") {
|
|
3783
|
+
const groups = await buildGroupsDetailed(api);
|
|
3784
|
+
const targetGroupIds = /* @__PURE__ */ new Set();
|
|
3785
|
+
const titleById = /* @__PURE__ */ new Map();
|
|
3786
|
+
for (const group2 of groups) {
|
|
3787
|
+
const record = group2;
|
|
3788
|
+
const groupId = normalizeCachedId(record.groupId);
|
|
3789
|
+
if (!groupId) continue;
|
|
3790
|
+
const title = typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
|
|
3791
|
+
targetGroupIds.add(groupId);
|
|
3792
|
+
titleById.set(groupId, title);
|
|
3793
|
+
await prepareDbGroupTarget({
|
|
3794
|
+
profile,
|
|
3795
|
+
api,
|
|
3796
|
+
groupId,
|
|
3797
|
+
title,
|
|
3798
|
+
rawJson: JSON.stringify(group2),
|
|
3799
|
+
pinnedIds,
|
|
3800
|
+
hiddenIds
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
await syncDbGroupHistoryFull({
|
|
3804
|
+
profile,
|
|
3805
|
+
api,
|
|
3806
|
+
selfId,
|
|
3807
|
+
targetGroupIds,
|
|
3808
|
+
titleById,
|
|
3809
|
+
summary,
|
|
3810
|
+
progress: params.progress
|
|
3811
|
+
});
|
|
3812
|
+
}
|
|
3813
|
+
if (params.mode === "group") {
|
|
3814
|
+
if (!params.groupId) {
|
|
3815
|
+
throw new Error("Missing group id for db sync group.");
|
|
3816
|
+
}
|
|
3817
|
+
const groupInfo = await api.getGroupInfo(params.groupId);
|
|
3818
|
+
const group2 = groupInfo.gridInfoMap[params.groupId];
|
|
3819
|
+
const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : void 0;
|
|
3820
|
+
await prepareDbGroupTarget({
|
|
3821
|
+
profile,
|
|
3822
|
+
api,
|
|
3823
|
+
groupId: params.groupId,
|
|
3824
|
+
title,
|
|
3825
|
+
rawJson: group2 ? JSON.stringify(group2) : void 0,
|
|
3826
|
+
pinnedIds,
|
|
3827
|
+
hiddenIds
|
|
3828
|
+
});
|
|
3829
|
+
await syncDbGroupHistoryFull({
|
|
3830
|
+
profile,
|
|
3831
|
+
api,
|
|
3832
|
+
selfId,
|
|
3833
|
+
targetGroupIds: /* @__PURE__ */ new Set([params.groupId]),
|
|
3834
|
+
titleById: /* @__PURE__ */ new Map([[params.groupId, title]]),
|
|
3835
|
+
summary,
|
|
3836
|
+
progress: params.progress
|
|
3837
|
+
});
|
|
3838
|
+
}
|
|
3839
|
+
if (params.mode === "chat") {
|
|
3840
|
+
if (!params.threadId) {
|
|
3841
|
+
throw new Error("Missing chat id for db sync chat.");
|
|
3842
|
+
}
|
|
3843
|
+
if (friendNames.size === 0) {
|
|
3844
|
+
friendNames = await persistFriendDirectory(profile, api);
|
|
3845
|
+
}
|
|
3846
|
+
await syncDbChatThread({
|
|
3847
|
+
profile,
|
|
3848
|
+
api,
|
|
3849
|
+
selfId,
|
|
3850
|
+
threadId: params.threadId,
|
|
3851
|
+
count: params.count,
|
|
3852
|
+
title: friendNames.get(params.threadId),
|
|
3853
|
+
pinnedIds,
|
|
3854
|
+
hiddenIds,
|
|
3855
|
+
summary,
|
|
3856
|
+
progress: params.progress
|
|
3857
|
+
});
|
|
3858
|
+
}
|
|
3859
|
+
if (params.mode === "all" || params.mode === "chats") {
|
|
3860
|
+
if (friendNames.size === 0) {
|
|
3861
|
+
friendNames = await persistFriendDirectory(profile, api);
|
|
3862
|
+
}
|
|
3863
|
+
await syncDbChatsBestEffort({
|
|
3864
|
+
profile,
|
|
3865
|
+
api,
|
|
3866
|
+
selfId,
|
|
3867
|
+
count: params.count,
|
|
3868
|
+
titleById: friendNames,
|
|
3869
|
+
pinnedIds,
|
|
3870
|
+
hiddenIds,
|
|
3871
|
+
summary,
|
|
3872
|
+
progress: params.progress
|
|
3873
|
+
});
|
|
3874
|
+
}
|
|
3875
|
+
params.progress?.(
|
|
3876
|
+
`done: groups=${summary.groupsSynced}, groupMessages=${summary.groupMessagesImported}, friends=${summary.friendsSynced}, chats=${summary.chatsSynced}, dmMessages=${summary.dmMessagesImported}`
|
|
3877
|
+
);
|
|
3878
|
+
return summary;
|
|
3879
|
+
}
|
|
1930
3880
|
async function buildGroupsDetailed(api) {
|
|
1931
3881
|
const groups = await api.getAllGroups();
|
|
1932
3882
|
const ids = Object.keys(groups.gridVerMap ?? {});
|
|
@@ -2383,12 +4333,168 @@ async function fetchRecentGroupMessagesViaApi(api, threadId, count) {
|
|
|
2383
4333
|
return fetchRecentGroupMessagesViaListener(api, threadId, count);
|
|
2384
4334
|
}
|
|
2385
4335
|
async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
|
|
4336
|
+
const result = await crawlGroupHistoryViaListener(api, {
|
|
4337
|
+
threadId,
|
|
4338
|
+
limit: count,
|
|
4339
|
+
maxPages: parsePositiveIntFromEnv("OPENZCA_RECENT_GROUP_MAX_PAGES", 20),
|
|
4340
|
+
idleTimeoutMs: 12e3
|
|
4341
|
+
});
|
|
4342
|
+
return result.messages;
|
|
4343
|
+
}
|
|
4344
|
+
async function crawlGroupHistoryViaListener(api, options) {
|
|
4345
|
+
return new Promise((resolve, reject) => {
|
|
4346
|
+
let settled = false;
|
|
4347
|
+
let stopReason = "closed";
|
|
4348
|
+
const shouldCollect = options.limit != null || !options.onMessages;
|
|
4349
|
+
const collected = [];
|
|
4350
|
+
const seenMessageKeys = /* @__PURE__ */ new Set();
|
|
4351
|
+
const requestedCursors = /* @__PURE__ */ new Set();
|
|
4352
|
+
let pagesRequested = 0;
|
|
4353
|
+
let idleTimer;
|
|
4354
|
+
let processing = Promise.resolve();
|
|
4355
|
+
const toKey = (message) => {
|
|
4356
|
+
const msgId = String(message.data?.msgId ?? "");
|
|
4357
|
+
const cliMsgId = String(message.data?.cliMsgId ?? "");
|
|
4358
|
+
return `${message.threadId}:${msgId}:${cliMsgId}`;
|
|
4359
|
+
};
|
|
4360
|
+
const requestPage = (lastId) => {
|
|
4361
|
+
const cursor = String(lastId ?? "").trim();
|
|
4362
|
+
if (cursor) {
|
|
4363
|
+
if (requestedCursors.has(cursor)) return false;
|
|
4364
|
+
requestedCursors.add(cursor);
|
|
4365
|
+
}
|
|
4366
|
+
pagesRequested += 1;
|
|
4367
|
+
api.listener.requestOldMessages(ThreadType2.Group, cursor || null);
|
|
4368
|
+
return true;
|
|
4369
|
+
};
|
|
4370
|
+
const armIdleTimer = () => {
|
|
4371
|
+
if (idleTimer) {
|
|
4372
|
+
clearTimeout(idleTimer);
|
|
4373
|
+
}
|
|
4374
|
+
idleTimer = setTimeout(() => {
|
|
4375
|
+
finish(void 0, "timeout");
|
|
4376
|
+
}, options.idleTimeoutMs);
|
|
4377
|
+
};
|
|
4378
|
+
const cleanup = () => {
|
|
4379
|
+
if (idleTimer) {
|
|
4380
|
+
clearTimeout(idleTimer);
|
|
4381
|
+
}
|
|
4382
|
+
api.listener.off("connected", onConnected);
|
|
4383
|
+
api.listener.off("old_messages", onOldMessages);
|
|
4384
|
+
api.listener.off("error", onError);
|
|
4385
|
+
api.listener.off("closed", onClosed);
|
|
4386
|
+
try {
|
|
4387
|
+
api.listener.stop();
|
|
4388
|
+
} catch {
|
|
4389
|
+
}
|
|
4390
|
+
};
|
|
4391
|
+
const finish = (error, reason) => {
|
|
4392
|
+
if (settled) return;
|
|
4393
|
+
settled = true;
|
|
4394
|
+
if (reason) {
|
|
4395
|
+
stopReason = reason;
|
|
4396
|
+
}
|
|
4397
|
+
void processing.then(() => {
|
|
4398
|
+
cleanup();
|
|
4399
|
+
if (error) {
|
|
4400
|
+
reject(error);
|
|
4401
|
+
return;
|
|
4402
|
+
}
|
|
4403
|
+
resolve({
|
|
4404
|
+
messages: options.limit != null ? sortRecentMessagesNewestFirst(collected).slice(0, options.limit) : collected,
|
|
4405
|
+
stopReason,
|
|
4406
|
+
pagesRequested
|
|
4407
|
+
});
|
|
4408
|
+
}).catch((processingError) => {
|
|
4409
|
+
cleanup();
|
|
4410
|
+
reject(processingError);
|
|
4411
|
+
});
|
|
4412
|
+
};
|
|
4413
|
+
const onConnected = () => {
|
|
4414
|
+
try {
|
|
4415
|
+
armIdleTimer();
|
|
4416
|
+
requestPage(null);
|
|
4417
|
+
} catch (error) {
|
|
4418
|
+
finish(error, "closed");
|
|
4419
|
+
}
|
|
4420
|
+
};
|
|
4421
|
+
const onOldMessages = (messages, type) => {
|
|
4422
|
+
if (type !== ThreadType2.Group) return;
|
|
4423
|
+
armIdleTimer();
|
|
4424
|
+
const typedMessages = messages;
|
|
4425
|
+
processing = processing.then(async () => {
|
|
4426
|
+
const filtered = [];
|
|
4427
|
+
for (const message of typedMessages) {
|
|
4428
|
+
if (options.threadId && message.threadId !== options.threadId) {
|
|
4429
|
+
continue;
|
|
4430
|
+
}
|
|
4431
|
+
const key = toKey(message);
|
|
4432
|
+
if (seenMessageKeys.has(key)) continue;
|
|
4433
|
+
seenMessageKeys.add(key);
|
|
4434
|
+
if (shouldCollect) {
|
|
4435
|
+
collected.push(message);
|
|
4436
|
+
}
|
|
4437
|
+
filtered.push(message);
|
|
4438
|
+
}
|
|
4439
|
+
if (filtered.length > 0) {
|
|
4440
|
+
await options.onMessages?.(filtered);
|
|
4441
|
+
}
|
|
4442
|
+
await options.onPage?.({
|
|
4443
|
+
pagesRequested,
|
|
4444
|
+
filteredCount: filtered.length,
|
|
4445
|
+
collectedCount: collected.length
|
|
4446
|
+
});
|
|
4447
|
+
if (options.limit != null && collected.length >= options.limit) {
|
|
4448
|
+
finish(void 0, "limit");
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
if (typedMessages.length === 0) {
|
|
4452
|
+
finish(void 0, "exhausted");
|
|
4453
|
+
return;
|
|
4454
|
+
}
|
|
4455
|
+
if (pagesRequested >= options.maxPages) {
|
|
4456
|
+
finish(void 0, "max_pages");
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
const cursorCandidates = getRecentPageCursors(typedMessages);
|
|
4460
|
+
let requested = false;
|
|
4461
|
+
for (const cursor of cursorCandidates) {
|
|
4462
|
+
if (requestPage(cursor)) {
|
|
4463
|
+
requested = true;
|
|
4464
|
+
break;
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
if (!requested) {
|
|
4468
|
+
finish(void 0, "exhausted");
|
|
4469
|
+
}
|
|
4470
|
+
}).catch((error) => {
|
|
4471
|
+
finish(error, "closed");
|
|
4472
|
+
});
|
|
4473
|
+
};
|
|
4474
|
+
const onError = (error) => {
|
|
4475
|
+
finish(error, "closed");
|
|
4476
|
+
};
|
|
4477
|
+
const onClosed = () => {
|
|
4478
|
+
finish(void 0, "closed");
|
|
4479
|
+
};
|
|
4480
|
+
api.listener.on("connected", onConnected);
|
|
4481
|
+
api.listener.on("old_messages", onOldMessages);
|
|
4482
|
+
api.listener.on("error", onError);
|
|
4483
|
+
api.listener.on("closed", onClosed);
|
|
4484
|
+
try {
|
|
4485
|
+
api.listener.start();
|
|
4486
|
+
} catch (error) {
|
|
4487
|
+
finish(error);
|
|
4488
|
+
}
|
|
4489
|
+
});
|
|
4490
|
+
}
|
|
4491
|
+
async function fetchRecentUserMessagesViaListener(api, threadId, count) {
|
|
2386
4492
|
return new Promise((resolve, reject) => {
|
|
2387
4493
|
let settled = false;
|
|
2388
4494
|
const collected = [];
|
|
2389
4495
|
const seenMessageKeys = /* @__PURE__ */ new Set();
|
|
2390
4496
|
const requestedCursors = /* @__PURE__ */ new Set();
|
|
2391
|
-
const maxPages = parsePositiveIntFromEnv("
|
|
4497
|
+
const maxPages = parsePositiveIntFromEnv("OPENZCA_RECENT_USER_MAX_PAGES", 20);
|
|
2392
4498
|
let pagesRequested = 0;
|
|
2393
4499
|
const toKey = (message) => {
|
|
2394
4500
|
const msgId = String(message.data?.msgId ?? "");
|
|
@@ -2402,7 +4508,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
|
|
|
2402
4508
|
requestedCursors.add(cursor);
|
|
2403
4509
|
}
|
|
2404
4510
|
pagesRequested += 1;
|
|
2405
|
-
api.listener.requestOldMessages(ThreadType2.
|
|
4511
|
+
api.listener.requestOldMessages(ThreadType2.User, cursor || null);
|
|
2406
4512
|
return true;
|
|
2407
4513
|
};
|
|
2408
4514
|
const cleanup = () => {
|
|
@@ -2434,7 +4540,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
|
|
|
2434
4540
|
}
|
|
2435
4541
|
};
|
|
2436
4542
|
const onOldMessages = (messages, type) => {
|
|
2437
|
-
if (type !== ThreadType2.
|
|
4543
|
+
if (type !== ThreadType2.User) return;
|
|
2438
4544
|
const typedMessages = messages;
|
|
2439
4545
|
for (const message of typedMessages) {
|
|
2440
4546
|
if (message.threadId === threadId) {
|
|
@@ -2490,7 +4596,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
|
|
|
2490
4596
|
}
|
|
2491
4597
|
});
|
|
2492
4598
|
}
|
|
2493
|
-
async function
|
|
4599
|
+
async function fetchRecentUserMessagesAcrossThreads(api, maxMessages) {
|
|
2494
4600
|
return new Promise((resolve, reject) => {
|
|
2495
4601
|
let settled = false;
|
|
2496
4602
|
const collected = [];
|
|
@@ -2501,7 +4607,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
|
|
|
2501
4607
|
const toKey = (message) => {
|
|
2502
4608
|
const msgId = String(message.data?.msgId ?? "");
|
|
2503
4609
|
const cliMsgId = String(message.data?.cliMsgId ?? "");
|
|
2504
|
-
return `${msgId}:${cliMsgId}`;
|
|
4610
|
+
return `${message.threadId}:${msgId}:${cliMsgId}`;
|
|
2505
4611
|
};
|
|
2506
4612
|
const requestPage = (lastId) => {
|
|
2507
4613
|
const cursor = String(lastId ?? "").trim();
|
|
@@ -2532,7 +4638,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
|
|
|
2532
4638
|
reject(error);
|
|
2533
4639
|
return;
|
|
2534
4640
|
}
|
|
2535
|
-
resolve(sortRecentMessagesNewestFirst(collected).slice(0,
|
|
4641
|
+
resolve(sortRecentMessagesNewestFirst(collected).slice(0, maxMessages));
|
|
2536
4642
|
};
|
|
2537
4643
|
const onConnected = () => {
|
|
2538
4644
|
try {
|
|
@@ -2545,22 +4651,12 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
|
|
|
2545
4651
|
if (type !== ThreadType2.User) return;
|
|
2546
4652
|
const typedMessages = messages;
|
|
2547
4653
|
for (const message of typedMessages) {
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
collected.push(message);
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
|
-
if (collected.length >= count) {
|
|
2556
|
-
finish();
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
if (typedMessages.length === 0) {
|
|
2560
|
-
finish();
|
|
2561
|
-
return;
|
|
4654
|
+
const key = toKey(message);
|
|
4655
|
+
if (seenMessageKeys.has(key)) continue;
|
|
4656
|
+
seenMessageKeys.add(key);
|
|
4657
|
+
collected.push(message);
|
|
2562
4658
|
}
|
|
2563
|
-
if (pagesRequested >= maxPages) {
|
|
4659
|
+
if (collected.length >= maxMessages || typedMessages.length === 0 || pagesRequested >= maxPages) {
|
|
2564
4660
|
finish();
|
|
2565
4661
|
return;
|
|
2566
4662
|
}
|
|
@@ -2598,8 +4694,70 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
|
|
|
2598
4694
|
}
|
|
2599
4695
|
});
|
|
2600
4696
|
}
|
|
4697
|
+
function normalizeRecentMessageMentions(value) {
|
|
4698
|
+
if (!Array.isArray(value)) {
|
|
4699
|
+
return [];
|
|
4700
|
+
}
|
|
4701
|
+
const rows = [];
|
|
4702
|
+
const parseOptionalMentionInt = (input) => {
|
|
4703
|
+
if (typeof input === "number" && Number.isFinite(input)) {
|
|
4704
|
+
return Math.trunc(input);
|
|
4705
|
+
}
|
|
4706
|
+
if (typeof input === "string" && input.trim()) {
|
|
4707
|
+
const parsed = Number(input.trim());
|
|
4708
|
+
if (Number.isFinite(parsed)) {
|
|
4709
|
+
return Math.trunc(parsed);
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
return void 0;
|
|
4713
|
+
};
|
|
4714
|
+
for (const item of value) {
|
|
4715
|
+
if (!item || typeof item !== "object") continue;
|
|
4716
|
+
const record = item;
|
|
4717
|
+
const uid = normalizeCachedId(record.uid);
|
|
4718
|
+
if (!uid) continue;
|
|
4719
|
+
rows.push({
|
|
4720
|
+
uid,
|
|
4721
|
+
pos: parseOptionalMentionInt(record.pos),
|
|
4722
|
+
len: parseOptionalMentionInt(record.len),
|
|
4723
|
+
type: typeof record.type === "number" && Number.isFinite(record.type) ? Math.trunc(record.type) : typeof record.type === "string" && record.type.trim() ? Number.parseInt(record.type.trim(), 10) : void 0,
|
|
4724
|
+
rawJson: JSON.stringify(record)
|
|
4725
|
+
});
|
|
4726
|
+
}
|
|
4727
|
+
return rows;
|
|
4728
|
+
}
|
|
4729
|
+
function toDbRecordFromRecentMessage(params) {
|
|
4730
|
+
const content = params.message.data?.content;
|
|
4731
|
+
const quote = params.message.data?.quote;
|
|
4732
|
+
return normalizeInboundListenRecord({
|
|
4733
|
+
profile: params.profile,
|
|
4734
|
+
threadType: params.message.type === ThreadType2.Group ? "group" : "user",
|
|
4735
|
+
rawThreadId: params.message.threadId,
|
|
4736
|
+
senderId: params.message.data?.uidFrom,
|
|
4737
|
+
senderName: params.message.data?.dName,
|
|
4738
|
+
toId: params.message.data?.idTo,
|
|
4739
|
+
selfId: params.selfId,
|
|
4740
|
+
title: params.title,
|
|
4741
|
+
msgId: params.message.data?.msgId,
|
|
4742
|
+
cliMsgId: params.message.data?.cliMsgId,
|
|
4743
|
+
actionId: params.message.data?.actionId,
|
|
4744
|
+
timestampMs: toEpochMs(params.message.data?.ts),
|
|
4745
|
+
msgType: params.message.data?.msgType,
|
|
4746
|
+
contentText: typeof content === "string" ? content : void 0,
|
|
4747
|
+
contentJson: content && typeof content === "object" ? JSON.stringify(content) : void 0,
|
|
4748
|
+
quoteMsgId: quote?.globalMsgId != null ? String(quote.globalMsgId) : void 0,
|
|
4749
|
+
quoteCliMsgId: quote?.cliMsgId != null ? String(quote.cliMsgId) : void 0,
|
|
4750
|
+
quoteOwnerId: quote?.ownerId != null ? String(quote.ownerId) : void 0,
|
|
4751
|
+
quoteText: typeof quote?.msg === "string" ? quote.msg : void 0,
|
|
4752
|
+
mentions: normalizeRecentMessageMentions(
|
|
4753
|
+
params.message.data?.mentions
|
|
4754
|
+
),
|
|
4755
|
+
rawMessage: params.message.data,
|
|
4756
|
+
source: params.source
|
|
4757
|
+
});
|
|
4758
|
+
}
|
|
2601
4759
|
async function parseCredentialFile(filePath) {
|
|
2602
|
-
const raw = await
|
|
4760
|
+
const raw = await fs6.readFile(filePath, "utf8");
|
|
2603
4761
|
const parsed = JSON.parse(raw);
|
|
2604
4762
|
if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
|
|
2605
4763
|
throw new Error("Credential file must include imei, cookie, and userAgent.");
|
|
@@ -2620,7 +4778,7 @@ async function waitForFileContent(filePath, timeoutMs) {
|
|
|
2620
4778
|
const startedAt = Date.now();
|
|
2621
4779
|
while (Date.now() - startedAt < timeoutMs) {
|
|
2622
4780
|
try {
|
|
2623
|
-
const data = await
|
|
4781
|
+
const data = await fs6.readFile(filePath);
|
|
2624
4782
|
if (data.length > 0) {
|
|
2625
4783
|
return data;
|
|
2626
4784
|
}
|
|
@@ -2635,8 +4793,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
|
|
|
2635
4793
|
if (!scriptPath) {
|
|
2636
4794
|
throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
|
|
2637
4795
|
}
|
|
2638
|
-
const tempDir = await
|
|
2639
|
-
const targetPath =
|
|
4796
|
+
const tempDir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-qr-"));
|
|
4797
|
+
const targetPath = path6.resolve(qrPath ?? path6.join(tempDir, "qr.png"));
|
|
2640
4798
|
const child = spawn2(
|
|
2641
4799
|
process.execPath,
|
|
2642
4800
|
[scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
|
|
@@ -2914,7 +5072,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
|
|
|
2914
5072
|
if (fromType) return fromType;
|
|
2915
5073
|
try {
|
|
2916
5074
|
const parsedUrl = new URL(mediaUrl);
|
|
2917
|
-
const ext =
|
|
5075
|
+
const ext = path6.extname(parsedUrl.pathname);
|
|
2918
5076
|
if (ext) return ext;
|
|
2919
5077
|
} catch {
|
|
2920
5078
|
}
|
|
@@ -2945,20 +5103,20 @@ function parseInboundMediaFetchTimeoutMs() {
|
|
|
2945
5103
|
return Math.trunc(parsed);
|
|
2946
5104
|
}
|
|
2947
5105
|
function resolveOpenClawMediaDir() {
|
|
2948
|
-
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
2949
|
-
return
|
|
5106
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path6.join(os4.homedir(), ".openclaw");
|
|
5107
|
+
return path6.join(stateDir, "media");
|
|
2950
5108
|
}
|
|
2951
5109
|
function resolveInboundMediaDir(profile) {
|
|
2952
5110
|
const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
|
|
2953
5111
|
if (configuredRaw) {
|
|
2954
5112
|
const configured = normalizeMediaInput(configuredRaw);
|
|
2955
|
-
return
|
|
5113
|
+
return path6.isAbsolute(configured) ? configured : path6.resolve(process.cwd(), configured);
|
|
2956
5114
|
}
|
|
2957
5115
|
const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
|
|
2958
5116
|
if (legacyRequested) {
|
|
2959
|
-
return
|
|
5117
|
+
return path6.join(getProfileDir(profile), "inbound-media");
|
|
2960
5118
|
}
|
|
2961
|
-
return
|
|
5119
|
+
return path6.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
|
|
2962
5120
|
}
|
|
2963
5121
|
async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
|
|
2964
5122
|
const maxBytes = parseMaxInboundMediaBytes();
|
|
@@ -2992,11 +5150,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
|
|
|
2992
5150
|
return null;
|
|
2993
5151
|
}
|
|
2994
5152
|
const dir = resolveInboundMediaDir(profile);
|
|
2995
|
-
await
|
|
5153
|
+
await fs6.mkdir(dir, { recursive: true });
|
|
2996
5154
|
const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
|
|
2997
5155
|
const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
|
2998
|
-
const mediaPath =
|
|
2999
|
-
await
|
|
5156
|
+
const mediaPath = path6.join(dir, `${id}${ext}`);
|
|
5157
|
+
await fs6.writeFile(mediaPath, data);
|
|
3000
5158
|
return { mediaPath, mediaType };
|
|
3001
5159
|
}
|
|
3002
5160
|
async function cacheRemoteMediaEntries(params) {
|
|
@@ -3362,6 +5520,16 @@ function toEpochSeconds(input) {
|
|
|
3362
5520
|
}
|
|
3363
5521
|
return Math.floor(numeric);
|
|
3364
5522
|
}
|
|
5523
|
+
function toEpochMs(input) {
|
|
5524
|
+
const numeric = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
|
|
5525
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
5526
|
+
return Date.now();
|
|
5527
|
+
}
|
|
5528
|
+
if (numeric < 1e10) {
|
|
5529
|
+
return Math.floor(numeric * 1e3);
|
|
5530
|
+
}
|
|
5531
|
+
return Math.floor(numeric);
|
|
5532
|
+
}
|
|
3365
5533
|
function parseNonNegativeIntOption(label, value) {
|
|
3366
5534
|
if (!value || !value.trim()) return void 0;
|
|
3367
5535
|
const parsed = Number(value.trim());
|
|
@@ -3370,6 +5538,14 @@ function parseNonNegativeIntOption(label, value) {
|
|
|
3370
5538
|
}
|
|
3371
5539
|
return Math.trunc(parsed);
|
|
3372
5540
|
}
|
|
5541
|
+
function parsePositiveIntOption(label, value) {
|
|
5542
|
+
if (!value || !value.trim()) return void 0;
|
|
5543
|
+
const parsed = Number(value.trim());
|
|
5544
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
5545
|
+
throw new Error(`${label} must be a positive number.`);
|
|
5546
|
+
}
|
|
5547
|
+
return Math.trunc(parsed);
|
|
5548
|
+
}
|
|
3373
5549
|
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();
|
|
3374
5550
|
program.hook("preAction", (_parent, actionCommand) => {
|
|
3375
5551
|
if (!resolveDebugEnabled(actionCommand)) {
|
|
@@ -3488,7 +5664,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
|
|
|
3488
5664
|
auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
|
|
3489
5665
|
wrapAction(async (file, command) => {
|
|
3490
5666
|
const profile = await currentProfile(command);
|
|
3491
|
-
const credentials = file ? await parseCredentialFile(
|
|
5667
|
+
const credentials = file ? await parseCredentialFile(path6.resolve(normalizeMediaInput(file))) : toCredentials(
|
|
3492
5668
|
await loadCredentials(profile) ?? (() => {
|
|
3493
5669
|
throw new Error(
|
|
3494
5670
|
`No saved credentials for profile "${profile}". Run: openzca auth login`
|
|
@@ -3561,10 +5737,355 @@ auth.command("cache-clear").description("Clear local cache").action(
|
|
|
3561
5737
|
console.log(`Cache cleared for profile ${profile}`);
|
|
3562
5738
|
})
|
|
3563
5739
|
);
|
|
5740
|
+
var dbCmd = program.command("db").description("Profile-scoped SQLite message database");
|
|
5741
|
+
dbCmd.command("enable").option("--path <path>", "Custom SQLite file path").description("Enable local SQLite persistence for the active profile").action(
|
|
5742
|
+
wrapAction(async (opts, command) => {
|
|
5743
|
+
const profile = await currentProfile(command);
|
|
5744
|
+
await enableDb(profile, opts.path);
|
|
5745
|
+
output(await getDbStatus(profile), false);
|
|
5746
|
+
})
|
|
5747
|
+
);
|
|
5748
|
+
dbCmd.command("disable").description("Disable automatic SQLite persistence for the active profile").action(
|
|
5749
|
+
wrapAction(async (command) => {
|
|
5750
|
+
const profile = await currentProfile(command);
|
|
5751
|
+
await disableDb(profile);
|
|
5752
|
+
await closeDb(profile);
|
|
5753
|
+
output(await getDbStatus(profile), false);
|
|
5754
|
+
})
|
|
5755
|
+
);
|
|
5756
|
+
dbCmd.command("reset").option("-y, --yes", "Delete the SQLite DB file for the active profile").option("--drop-config", "Also remove the DB config file").option("-j, --json", "JSON output").description("Delete the local SQLite DB for the active profile").action(
|
|
5757
|
+
wrapAction(async (opts, command) => {
|
|
5758
|
+
if (!opts.yes) {
|
|
5759
|
+
const confirmed = await confirmDestructiveAction(
|
|
5760
|
+
"Reset the local SQLite DB for the active profile?"
|
|
5761
|
+
);
|
|
5762
|
+
if (!confirmed) {
|
|
5763
|
+
console.log("Cancelled.");
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
}
|
|
5767
|
+
const profile = await currentProfile(command);
|
|
5768
|
+
const dbPath = await resolveDbPath(profile);
|
|
5769
|
+
const configPath = getDbConfigPath(profile);
|
|
5770
|
+
await closeDb(profile);
|
|
5771
|
+
const removedPaths = [];
|
|
5772
|
+
const deleteIfExists = async (filename) => {
|
|
5773
|
+
try {
|
|
5774
|
+
await fs6.unlink(filename);
|
|
5775
|
+
removedPaths.push(filename);
|
|
5776
|
+
} catch (error) {
|
|
5777
|
+
if (error.code !== "ENOENT") {
|
|
5778
|
+
throw error;
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
};
|
|
5782
|
+
await deleteIfExists(dbPath);
|
|
5783
|
+
await deleteIfExists(`${dbPath}-wal`);
|
|
5784
|
+
await deleteIfExists(`${dbPath}-shm`);
|
|
5785
|
+
if (opts.dropConfig) {
|
|
5786
|
+
await deleteIfExists(configPath);
|
|
5787
|
+
}
|
|
5788
|
+
const status = await getDbStatus(profile);
|
|
5789
|
+
output(
|
|
5790
|
+
{
|
|
5791
|
+
profile,
|
|
5792
|
+
removedPaths,
|
|
5793
|
+
droppedConfig: Boolean(opts.dropConfig),
|
|
5794
|
+
status: {
|
|
5795
|
+
enabled: status.enabled,
|
|
5796
|
+
path: status.path,
|
|
5797
|
+
exists: status.exists,
|
|
5798
|
+
messageCount: status.messageCount,
|
|
5799
|
+
threadCount: status.threadCount,
|
|
5800
|
+
groupCount: status.groupCount,
|
|
5801
|
+
userCount: status.userCount
|
|
5802
|
+
}
|
|
5803
|
+
},
|
|
5804
|
+
Boolean(opts.json)
|
|
5805
|
+
);
|
|
5806
|
+
})
|
|
5807
|
+
);
|
|
5808
|
+
dbCmd.command("status").option("-j, --json", "JSON output").description("Show DB status for the active profile").action(
|
|
5809
|
+
wrapAction(async (opts, command) => {
|
|
5810
|
+
const profile = await currentProfile(command);
|
|
5811
|
+
const config = await readDbConfig(profile);
|
|
5812
|
+
const status = await getDbStatus(profile);
|
|
5813
|
+
const syncRows = await listSyncState({ profile });
|
|
5814
|
+
output(
|
|
5815
|
+
{
|
|
5816
|
+
profile,
|
|
5817
|
+
enabled: status.enabled,
|
|
5818
|
+
path: await resolveDbPath(profile),
|
|
5819
|
+
exists: status.exists,
|
|
5820
|
+
configuredPath: config.path ?? null,
|
|
5821
|
+
messageCount: status.messageCount,
|
|
5822
|
+
threadCount: status.threadCount,
|
|
5823
|
+
groupCount: status.groupCount,
|
|
5824
|
+
userCount: status.userCount,
|
|
5825
|
+
syncStates: {
|
|
5826
|
+
total: syncRows.length,
|
|
5827
|
+
synced: syncRows.filter((row) => row.status === "synced").length,
|
|
5828
|
+
errors: syncRows.filter((row) => row.status === "error").length
|
|
5829
|
+
},
|
|
5830
|
+
lastMessageAtMs: status.lastMessageAtMs ?? null,
|
|
5831
|
+
updatedAt: status.updatedAt ?? null
|
|
5832
|
+
},
|
|
5833
|
+
Boolean(opts.json)
|
|
5834
|
+
);
|
|
5835
|
+
})
|
|
5836
|
+
);
|
|
5837
|
+
var dbMe = dbCmd.command("me").description("Query stored self profile data");
|
|
5838
|
+
dbMe.command("info").option("-j, --json", "JSON output").description("Show stored self profile info").action(
|
|
5839
|
+
wrapAction(async (opts, command) => {
|
|
5840
|
+
const profile = await currentProfile(command);
|
|
5841
|
+
const row = await getSelfProfile(profile);
|
|
5842
|
+
if (!row?.info) {
|
|
5843
|
+
throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
|
|
5844
|
+
}
|
|
5845
|
+
output(row.info, Boolean(opts.json));
|
|
5846
|
+
})
|
|
5847
|
+
);
|
|
5848
|
+
dbMe.command("id").description("Show stored self user ID").action(
|
|
5849
|
+
wrapAction(async (command) => {
|
|
5850
|
+
const profile = await currentProfile(command);
|
|
5851
|
+
const row = await getSelfProfile(profile);
|
|
5852
|
+
if (!row?.userId) {
|
|
5853
|
+
throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
|
|
5854
|
+
}
|
|
5855
|
+
console.log(row.userId);
|
|
5856
|
+
})
|
|
5857
|
+
);
|
|
5858
|
+
var dbGroup = dbCmd.command("group").description("Query stored group data");
|
|
5859
|
+
dbGroup.command("list").option("-j, --json", "JSON output").description("List groups stored in the local DB").action(
|
|
5860
|
+
wrapAction(async (opts, command) => {
|
|
5861
|
+
const profile = await currentProfile(command);
|
|
5862
|
+
output(await listGroups(profile), Boolean(opts.json));
|
|
5863
|
+
})
|
|
5864
|
+
);
|
|
5865
|
+
dbGroup.command("info <groupId>").option("-j, --json", "JSON output").description("Show stored info for a group").action(
|
|
5866
|
+
wrapAction(async (groupId, opts, command) => {
|
|
5867
|
+
const profile = await currentProfile(command);
|
|
5868
|
+
const row = await getThreadInfo({ profile, threadId: groupId, threadType: "group" });
|
|
5869
|
+
if (!row) {
|
|
5870
|
+
throw new Error(`Group not found in DB: ${groupId}`);
|
|
5871
|
+
}
|
|
5872
|
+
output(row, Boolean(opts.json));
|
|
5873
|
+
})
|
|
5874
|
+
);
|
|
5875
|
+
dbGroup.command("members <groupId>").option("-j, --json", "JSON output").description("List stored members for a group").action(
|
|
5876
|
+
wrapAction(async (groupId, opts, command) => {
|
|
5877
|
+
const profile = await currentProfile(command);
|
|
5878
|
+
output(await listThreadMembers({ profile, threadId: groupId }), Boolean(opts.json));
|
|
5879
|
+
})
|
|
5880
|
+
);
|
|
5881
|
+
dbGroup.command("messages <groupId>").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description("List stored messages for a group").action(
|
|
5882
|
+
wrapAction(async (groupId, opts, command) => {
|
|
5883
|
+
const profile = await currentProfile(command);
|
|
5884
|
+
const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
|
|
5885
|
+
const rows = await listMessages({
|
|
5886
|
+
profile,
|
|
5887
|
+
threadId: groupId,
|
|
5888
|
+
threadType: "group",
|
|
5889
|
+
sinceMs,
|
|
5890
|
+
untilMs,
|
|
5891
|
+
limit,
|
|
5892
|
+
newestFirst
|
|
5893
|
+
});
|
|
5894
|
+
output(
|
|
5895
|
+
{
|
|
5896
|
+
groupId,
|
|
5897
|
+
count: rows.length,
|
|
5898
|
+
messages: rows
|
|
5899
|
+
},
|
|
5900
|
+
Boolean(opts.json)
|
|
5901
|
+
);
|
|
5902
|
+
})
|
|
5903
|
+
);
|
|
5904
|
+
var dbFriend = dbCmd.command("friend").description("Query stored friend directory data");
|
|
5905
|
+
dbFriend.command("list").option("-j, --json", "JSON output").description("List friends stored in the local DB").action(
|
|
5906
|
+
wrapAction(async (opts, command) => {
|
|
5907
|
+
const profile = await currentProfile(command);
|
|
5908
|
+
output(await listFriends(profile), Boolean(opts.json));
|
|
5909
|
+
})
|
|
5910
|
+
);
|
|
5911
|
+
dbFriend.command("find <query>").option("-j, --json", "JSON output").description("Find stored friends by ID or name").action(
|
|
5912
|
+
wrapAction(async (query, opts, command) => {
|
|
5913
|
+
const profile = await currentProfile(command);
|
|
5914
|
+
output(await findFriends({ profile, query }), Boolean(opts.json));
|
|
5915
|
+
})
|
|
5916
|
+
);
|
|
5917
|
+
dbFriend.command("info <userId>").option("-j, --json", "JSON output").description("Show stored info for a friend").action(
|
|
5918
|
+
wrapAction(async (userId, opts, command) => {
|
|
5919
|
+
const profile = await currentProfile(command);
|
|
5920
|
+
const row = await getFriendInfo({ profile, userId });
|
|
5921
|
+
if (!row) {
|
|
5922
|
+
throw new Error(`Friend not found in DB: ${userId}`);
|
|
5923
|
+
}
|
|
5924
|
+
output(row, Boolean(opts.json));
|
|
5925
|
+
})
|
|
5926
|
+
);
|
|
5927
|
+
dbFriend.command("messages <userId>").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description("List stored direct-message rows for a friend").action(
|
|
5928
|
+
wrapAction(async (userId, opts, command) => {
|
|
5929
|
+
const profile = await currentProfile(command);
|
|
5930
|
+
const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
|
|
5931
|
+
const rows = await listMessages({
|
|
5932
|
+
profile,
|
|
5933
|
+
threadId: userId,
|
|
5934
|
+
threadType: "user",
|
|
5935
|
+
sinceMs,
|
|
5936
|
+
untilMs,
|
|
5937
|
+
limit,
|
|
5938
|
+
newestFirst
|
|
5939
|
+
});
|
|
5940
|
+
output(
|
|
5941
|
+
{
|
|
5942
|
+
userId,
|
|
5943
|
+
count: rows.length,
|
|
5944
|
+
messages: rows
|
|
5945
|
+
},
|
|
5946
|
+
Boolean(opts.json)
|
|
5947
|
+
);
|
|
5948
|
+
})
|
|
5949
|
+
);
|
|
5950
|
+
var dbChat = dbCmd.command("chat").description("Query stored conversation data");
|
|
5951
|
+
dbChat.command("list").option("-j, --json", "JSON output").description("List chats stored in the local DB").action(
|
|
5952
|
+
wrapAction(async (opts, command) => {
|
|
5953
|
+
const profile = await currentProfile(command);
|
|
5954
|
+
output(await listChats(profile), shouldOutputJson(opts));
|
|
5955
|
+
})
|
|
5956
|
+
);
|
|
5957
|
+
dbChat.command("info <chatId>").option("-g, --group", "Read as a group chat").option("-j, --json", "JSON output").description("Show stored info for a chat").action(
|
|
5958
|
+
wrapAction(async (chatId, opts, command) => {
|
|
5959
|
+
const profile = await currentProfile(command);
|
|
5960
|
+
const row = await getThreadInfo({
|
|
5961
|
+
profile,
|
|
5962
|
+
threadId: chatId,
|
|
5963
|
+
threadType: opts.group ? "group" : void 0
|
|
5964
|
+
});
|
|
5965
|
+
if (!row) {
|
|
5966
|
+
throw new Error(`Chat not found in DB: ${chatId}`);
|
|
5967
|
+
}
|
|
5968
|
+
output(row, shouldOutputJson(opts));
|
|
5969
|
+
})
|
|
5970
|
+
);
|
|
5971
|
+
dbChat.command("messages <chatId>").option("-g, --group", "Read as a group chat").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description("List stored messages for a chat").action(
|
|
5972
|
+
wrapAction(async (chatId, opts, command) => {
|
|
5973
|
+
const profile = await currentProfile(command);
|
|
5974
|
+
const threadType = await resolveStoredChatThreadType(profile, chatId, opts.group);
|
|
5975
|
+
const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
|
|
5976
|
+
const rows = await listMessages({
|
|
5977
|
+
profile,
|
|
5978
|
+
threadId: chatId,
|
|
5979
|
+
threadType,
|
|
5980
|
+
sinceMs,
|
|
5981
|
+
untilMs,
|
|
5982
|
+
limit,
|
|
5983
|
+
newestFirst
|
|
5984
|
+
});
|
|
5985
|
+
output(
|
|
5986
|
+
{
|
|
5987
|
+
chatId,
|
|
5988
|
+
threadType,
|
|
5989
|
+
count: rows.length,
|
|
5990
|
+
messages: rows
|
|
5991
|
+
},
|
|
5992
|
+
shouldOutputJson(opts)
|
|
5993
|
+
);
|
|
5994
|
+
})
|
|
5995
|
+
);
|
|
5996
|
+
var dbMessage = dbCmd.command("message").description("Query stored messages");
|
|
5997
|
+
dbMessage.command("get <id>").option("-j, --json", "JSON output").description("Read one stored message by msgId, cliMsgId, or internal uid").action(
|
|
5998
|
+
wrapAction(async (id, opts, command) => {
|
|
5999
|
+
const profile = await currentProfile(command);
|
|
6000
|
+
const row = await getMessageById({ profile, id });
|
|
6001
|
+
if (!row) {
|
|
6002
|
+
throw new Error(`Message not found in DB: ${id}`);
|
|
6003
|
+
}
|
|
6004
|
+
output(row, Boolean(opts.json));
|
|
6005
|
+
})
|
|
6006
|
+
);
|
|
6007
|
+
var dbSync = dbCmd.command("sync").description("Sync discoverable data into the local DB");
|
|
6008
|
+
dbSync.enablePositionalOptions();
|
|
6009
|
+
dbSync.option("-n, --count <count>", "Recent DM/chat messages to fetch per window", "200").option("-j, --json", "JSON output").action(
|
|
6010
|
+
wrapAction(async (opts, command) => {
|
|
6011
|
+
const count = resolveSyncWindowCount(opts.count);
|
|
6012
|
+
const progress = createSyncProgressReporter();
|
|
6013
|
+
const summary = await runDbSync({
|
|
6014
|
+
command,
|
|
6015
|
+
mode: "all",
|
|
6016
|
+
count,
|
|
6017
|
+
progress
|
|
6018
|
+
});
|
|
6019
|
+
output(summary, Boolean(opts.json));
|
|
6020
|
+
})
|
|
6021
|
+
);
|
|
6022
|
+
dbSync.command("all").option("-n, --count <count>", "Recent DM/chat messages to fetch per window", "200").option("-j, --json", "JSON output").description("Sync full group history, friend directory, and recent DM/chat windows").action(
|
|
6023
|
+
wrapAction(async (_opts, command) => {
|
|
6024
|
+
const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
|
|
6025
|
+
output(
|
|
6026
|
+
await runDbSync({ command, mode: "all", count, progress: createSyncProgressReporter() }),
|
|
6027
|
+
readCliFlag(["--json", "-j"])
|
|
6028
|
+
);
|
|
6029
|
+
})
|
|
6030
|
+
);
|
|
6031
|
+
dbSync.command("groups").option("-j, --json", "JSON output").description("Sync group directory, members, and full group history").action(
|
|
6032
|
+
wrapAction(async (_opts, command) => {
|
|
6033
|
+
output(
|
|
6034
|
+
await runDbSync({ command, mode: "groups", count: 0, progress: createSyncProgressReporter() }),
|
|
6035
|
+
readCliFlag(["--json", "-j"])
|
|
6036
|
+
);
|
|
6037
|
+
})
|
|
6038
|
+
);
|
|
6039
|
+
dbSync.command("friends").option("-j, --json", "JSON output").description("Sync friend directory only").action(
|
|
6040
|
+
wrapAction(async (_opts, command) => {
|
|
6041
|
+
output(
|
|
6042
|
+
await runDbSync({ command, mode: "friends", count: 0, progress: createSyncProgressReporter() }),
|
|
6043
|
+
readCliFlag(["--json", "-j"])
|
|
6044
|
+
);
|
|
6045
|
+
})
|
|
6046
|
+
);
|
|
6047
|
+
dbSync.command("chats").option("-n, --count <count>", "Recent messages to fetch per scan/window", "200").option("-j, --json", "JSON output").description("Sync discoverable chat windows (DM/chat sync is best-effort)").action(
|
|
6048
|
+
wrapAction(async (_opts, command) => {
|
|
6049
|
+
const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
|
|
6050
|
+
output(
|
|
6051
|
+
await runDbSync({ command, mode: "chats", count, progress: createSyncProgressReporter() }),
|
|
6052
|
+
readCliFlag(["--json", "-j"])
|
|
6053
|
+
);
|
|
6054
|
+
})
|
|
6055
|
+
);
|
|
6056
|
+
dbSync.command("group <groupId>").option("-j, --json", "JSON output").description("Sync one group with full group history").action(
|
|
6057
|
+
wrapAction(async (groupId, _opts, command) => {
|
|
6058
|
+
output(
|
|
6059
|
+
await runDbSync({
|
|
6060
|
+
command,
|
|
6061
|
+
mode: "group",
|
|
6062
|
+
count: 0,
|
|
6063
|
+
groupId,
|
|
6064
|
+
progress: createSyncProgressReporter()
|
|
6065
|
+
}),
|
|
6066
|
+
readCliFlag(["--json", "-j"])
|
|
6067
|
+
);
|
|
6068
|
+
})
|
|
6069
|
+
);
|
|
6070
|
+
dbSync.command("chat <chatId>").option("-n, --count <count>", "Recent messages to fetch for this chat", "200").option("-j, --json", "JSON output").description("Sync one chat (best-effort for direct-message history)").action(
|
|
6071
|
+
wrapAction(async (chatId, _opts, command) => {
|
|
6072
|
+
const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
|
|
6073
|
+
output(
|
|
6074
|
+
await runDbSync({
|
|
6075
|
+
command,
|
|
6076
|
+
mode: "chat",
|
|
6077
|
+
count,
|
|
6078
|
+
threadId: chatId,
|
|
6079
|
+
progress: createSyncProgressReporter()
|
|
6080
|
+
}),
|
|
6081
|
+
readCliFlag(["--json", "-j"])
|
|
6082
|
+
);
|
|
6083
|
+
})
|
|
6084
|
+
);
|
|
3564
6085
|
var msg = program.command("msg").description("Messaging commands");
|
|
3565
6086
|
msg.command("send <threadId> <message>").option("-g, --group", "Send to group").option("--raw", "Send raw text without parsing formatting markers").description("Send text message with formatting (**bold** *italic* __bold__ ~~strike~~ {underline}text{/underline} {red}color{/red} {big}size{/big} lists indents). Group sends also resolve unique @Name/@userId mentions.").action(
|
|
3566
6087
|
wrapAction(async (threadId, message, opts, command) => {
|
|
3567
|
-
const { api } = await requireApi(command);
|
|
6088
|
+
const { api, profile } = await requireApi(command);
|
|
3568
6089
|
const threadType = asThreadType(opts.group);
|
|
3569
6090
|
const payload = await buildTextSendPayload({
|
|
3570
6091
|
message,
|
|
@@ -3575,12 +6096,26 @@ msg.command("send <threadId> <message>").option("-g, --group", "Send to group").
|
|
|
3575
6096
|
});
|
|
3576
6097
|
const response = await api.sendMessage(payload, threadId, threadType);
|
|
3577
6098
|
output(response, false);
|
|
6099
|
+
if (await shouldWriteToDb(profile)) {
|
|
6100
|
+
scheduleDbWrite(profile, command, "msg.send.db.persist_error", async () => {
|
|
6101
|
+
await persistOutgoingMessageBestEffort({
|
|
6102
|
+
profile,
|
|
6103
|
+
api,
|
|
6104
|
+
threadId,
|
|
6105
|
+
group: opts.group,
|
|
6106
|
+
text: message,
|
|
6107
|
+
msgType: "text",
|
|
6108
|
+
response,
|
|
6109
|
+
rawPayload: payload
|
|
6110
|
+
});
|
|
6111
|
+
});
|
|
6112
|
+
}
|
|
3578
6113
|
})
|
|
3579
6114
|
);
|
|
3580
6115
|
msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (repeatable)", collectValues, []).option("-m, --message <message>", "Caption").option("-g, --group", "Send to group").description("Send image(s) from file or URL").action(
|
|
3581
6116
|
wrapAction(
|
|
3582
6117
|
async (threadId, file, opts, command) => {
|
|
3583
|
-
const { api } = await requireApi(command);
|
|
6118
|
+
const { api, profile } = await requireApi(command);
|
|
3584
6119
|
const normalizedFile = file ? normalizeMediaInput(file) : void 0;
|
|
3585
6120
|
const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
|
|
3586
6121
|
const urlInputs = files.filter((entry) => isHttpUrl(entry));
|
|
@@ -3611,6 +6146,28 @@ msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (rep
|
|
|
3611
6146
|
asThreadType(opts.group)
|
|
3612
6147
|
);
|
|
3613
6148
|
output(response, false);
|
|
6149
|
+
if (await shouldWriteToDb(profile)) {
|
|
6150
|
+
scheduleDbWrite(profile, command, "msg.image.db.persist_error", async () => {
|
|
6151
|
+
await persistOutgoingMessageBestEffort({
|
|
6152
|
+
profile,
|
|
6153
|
+
api,
|
|
6154
|
+
threadId,
|
|
6155
|
+
group: opts.group,
|
|
6156
|
+
text: opts.message ?? "",
|
|
6157
|
+
msgType: "image",
|
|
6158
|
+
response,
|
|
6159
|
+
rawPayload: {
|
|
6160
|
+
msg: opts.message ?? "",
|
|
6161
|
+
attachments
|
|
6162
|
+
},
|
|
6163
|
+
media: attachments.map((item) => ({
|
|
6164
|
+
mediaKind: "image",
|
|
6165
|
+
mediaPath: isHttpUrl(item) ? void 0 : item,
|
|
6166
|
+
mediaUrl: isHttpUrl(item) ? item : void 0
|
|
6167
|
+
}))
|
|
6168
|
+
});
|
|
6169
|
+
});
|
|
6170
|
+
}
|
|
3614
6171
|
} finally {
|
|
3615
6172
|
await downloaded.cleanup();
|
|
3616
6173
|
}
|
|
@@ -3691,6 +6248,30 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
|
|
|
3691
6248
|
command
|
|
3692
6249
|
);
|
|
3693
6250
|
output(response2, false);
|
|
6251
|
+
if (await shouldWriteToDb(profile)) {
|
|
6252
|
+
scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
|
|
6253
|
+
await persistOutgoingMessageBestEffort({
|
|
6254
|
+
profile,
|
|
6255
|
+
api,
|
|
6256
|
+
threadId,
|
|
6257
|
+
group: opts.group,
|
|
6258
|
+
text: opts.message ?? "",
|
|
6259
|
+
msgType: "video",
|
|
6260
|
+
response: response2,
|
|
6261
|
+
rawPayload: {
|
|
6262
|
+
msg: opts.message ?? "",
|
|
6263
|
+
videoPath: attachments[0],
|
|
6264
|
+
thumbnailPath: thumbnailPath ?? null
|
|
6265
|
+
},
|
|
6266
|
+
media: [
|
|
6267
|
+
{
|
|
6268
|
+
mediaKind: "video",
|
|
6269
|
+
mediaPath: attachments[0]
|
|
6270
|
+
}
|
|
6271
|
+
]
|
|
6272
|
+
});
|
|
6273
|
+
});
|
|
6274
|
+
}
|
|
3694
6275
|
return;
|
|
3695
6276
|
} catch (error) {
|
|
3696
6277
|
writeDebugLine(
|
|
@@ -3729,6 +6310,28 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
|
|
|
3729
6310
|
)
|
|
3730
6311
|
);
|
|
3731
6312
|
output(response, false);
|
|
6313
|
+
if (await shouldWriteToDb(profile)) {
|
|
6314
|
+
scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
|
|
6315
|
+
await persistOutgoingMessageBestEffort({
|
|
6316
|
+
profile,
|
|
6317
|
+
api,
|
|
6318
|
+
threadId,
|
|
6319
|
+
group: opts.group,
|
|
6320
|
+
text: opts.message ?? "",
|
|
6321
|
+
msgType: "video",
|
|
6322
|
+
response,
|
|
6323
|
+
rawPayload: {
|
|
6324
|
+
msg: opts.message ?? "",
|
|
6325
|
+
attachments
|
|
6326
|
+
},
|
|
6327
|
+
media: attachments.map((item) => ({
|
|
6328
|
+
mediaKind: "video",
|
|
6329
|
+
mediaPath: isHttpUrl(item) ? void 0 : item,
|
|
6330
|
+
mediaUrl: isHttpUrl(item) ? item : void 0
|
|
6331
|
+
}))
|
|
6332
|
+
});
|
|
6333
|
+
});
|
|
6334
|
+
}
|
|
3732
6335
|
} finally {
|
|
3733
6336
|
await downloaded.cleanup();
|
|
3734
6337
|
await downloadedThumbnail.cleanup();
|
|
@@ -3739,7 +6342,7 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
|
|
|
3739
6342
|
msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (repeatable)", collectValues, []).option("-g, --group", "Send to group").description("Send voice message from file or URL").action(
|
|
3740
6343
|
wrapAction(
|
|
3741
6344
|
async (threadId, file, opts, command) => {
|
|
3742
|
-
const { api } = await requireApi(command);
|
|
6345
|
+
const { api, profile } = await requireApi(command);
|
|
3743
6346
|
const type = asThreadType(opts.group);
|
|
3744
6347
|
const normalizedFile = file ? normalizeMediaInput(file) : void 0;
|
|
3745
6348
|
const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
|
|
@@ -3775,6 +6378,24 @@ msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (rep
|
|
|
3775
6378
|
);
|
|
3776
6379
|
}
|
|
3777
6380
|
output(results, false);
|
|
6381
|
+
if (await shouldWriteToDb(profile)) {
|
|
6382
|
+
scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
|
|
6383
|
+
await persistOutgoingMessageBestEffort({
|
|
6384
|
+
profile,
|
|
6385
|
+
api,
|
|
6386
|
+
threadId,
|
|
6387
|
+
group: opts.group,
|
|
6388
|
+
msgType: "voice",
|
|
6389
|
+
response: results,
|
|
6390
|
+
rawPayload: uploaded,
|
|
6391
|
+
media: uploaded.map((item) => ({
|
|
6392
|
+
mediaKind: "voice",
|
|
6393
|
+
mediaUrl: "fileUrl" in item && typeof item.fileUrl === "string" ? item.fileUrl : void 0,
|
|
6394
|
+
rawJson: JSON.stringify(item)
|
|
6395
|
+
}))
|
|
6396
|
+
});
|
|
6397
|
+
});
|
|
6398
|
+
}
|
|
3778
6399
|
} finally {
|
|
3779
6400
|
await downloaded.cleanup();
|
|
3780
6401
|
}
|
|
@@ -3805,9 +6426,23 @@ msg.command("sticker <threadId> <stickerId>").option("-g, --group", "Send to gro
|
|
|
3805
6426
|
);
|
|
3806
6427
|
msg.command("link <threadId> <url>").option("-g, --group", "Send to group").description("Send link").action(
|
|
3807
6428
|
wrapAction(async (threadId, url, opts, command) => {
|
|
3808
|
-
const { api } = await requireApi(command);
|
|
6429
|
+
const { api, profile } = await requireApi(command);
|
|
3809
6430
|
const response = await api.sendLink({ link: url }, threadId, asThreadType(opts.group));
|
|
3810
6431
|
output(response, false);
|
|
6432
|
+
if (await shouldWriteToDb(profile)) {
|
|
6433
|
+
scheduleDbWrite(profile, command, "msg.link.db.persist_error", async () => {
|
|
6434
|
+
await persistOutgoingMessageBestEffort({
|
|
6435
|
+
profile,
|
|
6436
|
+
api,
|
|
6437
|
+
threadId,
|
|
6438
|
+
group: opts.group,
|
|
6439
|
+
text: url,
|
|
6440
|
+
msgType: "link",
|
|
6441
|
+
response,
|
|
6442
|
+
rawPayload: { link: url }
|
|
6443
|
+
});
|
|
6444
|
+
});
|
|
6445
|
+
}
|
|
3811
6446
|
})
|
|
3812
6447
|
);
|
|
3813
6448
|
msg.command("card <threadId> <contactId>").option("-g, --group", "Send to group").description("Send contact card").action(
|
|
@@ -4020,35 +6655,47 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
|
|
|
4020
6655
|
}
|
|
4021
6656
|
)
|
|
4022
6657
|
);
|
|
4023
|
-
msg.command("recent <threadId>").option("-g, --group", "List recent messages for group thread").option("-n, --count <count>", "Number of messages (
|
|
6658
|
+
msg.command("recent <threadId>").option("-g, --group", "List recent messages for group thread").option("-n, --count <count>", "Number of messages", "20").option("--source <source>", "Message source: live, db, or auto", "live").option("-j, --json", "JSON output").description("List recent messages (group uses direct history API when available)").action(
|
|
4024
6659
|
wrapAction(
|
|
4025
6660
|
async (threadId, opts, command) => {
|
|
4026
|
-
const { api } = await requireApi(command);
|
|
6661
|
+
const { api, profile } = await requireApi(command);
|
|
4027
6662
|
const parsedCount = Number(opts.count);
|
|
4028
6663
|
const count = Number.isFinite(parsedCount) ? Math.min(Math.max(Math.trunc(parsedCount), 1), 200) : 20;
|
|
4029
6664
|
const threadType = opts.group ? ThreadType2.Group : ThreadType2.User;
|
|
4030
|
-
const
|
|
4031
|
-
|
|
6665
|
+
const source = (opts.source ?? "live").trim().toLowerCase();
|
|
6666
|
+
if (!["live", "db", "auto"].includes(source)) {
|
|
6667
|
+
throw new Error("--source must be one of: live, db, auto");
|
|
6668
|
+
}
|
|
6669
|
+
let rows = source === "db" || source === "auto" ? await listRecentMessages({
|
|
6670
|
+
profile,
|
|
4032
6671
|
threadId,
|
|
6672
|
+
threadType: opts.group ? "group" : "user",
|
|
4033
6673
|
count
|
|
4034
|
-
);
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
ts: message.data.ts,
|
|
4043
|
-
msgType: message.data.msgType,
|
|
4044
|
-
undo: {
|
|
6674
|
+
}) : [];
|
|
6675
|
+
if (source === "live" || source === "auto" && rows.length === 0) {
|
|
6676
|
+
const messages = opts.group ? await fetchRecentGroupMessagesViaApi(api, threadId, count) : await fetchRecentUserMessagesViaListener(
|
|
6677
|
+
api,
|
|
6678
|
+
threadId,
|
|
6679
|
+
count
|
|
6680
|
+
);
|
|
6681
|
+
rows = messages.map((message) => ({
|
|
4045
6682
|
msgId: message.data.msgId,
|
|
4046
6683
|
cliMsgId: message.data.cliMsgId,
|
|
4047
6684
|
threadId: message.threadId || threadId,
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
6685
|
+
threadType: message.type === ThreadType2.Group ? "group" : "user",
|
|
6686
|
+
senderId: message.data.uidFrom,
|
|
6687
|
+
senderName: message.data.dName ?? "",
|
|
6688
|
+
ts: message.data.ts,
|
|
6689
|
+
msgType: message.data.msgType,
|
|
6690
|
+
undo: {
|
|
6691
|
+
msgId: message.data.msgId,
|
|
6692
|
+
cliMsgId: message.data.cliMsgId,
|
|
6693
|
+
threadId: message.threadId || threadId,
|
|
6694
|
+
group: message.type === ThreadType2.Group
|
|
6695
|
+
},
|
|
6696
|
+
content: typeof message.data.content === "string" ? message.data.content : JSON.stringify(message.data.content)
|
|
6697
|
+
}));
|
|
6698
|
+
}
|
|
4052
6699
|
if (opts.json) {
|
|
4053
6700
|
output(
|
|
4054
6701
|
{
|
|
@@ -4678,7 +7325,7 @@ me.command("last-online <userId>").description("Get last online of a user").acti
|
|
|
4678
7325
|
output(await api.lastOnline(userId), false);
|
|
4679
7326
|
})
|
|
4680
7327
|
);
|
|
4681
|
-
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(
|
|
7328
|
+
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(
|
|
4682
7329
|
"--supervised",
|
|
4683
7330
|
"Supervisor mode (disable internal retry ownership; emit lifecycle events in --raw)"
|
|
4684
7331
|
).option(
|
|
@@ -4719,6 +7366,8 @@ program.command("listen").description("Listen for real-time incoming messages").
|
|
|
4719
7366
|
process.env.OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA
|
|
4720
7367
|
);
|
|
4721
7368
|
const sessionId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
7369
|
+
const selfId = api.getOwnId();
|
|
7370
|
+
const dbWriteEnabled = await shouldWriteToDb(profile, getDbWriteOverride(opts));
|
|
4722
7371
|
const emitLifecycle = (event, fields) => {
|
|
4723
7372
|
if (!lifecycleEventsEnabled) return;
|
|
4724
7373
|
console.log(
|
|
@@ -4946,6 +7595,7 @@ ${replyContextText}` : replyContextText;
|
|
|
4946
7595
|
});
|
|
4947
7596
|
const mentionIds = mentions.map((item) => item.uid);
|
|
4948
7597
|
const timestamp = toEpochSeconds(message.data.ts);
|
|
7598
|
+
const timestampMs = toEpochMs(message.data.ts);
|
|
4949
7599
|
const payload = {
|
|
4950
7600
|
threadId: message.threadId,
|
|
4951
7601
|
targetId: message.threadId,
|
|
@@ -5011,6 +7661,52 @@ ${replyContextText}` : replyContextText;
|
|
|
5011
7661
|
toId,
|
|
5012
7662
|
ts: message.data.ts
|
|
5013
7663
|
};
|
|
7664
|
+
if (dbWriteEnabled) {
|
|
7665
|
+
const mediaForDb = mediaEntries.map((entry) => ({
|
|
7666
|
+
mediaKind: mediaKind ?? void 0,
|
|
7667
|
+
mediaUrl: entry.mediaUrl,
|
|
7668
|
+
mediaPath: entry.mediaPath,
|
|
7669
|
+
mediaType: entry.mediaType,
|
|
7670
|
+
rawJson: JSON.stringify(entry)
|
|
7671
|
+
}));
|
|
7672
|
+
const mentionsForDb = mentions.map((mention) => ({
|
|
7673
|
+
uid: mention.uid,
|
|
7674
|
+
pos: mention.pos,
|
|
7675
|
+
len: mention.len,
|
|
7676
|
+
type: mention.type,
|
|
7677
|
+
rawJson: JSON.stringify(mention)
|
|
7678
|
+
}));
|
|
7679
|
+
scheduleDbWrite(profile, command, "listen.db.persist_error", async () => {
|
|
7680
|
+
await persistMessage(
|
|
7681
|
+
normalizeInboundListenRecord({
|
|
7682
|
+
profile,
|
|
7683
|
+
threadType: chatType,
|
|
7684
|
+
rawThreadId: message.threadId,
|
|
7685
|
+
senderId,
|
|
7686
|
+
senderName: senderDisplayName,
|
|
7687
|
+
toId,
|
|
7688
|
+
selfId,
|
|
7689
|
+
title: threadName,
|
|
7690
|
+
msgId: message.data.msgId,
|
|
7691
|
+
cliMsgId: message.data.cliMsgId,
|
|
7692
|
+
actionId: getStringCandidate(messageData, ["actionId"]),
|
|
7693
|
+
timestampMs,
|
|
7694
|
+
msgType: msgType || void 0,
|
|
7695
|
+
contentText: processedText || rawText || void 0,
|
|
7696
|
+
contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
|
|
7697
|
+
quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
|
|
7698
|
+
quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
|
|
7699
|
+
quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
|
|
7700
|
+
quoteText: quote?.msg,
|
|
7701
|
+
media: mediaForDb,
|
|
7702
|
+
mentions: mentionsForDb,
|
|
7703
|
+
rawMessage: message.data,
|
|
7704
|
+
rawPayload: payload,
|
|
7705
|
+
source: "listen"
|
|
7706
|
+
})
|
|
7707
|
+
);
|
|
7708
|
+
});
|
|
7709
|
+
}
|
|
5014
7710
|
if (opts.raw) {
|
|
5015
7711
|
console.log(JSON.stringify(payload));
|
|
5016
7712
|
} else {
|
|
@@ -5191,4 +7887,4 @@ ${replyContextText}` : replyContextText;
|
|
|
5191
7887
|
}
|
|
5192
7888
|
)
|
|
5193
7889
|
);
|
|
5194
|
-
program.parseAsync(process.argv);
|
|
7890
|
+
program.parseAsync(normalizeCommandAliases(process.argv));
|