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.
Files changed (3) hide show
  1. package/README.md +78 -1
  2. package/dist/cli.js +2793 -97
  3. 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 fs5 from "fs/promises";
7
+ import fs6 from "fs/promises";
8
8
  import net from "net";
9
9
  import os4 from "os";
10
- import path5 from "path";
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/client.ts
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(path2.basename(filePath)).toString("base64");
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 fs2.readFile(filePath);
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 = path2.resolve(targetPath);
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 fs3 from "fs/promises";
1678
+ import fs4 from "fs/promises";
456
1679
  import os2 from "os";
457
- import path3 from "path";
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 path3.join(os2.homedir(), value.slice(2));
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 = path3.extname(pathname);
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 fs3.mkdtemp(path3.join(os2.tmpdir(), "openzca-"));
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 = path3.join(dir, `url-${i + 1}${ext}`);
1782
+ const filePath = path4.join(dir, `url-${i + 1}${ext}`);
560
1783
  const data = Buffer.from(await response.arrayBuffer());
561
- await fs3.writeFile(filePath, data);
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 fs3.rm(dir, { recursive: true, force: true });
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 fs3.access(file);
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 fs4 from "fs/promises";
2318
+ import fs5 from "fs/promises";
1020
2319
  import os3 from "os";
1021
- import path4 from "path";
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 = path4.extname(files[0] ?? "").toLowerCase();
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 fs4.mkdtemp(path4.join(os3.tmpdir(), "openzca-video-thumb-"));
1133
- const outputPath = path4.join(dir, "thumbnail.jpg");
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 fs4.access(outputPath);
2446
+ await fs5.access(outputPath);
1148
2447
  } catch (error) {
1149
- await fs4.rm(dir, { recursive: true, force: true });
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 fs4.rm(dir, { recursive: true, force: true });
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() || path5.join(APP_HOME, "logs", "openzca-debug.log");
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 path5.isAbsolute(normalized) ? normalized : path5.resolve(process.cwd(), normalized);
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(path5.dirname(filePath), { recursive: true });
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 path5.join(getProfileDir(profile), "listener-owner.json");
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 path5.join(getProfileDir(profile), "listen.sock");
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 fs5.readFile(lockPath, "utf8");
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 fs5.rm(lockPath, { force: true });
2752
+ await fs6.rm(lockPath, { force: true });
1416
2753
  return null;
1417
2754
  }
1418
2755
  if (!isProcessAlive(record.pid)) {
1419
- await fs5.rm(lockPath, { force: true });
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 fs5.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
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 fs5.rm(lockPath, { force: true });
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 fs5.rm(lockPath, { force: true });
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 fs5.rm(socketPath, { force: true });
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 fs5.rm(socketPath, { force: true });
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("OPENZCA_RECENT_GROUP_MAX_PAGES", 20);
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.Group, cursor || null);
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.Group) return;
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 fetchRecentUserMessagesViaListener(api, threadId, count) {
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, count));
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
- if (message.threadId === threadId) {
2549
- const key = toKey(message);
2550
- if (seenMessageKeys.has(key)) continue;
2551
- seenMessageKeys.add(key);
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 fs5.readFile(filePath, "utf8");
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 fs5.readFile(filePath);
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 fs5.mkdtemp(path5.join(os4.tmpdir(), "openzca-qr-"));
2639
- const targetPath = path5.resolve(qrPath ?? path5.join(tempDir, "qr.png"));
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 = path5.extname(parsedUrl.pathname);
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() || path5.join(os4.homedir(), ".openclaw");
2949
- return path5.join(stateDir, "media");
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 path5.isAbsolute(configured) ? configured : path5.resolve(process.cwd(), configured);
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 path5.join(getProfileDir(profile), "inbound-media");
5117
+ return path6.join(getProfileDir(profile), "inbound-media");
2960
5118
  }
2961
- return path5.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
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 fs5.mkdir(dir, { recursive: true });
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 = path5.join(dir, `${id}${ext}`);
2999
- await fs5.writeFile(mediaPath, data);
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(path5.resolve(normalizeMediaInput(file))) : toCredentials(
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 (default: 20)", "20").option("-j, --json", "JSON output").description("List recent messages (group uses direct history API when available)").action(
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 messages = opts.group ? await fetchRecentGroupMessagesViaApi(api, threadId, count) : await fetchRecentUserMessagesViaListener(
4031
- api,
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
- const rows = messages.map((message) => ({
4036
- msgId: message.data.msgId,
4037
- cliMsgId: message.data.cliMsgId,
4038
- threadId: message.threadId || threadId,
4039
- threadType: message.type === ThreadType2.Group ? "group" : "user",
4040
- senderId: message.data.uidFrom,
4041
- senderName: message.data.dName,
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
- group: message.type === ThreadType2.Group
4049
- },
4050
- content: typeof message.data.content === "string" ? message.data.content : JSON.stringify(message.data.content)
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));