openzca 0.1.48 → 0.1.50

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 +90 -2
  2. package/dist/cli.js +3142 -153
  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 {
@@ -15,7 +16,7 @@ import {
15
16
  Gender,
16
17
  Reactions,
17
18
  ReviewPendingMemberRequestStatus,
18
- ThreadType as ThreadType2
19
+ ThreadType as ThreadType3
19
20
  } from "zca-js";
20
21
 
21
22
  // src/lib/store.ts
@@ -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
  }
@@ -1195,33 +2494,258 @@ async function sendNativeVideo(params) {
1195
2494
  }
1196
2495
  }
1197
2496
 
1198
- // src/cli.ts
1199
- var require2 = createRequire(import.meta.url);
1200
- var { version: PKG_VERSION } = require2("../package.json");
1201
- var program = new Command();
1202
- var EMOJI_REACTION_MAP = {
1203
- "\u2764\uFE0F": Reactions.HEART,
1204
- "\u2764": Reactions.HEART,
1205
- "\u{1F44D}": Reactions.LIKE,
1206
- "\u{1F606}": Reactions.HAHA,
1207
- "\u{1F602}": Reactions.HAHA,
1208
- "\u{1F62E}": Reactions.WOW,
1209
- "\u{1F62D}": Reactions.CRY,
1210
- "\u{1F621}": Reactions.ANGRY
1211
- };
1212
- var DEBUG_COMMAND_START = /* @__PURE__ */ new WeakMap();
1213
- function parseDebugFlag(value) {
1214
- if (!value) return false;
1215
- const normalized = value.trim().toLowerCase();
1216
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1217
- }
1218
- function getActionCommand(args) {
1219
- for (let index = args.length - 1; index >= 0; index -= 1) {
1220
- const item = args[index];
1221
- if (item instanceof Command) {
1222
- return item;
1223
- }
1224
- }
2497
+ // src/lib/reply.ts
2498
+ import { ThreadType as ThreadType2 } from "zca-js";
2499
+ function prepareReplyMessage(value, params) {
2500
+ const sourceRecord = asReplyMessageRecord(value);
2501
+ const metadata = asOptionalReplyMessageRecord(sourceRecord.metadata);
2502
+ const rawMessageRecord = asOptionalReplyMessageRecord(sourceRecord.rawMessage);
2503
+ const rawPayloadRecord = asOptionalReplyMessageRecord(sourceRecord.rawPayload);
2504
+ const canonicalRecord = rawMessageRecord ?? sourceRecord;
2505
+ const content = parseReplyMessageContent(
2506
+ canonicalRecord.content ?? sourceRecord.content,
2507
+ isLikelyOpenzcaListenPayload(sourceRecord) && !rawMessageRecord
2508
+ );
2509
+ const msgType = requireStringLike(
2510
+ [canonicalRecord.msgType, sourceRecord.msgType, metadata?.msgType],
2511
+ "reply message msgType"
2512
+ );
2513
+ const uidFrom = requireStringLike(
2514
+ [
2515
+ canonicalRecord.uidFrom,
2516
+ sourceRecord.uidFrom,
2517
+ sourceRecord.senderId,
2518
+ sourceRecord.fromId,
2519
+ metadata?.senderId,
2520
+ metadata?.fromId
2521
+ ],
2522
+ "reply message uidFrom"
2523
+ );
2524
+ const msgId = requireStringLike(
2525
+ [canonicalRecord.msgId, sourceRecord.msgId, rawPayloadRecord?.msgId],
2526
+ "reply message msgId"
2527
+ );
2528
+ const cliMsgId = requireStringLike(
2529
+ [canonicalRecord.cliMsgId, sourceRecord.cliMsgId, rawPayloadRecord?.cliMsgId],
2530
+ "reply message cliMsgId"
2531
+ );
2532
+ const ts = requireTsString(
2533
+ [canonicalRecord.ts, sourceRecord.ts, maybeTimestampSecondsToMsString(sourceRecord.timestamp)],
2534
+ "reply message ts"
2535
+ );
2536
+ const ttl = parseReplyMessageTtl(canonicalRecord.ttl ?? sourceRecord.ttl);
2537
+ const propertyExt = parseReplyMessagePropertyExt(canonicalRecord.propertyExt);
2538
+ return {
2539
+ quote: {
2540
+ content,
2541
+ msgType,
2542
+ propertyExt,
2543
+ uidFrom,
2544
+ msgId,
2545
+ cliMsgId,
2546
+ ts,
2547
+ ttl
2548
+ },
2549
+ inferredThreadId: inferReplyMessageThreadId({
2550
+ sourceRecord,
2551
+ canonicalRecord,
2552
+ metadata,
2553
+ threadType: params?.threadType,
2554
+ selfId: params?.selfId
2555
+ })
2556
+ };
2557
+ }
2558
+ function prepareStoredReplyMessage(value, params) {
2559
+ const record = asReplyMessageRecord(value);
2560
+ const storedThreadType = record.threadType === "group" ? ThreadType2.Group : record.threadType === "user" ? ThreadType2.User : void 0;
2561
+ if (storedThreadType !== void 0 && storedThreadType !== params.threadType) {
2562
+ throw new Error("Reply source thread type does not match --group.");
2563
+ }
2564
+ const storedThreadId = firstString([record.threadId, record.rawThreadId]) ?? void 0;
2565
+ if (storedThreadId && storedThreadId !== params.threadId) {
2566
+ throw new Error("Reply source belongs to a different thread.");
2567
+ }
2568
+ const rawMessage = asOptionalReplyMessageRecord(record.rawMessage);
2569
+ const rawPayload = asOptionalReplyMessageRecord(record.rawPayload);
2570
+ const replyRecord = rawMessage ?? rawPayload;
2571
+ if (!replyRecord) {
2572
+ throw new Error(
2573
+ "Reply source found in DB but has no reusable raw message payload. Re-sync or capture it via listener first."
2574
+ );
2575
+ }
2576
+ return prepareReplyMessage(replyRecord, {
2577
+ threadType: params.threadType,
2578
+ selfId: params.selfId
2579
+ }).quote;
2580
+ }
2581
+ function asReplyMessageRecord(value) {
2582
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2583
+ throw new Error("Reply message must be a JSON object matching the raw message.data shape.");
2584
+ }
2585
+ return value;
2586
+ }
2587
+ function asOptionalReplyMessageRecord(value) {
2588
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2589
+ return void 0;
2590
+ }
2591
+ return value;
2592
+ }
2593
+ function parseReplyMessageContent(value, stripOpenzcaDecorations) {
2594
+ if (typeof value === "string") {
2595
+ return stripOpenzcaDecorations ? stripEnrichedReplyDecorations(value) : value;
2596
+ }
2597
+ if (value && typeof value === "object" && !Array.isArray(value)) {
2598
+ return value;
2599
+ }
2600
+ throw new Error("Reply message content must be a string or object.");
2601
+ }
2602
+ function stripEnrichedReplyDecorations(value) {
2603
+ const lines = value.split("\n");
2604
+ while (lines.length > 0) {
2605
+ const last = lines[lines.length - 1].trim();
2606
+ if (last.startsWith("[reply context: ") || last.startsWith("[reply media attached:") || last.startsWith("[reply media attached ")) {
2607
+ lines.pop();
2608
+ continue;
2609
+ }
2610
+ break;
2611
+ }
2612
+ return lines.join("\n");
2613
+ }
2614
+ function parseReplyMessagePropertyExt(value) {
2615
+ if (value === void 0) {
2616
+ return void 0;
2617
+ }
2618
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2619
+ throw new Error("Reply message propertyExt must be an object when provided.");
2620
+ }
2621
+ return value;
2622
+ }
2623
+ function parseReplyMessageTtl(value) {
2624
+ if (value === void 0 || value === null || value === "") {
2625
+ return 0;
2626
+ }
2627
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
2628
+ if (!Number.isFinite(parsed)) {
2629
+ throw new Error("Reply message ttl must be a finite number.");
2630
+ }
2631
+ return Math.trunc(parsed);
2632
+ }
2633
+ function requireStringLike(values, label) {
2634
+ const value = firstString(values);
2635
+ if (!value) {
2636
+ throw new Error(`Missing ${label}.`);
2637
+ }
2638
+ return value;
2639
+ }
2640
+ function requireTsString(values, label) {
2641
+ for (const value of values) {
2642
+ if (typeof value === "string" && value.trim()) {
2643
+ return value.trim();
2644
+ }
2645
+ if (typeof value === "number" && Number.isFinite(value)) {
2646
+ return String(Math.trunc(value));
2647
+ }
2648
+ }
2649
+ throw new Error(`Missing ${label}.`);
2650
+ }
2651
+ function firstString(values) {
2652
+ for (const value of values) {
2653
+ if (typeof value === "string" && value.trim()) {
2654
+ return value.trim();
2655
+ }
2656
+ if (typeof value === "number" && Number.isFinite(value)) {
2657
+ return String(Math.trunc(value));
2658
+ }
2659
+ }
2660
+ return void 0;
2661
+ }
2662
+ function maybeTimestampSecondsToMsString(value) {
2663
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2664
+ return void 0;
2665
+ }
2666
+ return String(Math.trunc(value * 1e3));
2667
+ }
2668
+ function isLikelyOpenzcaListenPayload(record) {
2669
+ return typeof record.threadId === "string" && (typeof record.senderId === "string" || typeof record.chatType === "string" || typeof record.metadata === "object");
2670
+ }
2671
+ function inferReplyMessageThreadId(params) {
2672
+ const directThreadId = firstString([
2673
+ params.sourceRecord.threadId,
2674
+ params.sourceRecord.targetId,
2675
+ params.sourceRecord.conversationId,
2676
+ params.metadata?.threadId,
2677
+ params.metadata?.targetId
2678
+ ]);
2679
+ if (directThreadId) {
2680
+ return directThreadId;
2681
+ }
2682
+ if (params.threadType === void 0) {
2683
+ return void 0;
2684
+ }
2685
+ const idTo = firstString([
2686
+ params.canonicalRecord.idTo,
2687
+ params.sourceRecord.idTo,
2688
+ params.sourceRecord.toId,
2689
+ params.metadata?.toId
2690
+ ]);
2691
+ if (params.threadType === ThreadType2.Group) {
2692
+ return idTo;
2693
+ }
2694
+ const uidFrom = firstString([
2695
+ params.canonicalRecord.uidFrom,
2696
+ params.sourceRecord.uidFrom,
2697
+ params.sourceRecord.senderId,
2698
+ params.sourceRecord.fromId,
2699
+ params.metadata?.senderId,
2700
+ params.metadata?.fromId
2701
+ ]);
2702
+ if (!uidFrom && !idTo) {
2703
+ return void 0;
2704
+ }
2705
+ if (params.selfId) {
2706
+ if (uidFrom && uidFrom !== params.selfId && uidFrom !== "0") {
2707
+ return uidFrom;
2708
+ }
2709
+ if (idTo && idTo !== params.selfId && idTo !== "0") {
2710
+ return idTo;
2711
+ }
2712
+ }
2713
+ if (uidFrom && uidFrom !== "0") {
2714
+ return uidFrom;
2715
+ }
2716
+ if (idTo && idTo !== "0") {
2717
+ return idTo;
2718
+ }
2719
+ return void 0;
2720
+ }
2721
+
2722
+ // src/cli.ts
2723
+ var require2 = createRequire(import.meta.url);
2724
+ var { version: PKG_VERSION } = require2("../package.json");
2725
+ var program = new Command();
2726
+ var EMOJI_REACTION_MAP = {
2727
+ "\u2764\uFE0F": Reactions.HEART,
2728
+ "\u2764": Reactions.HEART,
2729
+ "\u{1F44D}": Reactions.LIKE,
2730
+ "\u{1F606}": Reactions.HAHA,
2731
+ "\u{1F602}": Reactions.HAHA,
2732
+ "\u{1F62E}": Reactions.WOW,
2733
+ "\u{1F62D}": Reactions.CRY,
2734
+ "\u{1F621}": Reactions.ANGRY
2735
+ };
2736
+ var DEBUG_COMMAND_START = /* @__PURE__ */ new WeakMap();
2737
+ function parseDebugFlag(value) {
2738
+ if (!value) return false;
2739
+ const normalized = value.trim().toLowerCase();
2740
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
2741
+ }
2742
+ function getActionCommand(args) {
2743
+ for (let index = args.length - 1; index >= 0; index -= 1) {
2744
+ const item = args[index];
2745
+ if (item instanceof Command) {
2746
+ return item;
2747
+ }
2748
+ }
1225
2749
  return void 0;
1226
2750
  }
1227
2751
  function commandPathLabel(command) {
@@ -1237,6 +2761,25 @@ function commandPathLabel(command) {
1237
2761
  }
1238
2762
  return names.join(" ");
1239
2763
  }
2764
+ function readCliFlag(names) {
2765
+ const argv = process.argv.slice(2);
2766
+ return argv.some((item) => names.includes(item));
2767
+ }
2768
+ function readCliOptionValue(names) {
2769
+ const argv = process.argv.slice(2);
2770
+ for (let index = argv.length - 1; index >= 0; index -= 1) {
2771
+ const item = argv[index];
2772
+ for (const name of names) {
2773
+ if (item === name) {
2774
+ return argv[index + 1];
2775
+ }
2776
+ if (item.startsWith(`${name}=`)) {
2777
+ return item.slice(name.length + 1);
2778
+ }
2779
+ }
2780
+ }
2781
+ return void 0;
2782
+ }
1240
2783
  function getDebugOptions(command) {
1241
2784
  if (command) {
1242
2785
  if (typeof command.optsWithGlobals === "function") {
@@ -1257,9 +2800,9 @@ function resolveDebugEnabled(command) {
1257
2800
  }
1258
2801
  function resolveDebugFilePath(command) {
1259
2802
  const options = getDebugOptions(command);
1260
- const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path5.join(APP_HOME, "logs", "openzca-debug.log");
2803
+ const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path6.join(APP_HOME, "logs", "openzca-debug.log");
1261
2804
  const normalized = normalizeMediaInput(configured);
1262
- return path5.isAbsolute(normalized) ? normalized : path5.resolve(process.cwd(), normalized);
2805
+ return path6.isAbsolute(normalized) ? normalized : path6.resolve(process.cwd(), normalized);
1263
2806
  }
1264
2807
  function writeDebugLine(event, details, command) {
1265
2808
  if (!resolveDebugEnabled(command)) {
@@ -1270,7 +2813,7 @@ function writeDebugLine(event, details, command) {
1270
2813
  `;
1271
2814
  const filePath = resolveDebugFilePath(command);
1272
2815
  try {
1273
- fsSync.mkdirSync(path5.dirname(filePath), { recursive: true });
2816
+ fsSync.mkdirSync(path6.dirname(filePath), { recursive: true });
1274
2817
  fsSync.appendFileSync(filePath, line, "utf8");
1275
2818
  } catch {
1276
2819
  }
@@ -1318,8 +2861,27 @@ function output(value, asJson = false) {
1318
2861
  }
1319
2862
  console.log(String(value));
1320
2863
  }
2864
+ function shouldOutputJson(opts) {
2865
+ return Boolean(opts?.json) || readCliFlag(["--json", "-j"]);
2866
+ }
2867
+ function normalizeCommandAliases(argv) {
2868
+ const normalized = [...argv];
2869
+ const dbIndex = normalized.indexOf("db");
2870
+ if (dbIndex === -1 || normalized[dbIndex + 1] !== "chat") {
2871
+ return normalized;
2872
+ }
2873
+ const subcommandOrId = normalized[dbIndex + 2];
2874
+ if (!subcommandOrId || subcommandOrId.startsWith("-")) {
2875
+ return normalized;
2876
+ }
2877
+ if (["list", "info", "messages", "help"].includes(subcommandOrId)) {
2878
+ return normalized;
2879
+ }
2880
+ normalized.splice(dbIndex + 2, 0, "messages");
2881
+ return normalized;
2882
+ }
1321
2883
  function asThreadType(groupFlag) {
1322
- return groupFlag ? ThreadType2.Group : ThreadType2.User;
2884
+ return groupFlag ? ThreadType3.Group : ThreadType3.User;
1323
2885
  }
1324
2886
  function parseBooleanFromEnv(name, fallback) {
1325
2887
  const raw = process.env[name]?.trim();
@@ -1358,14 +2920,14 @@ function collectIdsFromCacheEntries(entries, keys) {
1358
2920
  return ids;
1359
2921
  }
1360
2922
  function getListenerOwnerLockPath(profile) {
1361
- return path5.join(getProfileDir(profile), "listener-owner.json");
2923
+ return path6.join(getProfileDir(profile), "listener-owner.json");
1362
2924
  }
1363
2925
  function getListenIpcSocketPath(profile) {
1364
2926
  if (process.platform === "win32") {
1365
2927
  const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
1366
2928
  return `\\\\.\\pipe\\openzca-listen-${safe}`;
1367
2929
  }
1368
- return path5.join(getProfileDir(profile), "listen.sock");
2930
+ return path6.join(getProfileDir(profile), "listen.sock");
1369
2931
  }
1370
2932
  function parsePositiveIntFromUnknown(value) {
1371
2933
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -1392,7 +2954,7 @@ function isProcessAlive(pid) {
1392
2954
  }
1393
2955
  async function readListenerOwnerRecord(lockPath) {
1394
2956
  try {
1395
- const raw = await fs5.readFile(lockPath, "utf8");
2957
+ const raw = await fs6.readFile(lockPath, "utf8");
1396
2958
  const parsed = JSON.parse(raw);
1397
2959
  const pid = parsePositiveIntFromUnknown(parsed.pid);
1398
2960
  if (!pid) return null;
@@ -1412,11 +2974,11 @@ async function readActiveListenerOwner(profile) {
1412
2974
  const lockPath = getListenerOwnerLockPath(profile);
1413
2975
  const record = await readListenerOwnerRecord(lockPath);
1414
2976
  if (!record) {
1415
- await fs5.rm(lockPath, { force: true });
2977
+ await fs6.rm(lockPath, { force: true });
1416
2978
  return null;
1417
2979
  }
1418
2980
  if (!isProcessAlive(record.pid)) {
1419
- await fs5.rm(lockPath, { force: true });
2981
+ await fs6.rm(lockPath, { force: true });
1420
2982
  return null;
1421
2983
  }
1422
2984
  return record;
@@ -1432,7 +2994,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1432
2994
  };
1433
2995
  for (let attempt = 0; attempt < 3; attempt += 1) {
1434
2996
  try {
1435
- await fs5.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
2997
+ await fs6.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
1436
2998
  `, {
1437
2999
  encoding: "utf8",
1438
3000
  flag: "wx"
@@ -1445,7 +3007,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1445
3007
  released = true;
1446
3008
  const current = await readListenerOwnerRecord(lockPath);
1447
3009
  if (current && current.pid !== process.pid) return;
1448
- await fs5.rm(lockPath, { force: true });
3010
+ await fs6.rm(lockPath, { force: true });
1449
3011
  writeDebugLine(
1450
3012
  "listen.owner.released",
1451
3013
  {
@@ -1466,7 +3028,7 @@ async function acquireListenerOwnerLock(profile, sessionId, command) {
1466
3028
  `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
1467
3029
  );
1468
3030
  }
1469
- await fs5.rm(lockPath, { force: true });
3031
+ await fs6.rm(lockPath, { force: true });
1470
3032
  }
1471
3033
  }
1472
3034
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
@@ -1484,7 +3046,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1484
3046
  }
1485
3047
  const socketPath = getListenIpcSocketPath(profile);
1486
3048
  if (process.platform !== "win32") {
1487
- await fs5.rm(socketPath, { force: true });
3049
+ await fs6.rm(socketPath, { force: true });
1488
3050
  }
1489
3051
  const uploadTimeoutMs = parsePositiveIntFromEnv(
1490
3052
  "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
@@ -1529,7 +3091,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1529
3091
  fail(parsed.requestId, "Invalid upload payload.");
1530
3092
  return;
1531
3093
  }
1532
- const threadType = parsed.threadType === "group" ? ThreadType2.Group : ThreadType2.User;
3094
+ const threadType = parsed.threadType === "group" ? ThreadType3.Group : ThreadType3.User;
1533
3095
  const requestTimeoutMs = parsePositiveIntFromUnknown(parsed.uploadTimeoutMs) ?? uploadTimeoutMs;
1534
3096
  writeDebugLine(
1535
3097
  "listen.ipc.upload.start",
@@ -1656,7 +3218,7 @@ async function startListenerIpcServer(api, profile, sessionId, command) {
1656
3218
  server.close(() => resolve());
1657
3219
  });
1658
3220
  if (process.platform !== "win32") {
1659
- await fs5.rm(socketPath, { force: true });
3221
+ await fs6.rm(socketPath, { force: true });
1660
3222
  }
1661
3223
  writeDebugLine(
1662
3224
  "listen.ipc.stopped",
@@ -1686,7 +3248,7 @@ async function tryUploadViaListenerIpc(profile, threadId, threadType, attachment
1686
3248
  {
1687
3249
  profile,
1688
3250
  threadId,
1689
- threadType: threadType === ThreadType2.Group ? "group" : "user",
3251
+ threadType: threadType === ThreadType3.Group ? "group" : "user",
1690
3252
  attachmentCount: attachments.length,
1691
3253
  socketPath,
1692
3254
  requestId,
@@ -1730,7 +3292,7 @@ async function tryUploadViaListenerIpc(profile, threadId, threadType, attachment
1730
3292
  requestId,
1731
3293
  profile,
1732
3294
  threadId,
1733
- threadType: threadType === ThreadType2.Group ? "group" : "user",
3295
+ threadType: threadType === ThreadType3.Group ? "group" : "user",
1734
3296
  attachments
1735
3297
  };
1736
3298
  socket.write(`${JSON.stringify(payload)}
@@ -1792,21 +3354,21 @@ async function tryUploadViaListenerIpc(profile, threadId, threadType, attachment
1792
3354
  }
1793
3355
  async function resolveUploadThreadType(api, profile, threadId, groupFlag, command) {
1794
3356
  if (groupFlag) {
1795
- return { type: ThreadType2.Group, reason: "explicit_group_flag" };
3357
+ return { type: ThreadType3.Group, reason: "explicit_group_flag" };
1796
3358
  }
1797
3359
  const autoDetectEnabled = parseBooleanFromEnv("OPENZCA_UPLOAD_AUTO_THREAD_TYPE", false);
1798
3360
  if (!autoDetectEnabled) {
1799
- return { type: ThreadType2.User, reason: "auto_detect_disabled" };
3361
+ return { type: ThreadType3.User, reason: "auto_detect_disabled" };
1800
3362
  }
1801
3363
  try {
1802
3364
  const cache = await readCache(profile);
1803
3365
  const groupIds = collectIdsFromCacheEntries(cache.groups, ["groupId", "grid", "threadId", "id"]);
1804
3366
  if (groupIds.has(threadId)) {
1805
- return { type: ThreadType2.Group, reason: "cache_group_match" };
3367
+ return { type: ThreadType3.Group, reason: "cache_group_match" };
1806
3368
  }
1807
3369
  const friendIds = collectIdsFromCacheEntries(cache.friends, ["userId", "uid", "id", "threadId"]);
1808
3370
  if (friendIds.has(threadId)) {
1809
- return { type: ThreadType2.User, reason: "cache_friend_match" };
3371
+ return { type: ThreadType3.User, reason: "cache_friend_match" };
1810
3372
  }
1811
3373
  } catch (error) {
1812
3374
  writeDebugLine(
@@ -1821,7 +3383,7 @@ async function resolveUploadThreadType(api, profile, threadId, groupFlag, comman
1821
3383
  }
1822
3384
  const probeEnabled = parseBooleanFromEnv("OPENZCA_UPLOAD_GROUP_PROBE", true);
1823
3385
  if (!probeEnabled) {
1824
- return { type: ThreadType2.User, reason: "probe_disabled" };
3386
+ return { type: ThreadType3.User, reason: "probe_disabled" };
1825
3387
  }
1826
3388
  const probeTimeoutMs = parsePositiveIntFromEnv("OPENZCA_UPLOAD_GROUP_PROBE_TIMEOUT_MS", 5e3);
1827
3389
  try {
@@ -1831,7 +3393,7 @@ async function resolveUploadThreadType(api, profile, threadId, groupFlag, comman
1831
3393
  `Timed out waiting ${probeTimeoutMs}ms while probing group thread type.`
1832
3394
  );
1833
3395
  if (groupInfo?.gridInfoMap?.[threadId]) {
1834
- return { type: ThreadType2.Group, reason: "probe_group_match" };
3396
+ return { type: ThreadType3.Group, reason: "probe_group_match" };
1835
3397
  }
1836
3398
  } catch (error) {
1837
3399
  writeDebugLine(
@@ -1844,7 +3406,7 @@ async function resolveUploadThreadType(api, profile, threadId, groupFlag, comman
1844
3406
  command
1845
3407
  );
1846
3408
  }
1847
- return { type: ThreadType2.User, reason: "default_user" };
3409
+ return { type: ThreadType3.User, reason: "default_user" };
1848
3410
  }
1849
3411
  function parseReaction(input) {
1850
3412
  const normalized = input.trim();
@@ -1927,6 +3489,675 @@ async function requireApi(command) {
1927
3489
  const api = await loginWithStoredCredentials(profile);
1928
3490
  return { profile, api };
1929
3491
  }
3492
+ function toDbThreadType(groupFlag) {
3493
+ return groupFlag ? "group" : "user";
3494
+ }
3495
+ function getDbWriteOverride(opts) {
3496
+ if (!opts || typeof opts.db !== "boolean") {
3497
+ return void 0;
3498
+ }
3499
+ return opts.db;
3500
+ }
3501
+ async function shouldWriteToDb(profile, override) {
3502
+ if (typeof override === "boolean") {
3503
+ return override;
3504
+ }
3505
+ return isDbEnabled(profile);
3506
+ }
3507
+ async function resolveSendReplyQuote(params) {
3508
+ const replyId = params.replyId?.trim();
3509
+ const replyMessage = params.replyMessage?.trim();
3510
+ if (replyId && replyMessage) {
3511
+ throw new Error("Use either --reply-id or --reply-message, not both.");
3512
+ }
3513
+ if (!replyId && !replyMessage) {
3514
+ return void 0;
3515
+ }
3516
+ if (replyId) {
3517
+ if (!await shouldWriteToDb(params.profile)) {
3518
+ throw new Error("`--reply-id` requires the local DB. Enable DB/listen sync first.");
3519
+ }
3520
+ const row = await getMessageById({
3521
+ profile: params.profile,
3522
+ id: replyId
3523
+ });
3524
+ if (!row) {
3525
+ throw new Error(`Reply source not found in DB: ${replyId}`);
3526
+ }
3527
+ if (row.threadType === "group" !== (params.threadType === ThreadType3.Group)) {
3528
+ throw new Error("Reply source thread type does not match --group.");
3529
+ }
3530
+ if (row.threadId !== params.threadId) {
3531
+ throw new Error("Reply source belongs to a different thread.");
3532
+ }
3533
+ if (!row.rawMessage || typeof row.rawMessage !== "object") {
3534
+ if (!row.rawPayload || typeof row.rawPayload !== "object") {
3535
+ throw new Error(
3536
+ "Reply source found in DB but has no reusable raw message payload. Re-sync or capture it via listener first."
3537
+ );
3538
+ }
3539
+ }
3540
+ return prepareStoredReplyMessage(row, {
3541
+ threadId: params.threadId,
3542
+ threadType: params.threadType,
3543
+ selfId: params.api.getOwnId()
3544
+ });
3545
+ }
3546
+ let parsedReplyMessage;
3547
+ try {
3548
+ parsedReplyMessage = JSON.parse(replyMessage);
3549
+ } catch (error) {
3550
+ throw new Error(
3551
+ `Invalid JSON for --reply-message: ${error instanceof Error ? error.message : String(error)}`
3552
+ );
3553
+ }
3554
+ const preparedReply = prepareReplyMessage(parsedReplyMessage, {
3555
+ threadType: params.threadType,
3556
+ selfId: params.api.getOwnId()
3557
+ });
3558
+ if (preparedReply.inferredThreadId && preparedReply.inferredThreadId !== params.threadId) {
3559
+ throw new Error("Reply message belongs to a different thread.");
3560
+ }
3561
+ return preparedReply.quote;
3562
+ }
3563
+ function scheduleDbWrite(profile, command, event, task) {
3564
+ enqueueDbWrite(profile, async () => {
3565
+ try {
3566
+ await task();
3567
+ } catch (error) {
3568
+ writeDebugLine(
3569
+ event,
3570
+ {
3571
+ profile,
3572
+ message: error instanceof Error ? error.message : String(error)
3573
+ },
3574
+ command
3575
+ );
3576
+ }
3577
+ });
3578
+ }
3579
+ function extractResponseMessageIds(value) {
3580
+ const ids = /* @__PURE__ */ new Set();
3581
+ const visit = (item) => {
3582
+ if (!item) return;
3583
+ if (Array.isArray(item)) {
3584
+ for (const nested of item) {
3585
+ visit(nested);
3586
+ }
3587
+ return;
3588
+ }
3589
+ if (typeof item !== "object") {
3590
+ return;
3591
+ }
3592
+ const record = item;
3593
+ const msgId = normalizeCachedId(record.msgId);
3594
+ if (msgId) {
3595
+ ids.add(msgId);
3596
+ }
3597
+ for (const key of ["message", "attachment", "attachments", "results", "response"]) {
3598
+ if (key in record) {
3599
+ visit(record[key]);
3600
+ }
3601
+ }
3602
+ };
3603
+ visit(value);
3604
+ return Array.from(ids);
3605
+ }
3606
+ async function persistOutgoingMessageBestEffort(params) {
3607
+ const selfId = params.api.getOwnId();
3608
+ const threadType = toDbThreadType(params.group);
3609
+ const scopeThreadId = resolveScopeThreadId({
3610
+ threadType,
3611
+ rawThreadId: params.threadId,
3612
+ senderId: selfId,
3613
+ toId: params.threadId,
3614
+ selfId
3615
+ });
3616
+ const messageIds = extractResponseMessageIds(params.response);
3617
+ const baseRecord = {
3618
+ profile: params.profile,
3619
+ scopeThreadId,
3620
+ rawThreadId: params.threadId,
3621
+ threadType,
3622
+ peerId: threadType === "user" ? scopeThreadId : void 0,
3623
+ senderId: selfId,
3624
+ senderName: void 0,
3625
+ toId: threadType === "user" ? params.threadId : void 0,
3626
+ timestampMs: Date.now(),
3627
+ msgType: params.msgType,
3628
+ contentText: params.text,
3629
+ media: params.media,
3630
+ source: "send",
3631
+ rawPayloadJson: params.rawPayload ? JSON.stringify(params.rawPayload) : void 0,
3632
+ rawMessageJson: JSON.stringify(params.response)
3633
+ };
3634
+ if (messageIds.length === 0) {
3635
+ await persistMessage(baseRecord);
3636
+ return;
3637
+ }
3638
+ for (const msgId of messageIds) {
3639
+ await persistMessage({
3640
+ ...baseRecord,
3641
+ msgId
3642
+ });
3643
+ }
3644
+ }
3645
+ async function persistGroupMembersSnapshot(profile, groupId, api) {
3646
+ const rows = await listGroupMemberRows(api, groupId);
3647
+ const snapshotAtMs = Date.now();
3648
+ await replaceThreadMembers(
3649
+ profile,
3650
+ groupId,
3651
+ rows.map((row) => ({
3652
+ profile,
3653
+ scopeThreadId: groupId,
3654
+ userId: row.userId,
3655
+ displayName: row.displayName,
3656
+ zaloName: row.zaloName,
3657
+ rawJson: JSON.stringify(row),
3658
+ snapshotAtMs
3659
+ }))
3660
+ );
3661
+ }
3662
+ async function persistFriendDirectory(profile, api) {
3663
+ const friends = await api.getAllFriends();
3664
+ const nameById = /* @__PURE__ */ new Map();
3665
+ for (const friend2 of friends) {
3666
+ const record = friend2;
3667
+ const userId = normalizeCachedId(record.userId);
3668
+ if (!userId) continue;
3669
+ const displayName = typeof record.displayName === "string" && record.displayName.trim() ? record.displayName.trim() : void 0;
3670
+ const zaloName = typeof record.zaloName === "string" && record.zaloName.trim() ? record.zaloName.trim() : void 0;
3671
+ const avatar = typeof record.avatar === "string" && record.avatar.trim() ? record.avatar.trim() : void 0;
3672
+ const title = displayName || zaloName || userId;
3673
+ await persistFriend({
3674
+ profile,
3675
+ userId,
3676
+ displayName,
3677
+ zaloName,
3678
+ avatar,
3679
+ accountStatus: typeof record.accountStatus === "number" && Number.isFinite(record.accountStatus) ? Math.trunc(record.accountStatus) : void 0,
3680
+ rawJson: JSON.stringify(friend2)
3681
+ });
3682
+ await persistThread({
3683
+ profile,
3684
+ scopeThreadId: userId,
3685
+ rawThreadId: userId,
3686
+ threadType: "user",
3687
+ peerId: userId,
3688
+ title,
3689
+ rawJson: JSON.stringify(friend2)
3690
+ });
3691
+ nameById.set(userId, title);
3692
+ }
3693
+ return nameById;
3694
+ }
3695
+ function parseSinceDuration(label, value) {
3696
+ const parsed = parseDurationInput(value);
3697
+ if (parsed !== void 0) {
3698
+ return parsed;
3699
+ }
3700
+ if (!value || !value.trim()) {
3701
+ return void 0;
3702
+ }
3703
+ throw new Error(
3704
+ `${label} must be a relative duration like 30s, 7m, 24h, 7d, or 2w.`
3705
+ );
3706
+ }
3707
+ function parseTimeBoundary(label, value) {
3708
+ const parsed = parseTimeBoundaryInput(value);
3709
+ if (parsed !== void 0) {
3710
+ return parsed;
3711
+ }
3712
+ if (!value || !value.trim()) {
3713
+ return void 0;
3714
+ }
3715
+ throw new Error(
3716
+ `${label} must be an ISO timestamp, a date, or unix seconds/ms.`
3717
+ );
3718
+ }
3719
+ function pickExclusiveOption(primaryLabel, primaryValue, aliasLabel, aliasValue) {
3720
+ if (primaryValue?.trim() && aliasValue?.trim()) {
3721
+ throw new Error(`Use either ${primaryLabel} or ${aliasLabel}, not both.`);
3722
+ }
3723
+ return primaryValue?.trim() ? primaryValue : aliasValue?.trim() ? aliasValue : void 0;
3724
+ }
3725
+ function resolveMessageTimeRange(opts) {
3726
+ const sinceValue = opts.since?.trim() ? opts.since : void 0;
3727
+ const fromValue = opts.from?.trim() ? opts.from : void 0;
3728
+ const untilValue = pickExclusiveOption("--until", opts.until, "--to", opts.to);
3729
+ if (sinceValue && fromValue) {
3730
+ throw new Error("Use either --since for a rolling window or --from/--to for an explicit range, not both.");
3731
+ }
3732
+ if (sinceValue && untilValue) {
3733
+ throw new Error("Do not combine --since with --to/--until. Use --from/--to for explicit ranges.");
3734
+ }
3735
+ return {
3736
+ sinceMs: sinceValue ? parseSinceDuration("--since", sinceValue) : parseTimeBoundary("--from", fromValue),
3737
+ untilMs: parseTimeBoundary("--to/--until", untilValue)
3738
+ };
3739
+ }
3740
+ function resolveMessageQueryOptions(opts) {
3741
+ const { sinceMs, untilMs } = resolveMessageTimeRange(opts);
3742
+ if (opts.all && opts.limit?.trim()) {
3743
+ throw new Error("Use either --all or --limit, not both.");
3744
+ }
3745
+ const explicitLimit = parsePositiveIntOption("--limit", opts.limit);
3746
+ const hasTimeFilter = sinceMs !== void 0 || untilMs !== void 0;
3747
+ const limit = opts.all ? void 0 : explicitLimit ?? (hasTimeFilter ? void 0 : 20);
3748
+ const newestFirst = !Boolean(opts.oldestFirst);
3749
+ return {
3750
+ sinceMs,
3751
+ untilMs,
3752
+ limit,
3753
+ newestFirst
3754
+ };
3755
+ }
3756
+ async function resolveStoredChatThreadType(profile, chatId, forceGroup) {
3757
+ if (forceGroup) {
3758
+ return "group";
3759
+ }
3760
+ const row = await getThreadInfo({ profile, threadId: chatId });
3761
+ return row?.threadType === "group" ? "group" : "user";
3762
+ }
3763
+ async function confirmDestructiveAction(message) {
3764
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
3765
+ throw new Error("Refusing destructive operation without --yes in non-interactive mode.");
3766
+ }
3767
+ const rl = readline.createInterface({
3768
+ input: process.stdin,
3769
+ output: process.stdout
3770
+ });
3771
+ try {
3772
+ const answer = (await rl.question(`${message} [y/N] `)).trim().toLowerCase();
3773
+ return answer === "y" || answer === "yes";
3774
+ } finally {
3775
+ rl.close();
3776
+ }
3777
+ }
3778
+ function createSyncProgressReporter() {
3779
+ if (!process.stderr.isTTY) {
3780
+ return () => {
3781
+ };
3782
+ }
3783
+ return (message) => {
3784
+ process.stderr.write(`[db sync] ${message}
3785
+ `);
3786
+ };
3787
+ }
3788
+ function createDbSyncSummary(profile, dbPath, count) {
3789
+ return {
3790
+ profile,
3791
+ dbPath,
3792
+ windowCount: count,
3793
+ groupsSynced: 0,
3794
+ groupMessagesImported: 0,
3795
+ friendsSynced: 0,
3796
+ chatsSynced: 0,
3797
+ dmMessagesImported: 0,
3798
+ syncState: []
3799
+ };
3800
+ }
3801
+ function resolveSyncWindowCount(value) {
3802
+ return parsePositiveIntOption("--count", value) ?? 200;
3803
+ }
3804
+ async function collectConversationIds(api) {
3805
+ let pinnedIds = /* @__PURE__ */ new Set();
3806
+ let hiddenIds = /* @__PURE__ */ new Set();
3807
+ try {
3808
+ const pins = await api.getPinConversations();
3809
+ pinnedIds = new Set((pins.conversations ?? []).map((value) => String(value)));
3810
+ } catch {
3811
+ }
3812
+ try {
3813
+ const hidden = await api.getHiddenConversations();
3814
+ hiddenIds = new Set((hidden.threads ?? []).map((item) => String(item.thread_id)));
3815
+ } catch {
3816
+ }
3817
+ return { pinnedIds, hiddenIds };
3818
+ }
3819
+ async function prepareDbGroupTarget(params) {
3820
+ await persistThread({
3821
+ profile: params.profile,
3822
+ scopeThreadId: params.groupId,
3823
+ rawThreadId: params.groupId,
3824
+ threadType: "group",
3825
+ title: params.title,
3826
+ isPinned: params.pinnedIds.has(params.groupId),
3827
+ isHidden: params.hiddenIds.has(params.groupId),
3828
+ rawJson: params.rawJson
3829
+ });
3830
+ await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
3831
+ }
3832
+ async function syncDbGroupHistoryFull(params) {
3833
+ if (params.targetGroupIds.size === 0) {
3834
+ return;
3835
+ }
3836
+ const getStoredGroupMessageCount = async () => {
3837
+ let total = 0;
3838
+ for (const groupId of params.targetGroupIds) {
3839
+ const row = await getThreadInfo({
3840
+ profile: params.profile,
3841
+ threadId: groupId,
3842
+ threadType: "group"
3843
+ });
3844
+ const count = row && typeof row.messageCount === "number" && Number.isFinite(row.messageCount) ? row.messageCount : 0;
3845
+ total += count;
3846
+ }
3847
+ return total;
3848
+ };
3849
+ const persistMessages = async (messages) => {
3850
+ for (const message of messages) {
3851
+ if (!params.targetGroupIds.has(message.threadId)) {
3852
+ continue;
3853
+ }
3854
+ processed += 1;
3855
+ await persistMessage(
3856
+ toDbRecordFromRecentMessage({
3857
+ profile: params.profile,
3858
+ message,
3859
+ source: "sync_group",
3860
+ selfId: params.selfId,
3861
+ title: params.titleById.get(message.threadId)
3862
+ })
3863
+ );
3864
+ }
3865
+ };
3866
+ const beforeCount = await getStoredGroupMessageCount();
3867
+ let processed = 0;
3868
+ let completeness = "complete";
3869
+ let stopReason = "exhausted";
3870
+ let pagesRequested = 0;
3871
+ let listenerImportedCount = 0;
3872
+ try {
3873
+ params.progress?.(`syncing full history for ${params.targetGroupIds.size} group(s)`);
3874
+ const result = await crawlGroupHistoryViaListener(params.api, {
3875
+ maxPages: Number.MAX_SAFE_INTEGER,
3876
+ idleTimeoutMs: 15e3,
3877
+ onMessages: persistMessages,
3878
+ onPage: ({ pagesRequested: pagesRequested2, filteredCount }) => {
3879
+ params.progress?.(
3880
+ `groups page ${pagesRequested2}: batch ${filteredCount}, processed ${processed}`
3881
+ );
3882
+ }
3883
+ });
3884
+ completeness = result.stopReason === "exhausted" ? "complete" : result.stopReason === "max_pages" || result.stopReason === "timeout" ? "partial" : "window";
3885
+ stopReason = result.stopReason;
3886
+ pagesRequested = result.pagesRequested;
3887
+ listenerImportedCount = await getStoredGroupMessageCount() - beforeCount;
3888
+ } catch (error) {
3889
+ stopReason = `fallback_window:${toErrorText(error)}`;
3890
+ completeness = "window";
3891
+ }
3892
+ const fallbackCount = 200;
3893
+ params.progress?.(`merging recent group API window (${fallbackCount} per group)`);
3894
+ const beforeApiCount = await getStoredGroupMessageCount();
3895
+ for (const groupId of params.targetGroupIds) {
3896
+ const messages = await fetchRecentGroupMessagesViaApi(params.api, groupId, fallbackCount);
3897
+ await persistMessages(messages);
3898
+ params.progress?.(`group ${groupId}: fetched ${messages.length} message(s) from group history API`);
3899
+ }
3900
+ const afterCount = await getStoredGroupMessageCount();
3901
+ const apiAddedCount = afterCount - beforeApiCount;
3902
+ if (apiAddedCount > 0) {
3903
+ completeness = "window";
3904
+ if (stopReason === "exhausted" && listenerImportedCount === 0) {
3905
+ stopReason = "fallback_window:empty_listener_result";
3906
+ } else if (stopReason === "exhausted") {
3907
+ stopReason = "window_topoff:listener_incomplete";
3908
+ }
3909
+ }
3910
+ const imported = Math.max(afterCount - beforeCount, 0);
3911
+ for (const groupId of params.targetGroupIds) {
3912
+ await setSyncState({
3913
+ profile: params.profile,
3914
+ scopeThreadId: groupId,
3915
+ threadType: "group",
3916
+ status: "synced",
3917
+ completeness
3918
+ });
3919
+ }
3920
+ params.summary.groupsSynced += params.targetGroupIds.size;
3921
+ params.summary.groupMessagesImported += imported;
3922
+ params.summary.syncState.push({
3923
+ kind: "groups",
3924
+ groups: params.targetGroupIds.size,
3925
+ imported,
3926
+ completeness,
3927
+ stopReason,
3928
+ pagesRequested
3929
+ });
3930
+ }
3931
+ async function syncDbFriendDirectory(params) {
3932
+ params.progress?.("syncing friend directory");
3933
+ const names = await persistFriendDirectory(params.profile, params.api);
3934
+ params.summary.friendsSynced += names.size;
3935
+ params.progress?.(`friend directory synced: ${names.size} friend(s)`);
3936
+ params.summary.syncState.push({
3937
+ kind: "friends",
3938
+ imported: names.size
3939
+ });
3940
+ return names;
3941
+ }
3942
+ async function syncDbChatThread(params) {
3943
+ const scopeThreadId = resolveScopeThreadId({
3944
+ threadType: "user",
3945
+ rawThreadId: params.threadId,
3946
+ senderId: params.selfId,
3947
+ toId: params.threadId,
3948
+ selfId: params.selfId
3949
+ });
3950
+ await persistThread({
3951
+ profile: params.profile,
3952
+ scopeThreadId,
3953
+ rawThreadId: params.threadId,
3954
+ threadType: "user",
3955
+ peerId: scopeThreadId,
3956
+ title: params.title,
3957
+ isPinned: params.pinnedIds.has(params.threadId) || params.pinnedIds.has(scopeThreadId),
3958
+ isHidden: params.hiddenIds.has(params.threadId) || params.hiddenIds.has(scopeThreadId)
3959
+ });
3960
+ const messages = await fetchRecentUserMessagesViaListener(params.api, params.threadId, params.count);
3961
+ for (const message of messages) {
3962
+ await persistMessage(
3963
+ toDbRecordFromRecentMessage({
3964
+ profile: params.profile,
3965
+ message,
3966
+ source: "sync_dm_best_effort",
3967
+ selfId: params.selfId,
3968
+ title: params.title
3969
+ })
3970
+ );
3971
+ }
3972
+ await setSyncState({
3973
+ profile: params.profile,
3974
+ scopeThreadId,
3975
+ threadType: "user",
3976
+ status: "synced",
3977
+ completeness: "best_effort"
3978
+ });
3979
+ params.summary.chatsSynced += 1;
3980
+ params.summary.dmMessagesImported += messages.length;
3981
+ params.progress?.(`chat ${scopeThreadId}: imported ${messages.length} message(s)`);
3982
+ params.summary.syncState.push({
3983
+ kind: "chat",
3984
+ chatId: scopeThreadId,
3985
+ rawThreadId: params.threadId,
3986
+ imported: messages.length,
3987
+ completeness: "best_effort"
3988
+ });
3989
+ }
3990
+ async function syncDbChatsBestEffort(params) {
3991
+ const scanLimit = Math.max(params.count * 10, 500);
3992
+ params.progress?.(`scanning recent DM/chat windows (target window ${params.count}, scan limit ${scanLimit})`);
3993
+ const messages = await fetchRecentUserMessagesAcrossThreads(params.api, scanLimit);
3994
+ const seenScopes = /* @__PURE__ */ new Set();
3995
+ for (const message of messages) {
3996
+ const title = params.titleById.get(message.threadId);
3997
+ const record = toDbRecordFromRecentMessage({
3998
+ profile: params.profile,
3999
+ message,
4000
+ source: "sync_dm_best_effort",
4001
+ selfId: params.selfId,
4002
+ title
4003
+ });
4004
+ await persistThread({
4005
+ profile: params.profile,
4006
+ scopeThreadId: record.scopeThreadId,
4007
+ rawThreadId: record.rawThreadId,
4008
+ threadType: "user",
4009
+ peerId: record.scopeThreadId,
4010
+ title,
4011
+ isPinned: params.pinnedIds.has(record.rawThreadId) || params.pinnedIds.has(record.scopeThreadId),
4012
+ isHidden: params.hiddenIds.has(record.rawThreadId) || params.hiddenIds.has(record.scopeThreadId)
4013
+ });
4014
+ await persistMessage(record);
4015
+ if (!seenScopes.has(record.scopeThreadId)) {
4016
+ seenScopes.add(record.scopeThreadId);
4017
+ await setSyncState({
4018
+ profile: params.profile,
4019
+ scopeThreadId: record.scopeThreadId,
4020
+ threadType: "user",
4021
+ status: "synced",
4022
+ completeness: "best_effort"
4023
+ });
4024
+ }
4025
+ }
4026
+ params.summary.chatsSynced += seenScopes.size;
4027
+ params.summary.dmMessagesImported += messages.length;
4028
+ params.progress?.(`chat scan finished: ${messages.length} message(s) across ${seenScopes.size} chat(s)`);
4029
+ params.summary.syncState.push({
4030
+ kind: "chats",
4031
+ imported: messages.length,
4032
+ chats: seenScopes.size,
4033
+ completeness: "best_effort"
4034
+ });
4035
+ }
4036
+ async function runDbSync(params) {
4037
+ const { profile, api } = await requireApi(params.command);
4038
+ const dbPath = await resolveDbPath(profile);
4039
+ params.progress?.(`starting sync for profile ${profile}`);
4040
+ const summary = createDbSyncSummary(
4041
+ profile,
4042
+ dbPath,
4043
+ params.mode === "all" || params.mode === "chats" || params.mode === "chat" ? params.count : void 0
4044
+ );
4045
+ const selfId = api.getOwnId();
4046
+ const selfInfo = normalizeMeInfoOutput(await api.fetchAccountInfo());
4047
+ await persistSelfProfile({
4048
+ profile,
4049
+ userId: selfId,
4050
+ displayName: typeof selfInfo.displayName === "string" && selfInfo.displayName.trim() ? selfInfo.displayName.trim() : void 0,
4051
+ infoJson: JSON.stringify(selfInfo)
4052
+ });
4053
+ const { pinnedIds, hiddenIds } = await collectConversationIds(api);
4054
+ let friendNames = /* @__PURE__ */ new Map();
4055
+ if (params.mode === "all" || params.mode === "friends" || params.mode === "chats") {
4056
+ friendNames = await syncDbFriendDirectory({
4057
+ profile,
4058
+ api,
4059
+ summary,
4060
+ progress: params.progress
4061
+ });
4062
+ }
4063
+ if (params.mode === "all" || params.mode === "groups") {
4064
+ const groups = await buildGroupsDetailed(api);
4065
+ const targetGroupIds = /* @__PURE__ */ new Set();
4066
+ const titleById = /* @__PURE__ */ new Map();
4067
+ for (const group2 of groups) {
4068
+ const record = group2;
4069
+ const groupId = normalizeCachedId(record.groupId);
4070
+ if (!groupId) continue;
4071
+ const title = typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
4072
+ targetGroupIds.add(groupId);
4073
+ titleById.set(groupId, title);
4074
+ await prepareDbGroupTarget({
4075
+ profile,
4076
+ api,
4077
+ groupId,
4078
+ title,
4079
+ rawJson: JSON.stringify(group2),
4080
+ pinnedIds,
4081
+ hiddenIds
4082
+ });
4083
+ }
4084
+ await syncDbGroupHistoryFull({
4085
+ profile,
4086
+ api,
4087
+ selfId,
4088
+ targetGroupIds,
4089
+ titleById,
4090
+ summary,
4091
+ progress: params.progress
4092
+ });
4093
+ }
4094
+ if (params.mode === "group") {
4095
+ if (!params.groupId) {
4096
+ throw new Error("Missing group id for db sync group.");
4097
+ }
4098
+ const groupInfo = await api.getGroupInfo(params.groupId);
4099
+ const group2 = groupInfo.gridInfoMap[params.groupId];
4100
+ const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : void 0;
4101
+ await prepareDbGroupTarget({
4102
+ profile,
4103
+ api,
4104
+ groupId: params.groupId,
4105
+ title,
4106
+ rawJson: group2 ? JSON.stringify(group2) : void 0,
4107
+ pinnedIds,
4108
+ hiddenIds
4109
+ });
4110
+ await syncDbGroupHistoryFull({
4111
+ profile,
4112
+ api,
4113
+ selfId,
4114
+ targetGroupIds: /* @__PURE__ */ new Set([params.groupId]),
4115
+ titleById: /* @__PURE__ */ new Map([[params.groupId, title]]),
4116
+ summary,
4117
+ progress: params.progress
4118
+ });
4119
+ }
4120
+ if (params.mode === "chat") {
4121
+ if (!params.threadId) {
4122
+ throw new Error("Missing chat id for db sync chat.");
4123
+ }
4124
+ if (friendNames.size === 0) {
4125
+ friendNames = await persistFriendDirectory(profile, api);
4126
+ }
4127
+ await syncDbChatThread({
4128
+ profile,
4129
+ api,
4130
+ selfId,
4131
+ threadId: params.threadId,
4132
+ count: params.count,
4133
+ title: friendNames.get(params.threadId),
4134
+ pinnedIds,
4135
+ hiddenIds,
4136
+ summary,
4137
+ progress: params.progress
4138
+ });
4139
+ }
4140
+ if (params.mode === "all" || params.mode === "chats") {
4141
+ if (friendNames.size === 0) {
4142
+ friendNames = await persistFriendDirectory(profile, api);
4143
+ }
4144
+ await syncDbChatsBestEffort({
4145
+ profile,
4146
+ api,
4147
+ selfId,
4148
+ count: params.count,
4149
+ titleById: friendNames,
4150
+ pinnedIds,
4151
+ hiddenIds,
4152
+ summary,
4153
+ progress: params.progress
4154
+ });
4155
+ }
4156
+ params.progress?.(
4157
+ `done: groups=${summary.groupsSynced}, groupMessages=${summary.groupMessagesImported}, friends=${summary.friendsSynced}, chats=${summary.chatsSynced}, dmMessages=${summary.dmMessagesImported}`
4158
+ );
4159
+ return summary;
4160
+ }
1930
4161
  async function buildGroupsDetailed(api) {
1931
4162
  const groups = await api.getAllGroups();
1932
4163
  const ids = Object.keys(groups.gridVerMap ?? {});
@@ -2309,7 +4540,7 @@ function normalizeGroupHistoryMessages(messages, fallbackThreadId) {
2309
4540
  const threadIdRaw = String(raw.idTo ?? "").trim();
2310
4541
  normalized.push({
2311
4542
  threadId: threadIdRaw || fallbackThreadId,
2312
- type: ThreadType2.Group,
4543
+ type: ThreadType3.Group,
2313
4544
  data: {
2314
4545
  actionId: typeof raw.actionId === "string" && raw.actionId.trim() ? raw.actionId : void 0,
2315
4546
  msgId: String(raw.msgId ?? ""),
@@ -2383,12 +4614,168 @@ async function fetchRecentGroupMessagesViaApi(api, threadId, count) {
2383
4614
  return fetchRecentGroupMessagesViaListener(api, threadId, count);
2384
4615
  }
2385
4616
  async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
4617
+ const result = await crawlGroupHistoryViaListener(api, {
4618
+ threadId,
4619
+ limit: count,
4620
+ maxPages: parsePositiveIntFromEnv("OPENZCA_RECENT_GROUP_MAX_PAGES", 20),
4621
+ idleTimeoutMs: 12e3
4622
+ });
4623
+ return result.messages;
4624
+ }
4625
+ async function crawlGroupHistoryViaListener(api, options) {
4626
+ return new Promise((resolve, reject) => {
4627
+ let settled = false;
4628
+ let stopReason = "closed";
4629
+ const shouldCollect = options.limit != null || !options.onMessages;
4630
+ const collected = [];
4631
+ const seenMessageKeys = /* @__PURE__ */ new Set();
4632
+ const requestedCursors = /* @__PURE__ */ new Set();
4633
+ let pagesRequested = 0;
4634
+ let idleTimer;
4635
+ let processing = Promise.resolve();
4636
+ const toKey = (message) => {
4637
+ const msgId = String(message.data?.msgId ?? "");
4638
+ const cliMsgId = String(message.data?.cliMsgId ?? "");
4639
+ return `${message.threadId}:${msgId}:${cliMsgId}`;
4640
+ };
4641
+ const requestPage = (lastId) => {
4642
+ const cursor = String(lastId ?? "").trim();
4643
+ if (cursor) {
4644
+ if (requestedCursors.has(cursor)) return false;
4645
+ requestedCursors.add(cursor);
4646
+ }
4647
+ pagesRequested += 1;
4648
+ api.listener.requestOldMessages(ThreadType3.Group, cursor || null);
4649
+ return true;
4650
+ };
4651
+ const armIdleTimer = () => {
4652
+ if (idleTimer) {
4653
+ clearTimeout(idleTimer);
4654
+ }
4655
+ idleTimer = setTimeout(() => {
4656
+ finish(void 0, "timeout");
4657
+ }, options.idleTimeoutMs);
4658
+ };
4659
+ const cleanup = () => {
4660
+ if (idleTimer) {
4661
+ clearTimeout(idleTimer);
4662
+ }
4663
+ api.listener.off("connected", onConnected);
4664
+ api.listener.off("old_messages", onOldMessages);
4665
+ api.listener.off("error", onError);
4666
+ api.listener.off("closed", onClosed);
4667
+ try {
4668
+ api.listener.stop();
4669
+ } catch {
4670
+ }
4671
+ };
4672
+ const finish = (error, reason) => {
4673
+ if (settled) return;
4674
+ settled = true;
4675
+ if (reason) {
4676
+ stopReason = reason;
4677
+ }
4678
+ void processing.then(() => {
4679
+ cleanup();
4680
+ if (error) {
4681
+ reject(error);
4682
+ return;
4683
+ }
4684
+ resolve({
4685
+ messages: options.limit != null ? sortRecentMessagesNewestFirst(collected).slice(0, options.limit) : collected,
4686
+ stopReason,
4687
+ pagesRequested
4688
+ });
4689
+ }).catch((processingError) => {
4690
+ cleanup();
4691
+ reject(processingError);
4692
+ });
4693
+ };
4694
+ const onConnected = () => {
4695
+ try {
4696
+ armIdleTimer();
4697
+ requestPage(null);
4698
+ } catch (error) {
4699
+ finish(error, "closed");
4700
+ }
4701
+ };
4702
+ const onOldMessages = (messages, type) => {
4703
+ if (type !== ThreadType3.Group) return;
4704
+ armIdleTimer();
4705
+ const typedMessages = messages;
4706
+ processing = processing.then(async () => {
4707
+ const filtered = [];
4708
+ for (const message of typedMessages) {
4709
+ if (options.threadId && message.threadId !== options.threadId) {
4710
+ continue;
4711
+ }
4712
+ const key = toKey(message);
4713
+ if (seenMessageKeys.has(key)) continue;
4714
+ seenMessageKeys.add(key);
4715
+ if (shouldCollect) {
4716
+ collected.push(message);
4717
+ }
4718
+ filtered.push(message);
4719
+ }
4720
+ if (filtered.length > 0) {
4721
+ await options.onMessages?.(filtered);
4722
+ }
4723
+ await options.onPage?.({
4724
+ pagesRequested,
4725
+ filteredCount: filtered.length,
4726
+ collectedCount: collected.length
4727
+ });
4728
+ if (options.limit != null && collected.length >= options.limit) {
4729
+ finish(void 0, "limit");
4730
+ return;
4731
+ }
4732
+ if (typedMessages.length === 0) {
4733
+ finish(void 0, "exhausted");
4734
+ return;
4735
+ }
4736
+ if (pagesRequested >= options.maxPages) {
4737
+ finish(void 0, "max_pages");
4738
+ return;
4739
+ }
4740
+ const cursorCandidates = getRecentPageCursors(typedMessages);
4741
+ let requested = false;
4742
+ for (const cursor of cursorCandidates) {
4743
+ if (requestPage(cursor)) {
4744
+ requested = true;
4745
+ break;
4746
+ }
4747
+ }
4748
+ if (!requested) {
4749
+ finish(void 0, "exhausted");
4750
+ }
4751
+ }).catch((error) => {
4752
+ finish(error, "closed");
4753
+ });
4754
+ };
4755
+ const onError = (error) => {
4756
+ finish(error, "closed");
4757
+ };
4758
+ const onClosed = () => {
4759
+ finish(void 0, "closed");
4760
+ };
4761
+ api.listener.on("connected", onConnected);
4762
+ api.listener.on("old_messages", onOldMessages);
4763
+ api.listener.on("error", onError);
4764
+ api.listener.on("closed", onClosed);
4765
+ try {
4766
+ api.listener.start();
4767
+ } catch (error) {
4768
+ finish(error);
4769
+ }
4770
+ });
4771
+ }
4772
+ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2386
4773
  return new Promise((resolve, reject) => {
2387
4774
  let settled = false;
2388
4775
  const collected = [];
2389
4776
  const seenMessageKeys = /* @__PURE__ */ new Set();
2390
4777
  const requestedCursors = /* @__PURE__ */ new Set();
2391
- const maxPages = parsePositiveIntFromEnv("OPENZCA_RECENT_GROUP_MAX_PAGES", 20);
4778
+ const maxPages = parsePositiveIntFromEnv("OPENZCA_RECENT_USER_MAX_PAGES", 20);
2392
4779
  let pagesRequested = 0;
2393
4780
  const toKey = (message) => {
2394
4781
  const msgId = String(message.data?.msgId ?? "");
@@ -2402,7 +4789,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
2402
4789
  requestedCursors.add(cursor);
2403
4790
  }
2404
4791
  pagesRequested += 1;
2405
- api.listener.requestOldMessages(ThreadType2.Group, cursor || null);
4792
+ api.listener.requestOldMessages(ThreadType3.User, cursor || null);
2406
4793
  return true;
2407
4794
  };
2408
4795
  const cleanup = () => {
@@ -2434,7 +4821,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
2434
4821
  }
2435
4822
  };
2436
4823
  const onOldMessages = (messages, type) => {
2437
- if (type !== ThreadType2.Group) return;
4824
+ if (type !== ThreadType3.User) return;
2438
4825
  const typedMessages = messages;
2439
4826
  for (const message of typedMessages) {
2440
4827
  if (message.threadId === threadId) {
@@ -2490,7 +4877,7 @@ async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
2490
4877
  }
2491
4878
  });
2492
4879
  }
2493
- async function fetchRecentUserMessagesViaListener(api, threadId, count) {
4880
+ async function fetchRecentUserMessagesAcrossThreads(api, maxMessages) {
2494
4881
  return new Promise((resolve, reject) => {
2495
4882
  let settled = false;
2496
4883
  const collected = [];
@@ -2501,7 +4888,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2501
4888
  const toKey = (message) => {
2502
4889
  const msgId = String(message.data?.msgId ?? "");
2503
4890
  const cliMsgId = String(message.data?.cliMsgId ?? "");
2504
- return `${msgId}:${cliMsgId}`;
4891
+ return `${message.threadId}:${msgId}:${cliMsgId}`;
2505
4892
  };
2506
4893
  const requestPage = (lastId) => {
2507
4894
  const cursor = String(lastId ?? "").trim();
@@ -2510,7 +4897,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2510
4897
  requestedCursors.add(cursor);
2511
4898
  }
2512
4899
  pagesRequested += 1;
2513
- api.listener.requestOldMessages(ThreadType2.User, cursor || null);
4900
+ api.listener.requestOldMessages(ThreadType3.User, cursor || null);
2514
4901
  return true;
2515
4902
  };
2516
4903
  const cleanup = () => {
@@ -2532,7 +4919,7 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2532
4919
  reject(error);
2533
4920
  return;
2534
4921
  }
2535
- resolve(sortRecentMessagesNewestFirst(collected).slice(0, count));
4922
+ resolve(sortRecentMessagesNewestFirst(collected).slice(0, maxMessages));
2536
4923
  };
2537
4924
  const onConnected = () => {
2538
4925
  try {
@@ -2542,25 +4929,15 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2542
4929
  }
2543
4930
  };
2544
4931
  const onOldMessages = (messages, type) => {
2545
- if (type !== ThreadType2.User) return;
4932
+ if (type !== ThreadType3.User) return;
2546
4933
  const typedMessages = messages;
2547
4934
  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;
4935
+ const key = toKey(message);
4936
+ if (seenMessageKeys.has(key)) continue;
4937
+ seenMessageKeys.add(key);
4938
+ collected.push(message);
2562
4939
  }
2563
- if (pagesRequested >= maxPages) {
4940
+ if (collected.length >= maxMessages || typedMessages.length === 0 || pagesRequested >= maxPages) {
2564
4941
  finish();
2565
4942
  return;
2566
4943
  }
@@ -2598,8 +4975,70 @@ async function fetchRecentUserMessagesViaListener(api, threadId, count) {
2598
4975
  }
2599
4976
  });
2600
4977
  }
4978
+ function normalizeRecentMessageMentions(value) {
4979
+ if (!Array.isArray(value)) {
4980
+ return [];
4981
+ }
4982
+ const rows = [];
4983
+ const parseOptionalMentionInt = (input) => {
4984
+ if (typeof input === "number" && Number.isFinite(input)) {
4985
+ return Math.trunc(input);
4986
+ }
4987
+ if (typeof input === "string" && input.trim()) {
4988
+ const parsed = Number(input.trim());
4989
+ if (Number.isFinite(parsed)) {
4990
+ return Math.trunc(parsed);
4991
+ }
4992
+ }
4993
+ return void 0;
4994
+ };
4995
+ for (const item of value) {
4996
+ if (!item || typeof item !== "object") continue;
4997
+ const record = item;
4998
+ const uid = normalizeCachedId(record.uid);
4999
+ if (!uid) continue;
5000
+ rows.push({
5001
+ uid,
5002
+ pos: parseOptionalMentionInt(record.pos),
5003
+ len: parseOptionalMentionInt(record.len),
5004
+ 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,
5005
+ rawJson: JSON.stringify(record)
5006
+ });
5007
+ }
5008
+ return rows;
5009
+ }
5010
+ function toDbRecordFromRecentMessage(params) {
5011
+ const content = params.message.data?.content;
5012
+ const quote = params.message.data?.quote;
5013
+ return normalizeInboundListenRecord({
5014
+ profile: params.profile,
5015
+ threadType: params.message.type === ThreadType3.Group ? "group" : "user",
5016
+ rawThreadId: params.message.threadId,
5017
+ senderId: params.message.data?.uidFrom,
5018
+ senderName: params.message.data?.dName,
5019
+ toId: params.message.data?.idTo,
5020
+ selfId: params.selfId,
5021
+ title: params.title,
5022
+ msgId: params.message.data?.msgId,
5023
+ cliMsgId: params.message.data?.cliMsgId,
5024
+ actionId: params.message.data?.actionId,
5025
+ timestampMs: toEpochMs(params.message.data?.ts),
5026
+ msgType: params.message.data?.msgType,
5027
+ contentText: typeof content === "string" ? content : void 0,
5028
+ contentJson: content && typeof content === "object" ? JSON.stringify(content) : void 0,
5029
+ quoteMsgId: quote?.globalMsgId != null ? String(quote.globalMsgId) : void 0,
5030
+ quoteCliMsgId: quote?.cliMsgId != null ? String(quote.cliMsgId) : void 0,
5031
+ quoteOwnerId: quote?.ownerId != null ? String(quote.ownerId) : void 0,
5032
+ quoteText: typeof quote?.msg === "string" ? quote.msg : void 0,
5033
+ mentions: normalizeRecentMessageMentions(
5034
+ params.message.data?.mentions
5035
+ ),
5036
+ rawMessage: params.message.data,
5037
+ source: params.source
5038
+ });
5039
+ }
2601
5040
  async function parseCredentialFile(filePath) {
2602
- const raw = await fs5.readFile(filePath, "utf8");
5041
+ const raw = await fs6.readFile(filePath, "utf8");
2603
5042
  const parsed = JSON.parse(raw);
2604
5043
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
2605
5044
  throw new Error("Credential file must include imei, cookie, and userAgent.");
@@ -2620,7 +5059,7 @@ async function waitForFileContent(filePath, timeoutMs) {
2620
5059
  const startedAt = Date.now();
2621
5060
  while (Date.now() - startedAt < timeoutMs) {
2622
5061
  try {
2623
- const data = await fs5.readFile(filePath);
5062
+ const data = await fs6.readFile(filePath);
2624
5063
  if (data.length > 0) {
2625
5064
  return data;
2626
5065
  }
@@ -2635,8 +5074,8 @@ async function emitQrBase64FromDetachedLogin(profile, qrPath) {
2635
5074
  if (!scriptPath) {
2636
5075
  throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
2637
5076
  }
2638
- const tempDir = await fs5.mkdtemp(path5.join(os4.tmpdir(), "openzca-qr-"));
2639
- const targetPath = path5.resolve(qrPath ?? path5.join(tempDir, "qr.png"));
5077
+ const tempDir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-qr-"));
5078
+ const targetPath = path6.resolve(qrPath ?? path6.join(tempDir, "qr.png"));
2640
5079
  const child = spawn2(
2641
5080
  process.execPath,
2642
5081
  [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
@@ -2914,7 +5353,7 @@ function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
2914
5353
  if (fromType) return fromType;
2915
5354
  try {
2916
5355
  const parsedUrl = new URL(mediaUrl);
2917
- const ext = path5.extname(parsedUrl.pathname);
5356
+ const ext = path6.extname(parsedUrl.pathname);
2918
5357
  if (ext) return ext;
2919
5358
  } catch {
2920
5359
  }
@@ -2945,20 +5384,20 @@ function parseInboundMediaFetchTimeoutMs() {
2945
5384
  return Math.trunc(parsed);
2946
5385
  }
2947
5386
  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");
5387
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path6.join(os4.homedir(), ".openclaw");
5388
+ return path6.join(stateDir, "media");
2950
5389
  }
2951
5390
  function resolveInboundMediaDir(profile) {
2952
5391
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
2953
5392
  if (configuredRaw) {
2954
5393
  const configured = normalizeMediaInput(configuredRaw);
2955
- return path5.isAbsolute(configured) ? configured : path5.resolve(process.cwd(), configured);
5394
+ return path6.isAbsolute(configured) ? configured : path6.resolve(process.cwd(), configured);
2956
5395
  }
2957
5396
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
2958
5397
  if (legacyRequested) {
2959
- return path5.join(getProfileDir(profile), "inbound-media");
5398
+ return path6.join(getProfileDir(profile), "inbound-media");
2960
5399
  }
2961
- return path5.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
5400
+ return path6.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
2962
5401
  }
2963
5402
  async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2964
5403
  const maxBytes = parseMaxInboundMediaBytes();
@@ -2992,11 +5431,11 @@ async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
2992
5431
  return null;
2993
5432
  }
2994
5433
  const dir = resolveInboundMediaDir(profile);
2995
- await fs5.mkdir(dir, { recursive: true });
5434
+ await fs6.mkdir(dir, { recursive: true });
2996
5435
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
2997
5436
  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);
5437
+ const mediaPath = path6.join(dir, `${id}${ext}`);
5438
+ await fs6.writeFile(mediaPath, data);
3000
5439
  return { mediaPath, mediaType };
3001
5440
  }
3002
5441
  async function cacheRemoteMediaEntries(params) {
@@ -3362,6 +5801,16 @@ function toEpochSeconds(input) {
3362
5801
  }
3363
5802
  return Math.floor(numeric);
3364
5803
  }
5804
+ function toEpochMs(input) {
5805
+ const numeric = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
5806
+ if (!Number.isFinite(numeric) || numeric <= 0) {
5807
+ return Date.now();
5808
+ }
5809
+ if (numeric < 1e10) {
5810
+ return Math.floor(numeric * 1e3);
5811
+ }
5812
+ return Math.floor(numeric);
5813
+ }
3365
5814
  function parseNonNegativeIntOption(label, value) {
3366
5815
  if (!value || !value.trim()) return void 0;
3367
5816
  const parsed = Number(value.trim());
@@ -3370,6 +5819,14 @@ function parseNonNegativeIntOption(label, value) {
3370
5819
  }
3371
5820
  return Math.trunc(parsed);
3372
5821
  }
5822
+ function parsePositiveIntOption(label, value) {
5823
+ if (!value || !value.trim()) return void 0;
5824
+ const parsed = Number(value.trim());
5825
+ if (!Number.isFinite(parsed) || parsed <= 0) {
5826
+ throw new Error(`${label} must be a positive number.`);
5827
+ }
5828
+ return Math.trunc(parsed);
5829
+ }
3373
5830
  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
5831
  program.hook("preAction", (_parent, actionCommand) => {
3375
5832
  if (!resolveDebugEnabled(actionCommand)) {
@@ -3488,7 +5945,7 @@ auth.command("login").description("Login with QR code").option("-q, --qr-path <p
3488
5945
  auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
3489
5946
  wrapAction(async (file, command) => {
3490
5947
  const profile = await currentProfile(command);
3491
- const credentials = file ? await parseCredentialFile(path5.resolve(normalizeMediaInput(file))) : toCredentials(
5948
+ const credentials = file ? await parseCredentialFile(path6.resolve(normalizeMediaInput(file))) : toCredentials(
3492
5949
  await loadCredentials(profile) ?? (() => {
3493
5950
  throw new Error(
3494
5951
  `No saved credentials for profile "${profile}". Run: openzca auth login`
@@ -3561,26 +6018,397 @@ auth.command("cache-clear").description("Clear local cache").action(
3561
6018
  console.log(`Cache cleared for profile ${profile}`);
3562
6019
  })
3563
6020
  );
6021
+ var dbCmd = program.command("db").description("Profile-scoped SQLite message database");
6022
+ dbCmd.command("enable").option("--path <path>", "Custom SQLite file path").description("Enable local SQLite persistence for the active profile").action(
6023
+ wrapAction(async (opts, command) => {
6024
+ const profile = await currentProfile(command);
6025
+ await enableDb(profile, opts.path);
6026
+ output(await getDbStatus(profile), false);
6027
+ })
6028
+ );
6029
+ dbCmd.command("disable").description("Disable automatic SQLite persistence for the active profile").action(
6030
+ wrapAction(async (command) => {
6031
+ const profile = await currentProfile(command);
6032
+ await disableDb(profile);
6033
+ await closeDb(profile);
6034
+ output(await getDbStatus(profile), false);
6035
+ })
6036
+ );
6037
+ 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(
6038
+ wrapAction(async (opts, command) => {
6039
+ if (!opts.yes) {
6040
+ const confirmed = await confirmDestructiveAction(
6041
+ "Reset the local SQLite DB for the active profile?"
6042
+ );
6043
+ if (!confirmed) {
6044
+ console.log("Cancelled.");
6045
+ return;
6046
+ }
6047
+ }
6048
+ const profile = await currentProfile(command);
6049
+ const dbPath = await resolveDbPath(profile);
6050
+ const configPath = getDbConfigPath(profile);
6051
+ await closeDb(profile);
6052
+ const removedPaths = [];
6053
+ const deleteIfExists = async (filename) => {
6054
+ try {
6055
+ await fs6.unlink(filename);
6056
+ removedPaths.push(filename);
6057
+ } catch (error) {
6058
+ if (error.code !== "ENOENT") {
6059
+ throw error;
6060
+ }
6061
+ }
6062
+ };
6063
+ await deleteIfExists(dbPath);
6064
+ await deleteIfExists(`${dbPath}-wal`);
6065
+ await deleteIfExists(`${dbPath}-shm`);
6066
+ if (opts.dropConfig) {
6067
+ await deleteIfExists(configPath);
6068
+ }
6069
+ const status = await getDbStatus(profile);
6070
+ output(
6071
+ {
6072
+ profile,
6073
+ removedPaths,
6074
+ droppedConfig: Boolean(opts.dropConfig),
6075
+ status: {
6076
+ enabled: status.enabled,
6077
+ path: status.path,
6078
+ exists: status.exists,
6079
+ messageCount: status.messageCount,
6080
+ threadCount: status.threadCount,
6081
+ groupCount: status.groupCount,
6082
+ userCount: status.userCount
6083
+ }
6084
+ },
6085
+ Boolean(opts.json)
6086
+ );
6087
+ })
6088
+ );
6089
+ dbCmd.command("status").option("-j, --json", "JSON output").description("Show DB status for the active profile").action(
6090
+ wrapAction(async (opts, command) => {
6091
+ const profile = await currentProfile(command);
6092
+ const config = await readDbConfig(profile);
6093
+ const status = await getDbStatus(profile);
6094
+ const syncRows = await listSyncState({ profile });
6095
+ output(
6096
+ {
6097
+ profile,
6098
+ enabled: status.enabled,
6099
+ path: await resolveDbPath(profile),
6100
+ exists: status.exists,
6101
+ configuredPath: config.path ?? null,
6102
+ messageCount: status.messageCount,
6103
+ threadCount: status.threadCount,
6104
+ groupCount: status.groupCount,
6105
+ userCount: status.userCount,
6106
+ syncStates: {
6107
+ total: syncRows.length,
6108
+ synced: syncRows.filter((row) => row.status === "synced").length,
6109
+ errors: syncRows.filter((row) => row.status === "error").length
6110
+ },
6111
+ lastMessageAtMs: status.lastMessageAtMs ?? null,
6112
+ updatedAt: status.updatedAt ?? null
6113
+ },
6114
+ Boolean(opts.json)
6115
+ );
6116
+ })
6117
+ );
6118
+ var dbMe = dbCmd.command("me").description("Query stored self profile data");
6119
+ dbMe.command("info").option("-j, --json", "JSON output").description("Show stored self profile info").action(
6120
+ wrapAction(async (opts, command) => {
6121
+ const profile = await currentProfile(command);
6122
+ const row = await getSelfProfile(profile);
6123
+ if (!row?.info) {
6124
+ throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
6125
+ }
6126
+ output(row.info, Boolean(opts.json));
6127
+ })
6128
+ );
6129
+ dbMe.command("id").description("Show stored self user ID").action(
6130
+ wrapAction(async (command) => {
6131
+ const profile = await currentProfile(command);
6132
+ const row = await getSelfProfile(profile);
6133
+ if (!row?.userId) {
6134
+ throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
6135
+ }
6136
+ console.log(row.userId);
6137
+ })
6138
+ );
6139
+ var dbGroup = dbCmd.command("group").description("Query stored group data");
6140
+ dbGroup.command("list").option("-j, --json", "JSON output").description("List groups stored in the local DB").action(
6141
+ wrapAction(async (opts, command) => {
6142
+ const profile = await currentProfile(command);
6143
+ output(await listGroups(profile), Boolean(opts.json));
6144
+ })
6145
+ );
6146
+ dbGroup.command("info <groupId>").option("-j, --json", "JSON output").description("Show stored info for a group").action(
6147
+ wrapAction(async (groupId, opts, command) => {
6148
+ const profile = await currentProfile(command);
6149
+ const row = await getThreadInfo({ profile, threadId: groupId, threadType: "group" });
6150
+ if (!row) {
6151
+ throw new Error(`Group not found in DB: ${groupId}`);
6152
+ }
6153
+ output(row, Boolean(opts.json));
6154
+ })
6155
+ );
6156
+ dbGroup.command("members <groupId>").option("-j, --json", "JSON output").description("List stored members for a group").action(
6157
+ wrapAction(async (groupId, opts, command) => {
6158
+ const profile = await currentProfile(command);
6159
+ output(await listThreadMembers({ profile, threadId: groupId }), Boolean(opts.json));
6160
+ })
6161
+ );
6162
+ 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(
6163
+ wrapAction(async (groupId, opts, command) => {
6164
+ const profile = await currentProfile(command);
6165
+ const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6166
+ const rows = await listMessages({
6167
+ profile,
6168
+ threadId: groupId,
6169
+ threadType: "group",
6170
+ sinceMs,
6171
+ untilMs,
6172
+ limit,
6173
+ newestFirst
6174
+ });
6175
+ output(
6176
+ {
6177
+ groupId,
6178
+ count: rows.length,
6179
+ messages: rows
6180
+ },
6181
+ Boolean(opts.json)
6182
+ );
6183
+ })
6184
+ );
6185
+ var dbFriend = dbCmd.command("friend").description("Query stored friend directory data");
6186
+ dbFriend.command("list").option("-j, --json", "JSON output").description("List friends stored in the local DB").action(
6187
+ wrapAction(async (opts, command) => {
6188
+ const profile = await currentProfile(command);
6189
+ output(await listFriends(profile), Boolean(opts.json));
6190
+ })
6191
+ );
6192
+ dbFriend.command("find <query>").option("-j, --json", "JSON output").description("Find stored friends by ID or name").action(
6193
+ wrapAction(async (query, opts, command) => {
6194
+ const profile = await currentProfile(command);
6195
+ output(await findFriends({ profile, query }), Boolean(opts.json));
6196
+ })
6197
+ );
6198
+ dbFriend.command("info <userId>").option("-j, --json", "JSON output").description("Show stored info for a friend").action(
6199
+ wrapAction(async (userId, opts, command) => {
6200
+ const profile = await currentProfile(command);
6201
+ const row = await getFriendInfo({ profile, userId });
6202
+ if (!row) {
6203
+ throw new Error(`Friend not found in DB: ${userId}`);
6204
+ }
6205
+ output(row, Boolean(opts.json));
6206
+ })
6207
+ );
6208
+ 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(
6209
+ wrapAction(async (userId, opts, command) => {
6210
+ const profile = await currentProfile(command);
6211
+ const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6212
+ const rows = await listMessages({
6213
+ profile,
6214
+ threadId: userId,
6215
+ threadType: "user",
6216
+ sinceMs,
6217
+ untilMs,
6218
+ limit,
6219
+ newestFirst
6220
+ });
6221
+ output(
6222
+ {
6223
+ userId,
6224
+ count: rows.length,
6225
+ messages: rows
6226
+ },
6227
+ Boolean(opts.json)
6228
+ );
6229
+ })
6230
+ );
6231
+ var dbChat = dbCmd.command("chat").description("Query stored conversation data");
6232
+ dbChat.command("list").option("-j, --json", "JSON output").description("List chats stored in the local DB").action(
6233
+ wrapAction(async (opts, command) => {
6234
+ const profile = await currentProfile(command);
6235
+ output(await listChats(profile), shouldOutputJson(opts));
6236
+ })
6237
+ );
6238
+ 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(
6239
+ wrapAction(async (chatId, opts, command) => {
6240
+ const profile = await currentProfile(command);
6241
+ const row = await getThreadInfo({
6242
+ profile,
6243
+ threadId: chatId,
6244
+ threadType: opts.group ? "group" : void 0
6245
+ });
6246
+ if (!row) {
6247
+ throw new Error(`Chat not found in DB: ${chatId}`);
6248
+ }
6249
+ output(row, shouldOutputJson(opts));
6250
+ })
6251
+ );
6252
+ 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(
6253
+ wrapAction(async (chatId, opts, command) => {
6254
+ const profile = await currentProfile(command);
6255
+ const threadType = await resolveStoredChatThreadType(profile, chatId, opts.group);
6256
+ const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6257
+ const rows = await listMessages({
6258
+ profile,
6259
+ threadId: chatId,
6260
+ threadType,
6261
+ sinceMs,
6262
+ untilMs,
6263
+ limit,
6264
+ newestFirst
6265
+ });
6266
+ output(
6267
+ {
6268
+ chatId,
6269
+ threadType,
6270
+ count: rows.length,
6271
+ messages: rows
6272
+ },
6273
+ shouldOutputJson(opts)
6274
+ );
6275
+ })
6276
+ );
6277
+ var dbMessage = dbCmd.command("message").description("Query stored messages");
6278
+ dbMessage.command("get <id>").option("-j, --json", "JSON output").description("Read one stored message by msgId, cliMsgId, or internal uid").action(
6279
+ wrapAction(async (id, opts, command) => {
6280
+ const profile = await currentProfile(command);
6281
+ const row = await getMessageById({ profile, id });
6282
+ if (!row) {
6283
+ throw new Error(`Message not found in DB: ${id}`);
6284
+ }
6285
+ output(row, Boolean(opts.json));
6286
+ })
6287
+ );
6288
+ var dbSync = dbCmd.command("sync").description("Sync discoverable data into the local DB");
6289
+ dbSync.enablePositionalOptions();
6290
+ dbSync.option("-n, --count <count>", "Recent DM/chat messages to fetch per window", "200").option("-j, --json", "JSON output").action(
6291
+ wrapAction(async (opts, command) => {
6292
+ const count = resolveSyncWindowCount(opts.count);
6293
+ const progress = createSyncProgressReporter();
6294
+ const summary = await runDbSync({
6295
+ command,
6296
+ mode: "all",
6297
+ count,
6298
+ progress
6299
+ });
6300
+ output(summary, Boolean(opts.json));
6301
+ })
6302
+ );
6303
+ 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(
6304
+ wrapAction(async (_opts, command) => {
6305
+ const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
6306
+ output(
6307
+ await runDbSync({ command, mode: "all", count, progress: createSyncProgressReporter() }),
6308
+ readCliFlag(["--json", "-j"])
6309
+ );
6310
+ })
6311
+ );
6312
+ dbSync.command("groups").option("-j, --json", "JSON output").description("Sync group directory, members, and full group history").action(
6313
+ wrapAction(async (_opts, command) => {
6314
+ output(
6315
+ await runDbSync({ command, mode: "groups", count: 0, progress: createSyncProgressReporter() }),
6316
+ readCliFlag(["--json", "-j"])
6317
+ );
6318
+ })
6319
+ );
6320
+ dbSync.command("friends").option("-j, --json", "JSON output").description("Sync friend directory only").action(
6321
+ wrapAction(async (_opts, command) => {
6322
+ output(
6323
+ await runDbSync({ command, mode: "friends", count: 0, progress: createSyncProgressReporter() }),
6324
+ readCliFlag(["--json", "-j"])
6325
+ );
6326
+ })
6327
+ );
6328
+ 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(
6329
+ wrapAction(async (_opts, command) => {
6330
+ const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
6331
+ output(
6332
+ await runDbSync({ command, mode: "chats", count, progress: createSyncProgressReporter() }),
6333
+ readCliFlag(["--json", "-j"])
6334
+ );
6335
+ })
6336
+ );
6337
+ dbSync.command("group <groupId>").option("-j, --json", "JSON output").description("Sync one group with full group history").action(
6338
+ wrapAction(async (groupId, _opts, command) => {
6339
+ output(
6340
+ await runDbSync({
6341
+ command,
6342
+ mode: "group",
6343
+ count: 0,
6344
+ groupId,
6345
+ progress: createSyncProgressReporter()
6346
+ }),
6347
+ readCliFlag(["--json", "-j"])
6348
+ );
6349
+ })
6350
+ );
6351
+ 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(
6352
+ wrapAction(async (chatId, _opts, command) => {
6353
+ const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
6354
+ output(
6355
+ await runDbSync({
6356
+ command,
6357
+ mode: "chat",
6358
+ count,
6359
+ threadId: chatId,
6360
+ progress: createSyncProgressReporter()
6361
+ }),
6362
+ readCliFlag(["--json", "-j"])
6363
+ );
6364
+ })
6365
+ );
3564
6366
  var msg = program.command("msg").description("Messaging commands");
3565
- 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(
6367
+ msg.command("send <threadId> <message>").option("-g, --group", "Send to group").option("--raw", "Send raw text without parsing formatting markers").option("--reply-id <id>", "Reply using a stored DB message id/msgId/cliMsgId").option("--reply-message <json>", "Reply using a raw message.data JSON object").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
6368
  wrapAction(async (threadId, message, opts, command) => {
3567
- const { api } = await requireApi(command);
6369
+ const { api, profile } = await requireApi(command);
3568
6370
  const threadType = asThreadType(opts.group);
3569
- const payload = await buildTextSendPayload({
6371
+ const textPayload = await buildTextSendPayload({
3570
6372
  message,
3571
6373
  raw: opts.raw,
3572
6374
  threadType,
3573
6375
  threadId,
3574
- listGroupMembers: threadType === ThreadType2.Group ? (groupId) => listGroupMentionMembers(api, groupId) : void 0
6376
+ listGroupMembers: threadType === ThreadType3.Group ? (groupId) => listGroupMentionMembers(api, groupId) : void 0
3575
6377
  });
6378
+ const quote = await resolveSendReplyQuote({
6379
+ profile,
6380
+ api,
6381
+ threadId,
6382
+ threadType,
6383
+ replyId: opts.replyId,
6384
+ replyMessage: opts.replyMessage
6385
+ });
6386
+ const payload = quote || typeof textPayload !== "string" ? {
6387
+ ...typeof textPayload === "string" ? { msg: textPayload } : textPayload,
6388
+ ...quote ? { quote } : {}
6389
+ } : textPayload;
3576
6390
  const response = await api.sendMessage(payload, threadId, threadType);
3577
6391
  output(response, false);
6392
+ if (await shouldWriteToDb(profile)) {
6393
+ scheduleDbWrite(profile, command, "msg.send.db.persist_error", async () => {
6394
+ await persistOutgoingMessageBestEffort({
6395
+ profile,
6396
+ api,
6397
+ threadId,
6398
+ group: opts.group,
6399
+ text: message,
6400
+ msgType: "text",
6401
+ response,
6402
+ rawPayload: payload
6403
+ });
6404
+ });
6405
+ }
3578
6406
  })
3579
6407
  );
3580
6408
  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
6409
  wrapAction(
3582
6410
  async (threadId, file, opts, command) => {
3583
- const { api } = await requireApi(command);
6411
+ const { api, profile } = await requireApi(command);
3584
6412
  const normalizedFile = file ? normalizeMediaInput(file) : void 0;
3585
6413
  const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
3586
6414
  const urlInputs = files.filter((entry) => isHttpUrl(entry));
@@ -3611,6 +6439,28 @@ msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (rep
3611
6439
  asThreadType(opts.group)
3612
6440
  );
3613
6441
  output(response, false);
6442
+ if (await shouldWriteToDb(profile)) {
6443
+ scheduleDbWrite(profile, command, "msg.image.db.persist_error", async () => {
6444
+ await persistOutgoingMessageBestEffort({
6445
+ profile,
6446
+ api,
6447
+ threadId,
6448
+ group: opts.group,
6449
+ text: opts.message ?? "",
6450
+ msgType: "image",
6451
+ response,
6452
+ rawPayload: {
6453
+ msg: opts.message ?? "",
6454
+ attachments
6455
+ },
6456
+ media: attachments.map((item) => ({
6457
+ mediaKind: "image",
6458
+ mediaPath: isHttpUrl(item) ? void 0 : item,
6459
+ mediaUrl: isHttpUrl(item) ? item : void 0
6460
+ }))
6461
+ });
6462
+ });
6463
+ }
3614
6464
  } finally {
3615
6465
  await downloaded.cleanup();
3616
6466
  }
@@ -3691,6 +6541,30 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
3691
6541
  command
3692
6542
  );
3693
6543
  output(response2, false);
6544
+ if (await shouldWriteToDb(profile)) {
6545
+ scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
6546
+ await persistOutgoingMessageBestEffort({
6547
+ profile,
6548
+ api,
6549
+ threadId,
6550
+ group: opts.group,
6551
+ text: opts.message ?? "",
6552
+ msgType: "video",
6553
+ response: response2,
6554
+ rawPayload: {
6555
+ msg: opts.message ?? "",
6556
+ videoPath: attachments[0],
6557
+ thumbnailPath: thumbnailPath ?? null
6558
+ },
6559
+ media: [
6560
+ {
6561
+ mediaKind: "video",
6562
+ mediaPath: attachments[0]
6563
+ }
6564
+ ]
6565
+ });
6566
+ });
6567
+ }
3694
6568
  return;
3695
6569
  } catch (error) {
3696
6570
  writeDebugLine(
@@ -3729,6 +6603,28 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
3729
6603
  )
3730
6604
  );
3731
6605
  output(response, false);
6606
+ if (await shouldWriteToDb(profile)) {
6607
+ scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
6608
+ await persistOutgoingMessageBestEffort({
6609
+ profile,
6610
+ api,
6611
+ threadId,
6612
+ group: opts.group,
6613
+ text: opts.message ?? "",
6614
+ msgType: "video",
6615
+ response,
6616
+ rawPayload: {
6617
+ msg: opts.message ?? "",
6618
+ attachments
6619
+ },
6620
+ media: attachments.map((item) => ({
6621
+ mediaKind: "video",
6622
+ mediaPath: isHttpUrl(item) ? void 0 : item,
6623
+ mediaUrl: isHttpUrl(item) ? item : void 0
6624
+ }))
6625
+ });
6626
+ });
6627
+ }
3732
6628
  } finally {
3733
6629
  await downloaded.cleanup();
3734
6630
  await downloadedThumbnail.cleanup();
@@ -3739,7 +6635,7 @@ msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (rep
3739
6635
  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
6636
  wrapAction(
3741
6637
  async (threadId, file, opts, command) => {
3742
- const { api } = await requireApi(command);
6638
+ const { api, profile } = await requireApi(command);
3743
6639
  const type = asThreadType(opts.group);
3744
6640
  const normalizedFile = file ? normalizeMediaInput(file) : void 0;
3745
6641
  const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
@@ -3775,6 +6671,24 @@ msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (rep
3775
6671
  );
3776
6672
  }
3777
6673
  output(results, false);
6674
+ if (await shouldWriteToDb(profile)) {
6675
+ scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
6676
+ await persistOutgoingMessageBestEffort({
6677
+ profile,
6678
+ api,
6679
+ threadId,
6680
+ group: opts.group,
6681
+ msgType: "voice",
6682
+ response: results,
6683
+ rawPayload: uploaded,
6684
+ media: uploaded.map((item) => ({
6685
+ mediaKind: "voice",
6686
+ mediaUrl: "fileUrl" in item && typeof item.fileUrl === "string" ? item.fileUrl : void 0,
6687
+ rawJson: JSON.stringify(item)
6688
+ }))
6689
+ });
6690
+ });
6691
+ }
3778
6692
  } finally {
3779
6693
  await downloaded.cleanup();
3780
6694
  }
@@ -3805,9 +6719,23 @@ msg.command("sticker <threadId> <stickerId>").option("-g, --group", "Send to gro
3805
6719
  );
3806
6720
  msg.command("link <threadId> <url>").option("-g, --group", "Send to group").description("Send link").action(
3807
6721
  wrapAction(async (threadId, url, opts, command) => {
3808
- const { api } = await requireApi(command);
6722
+ const { api, profile } = await requireApi(command);
3809
6723
  const response = await api.sendLink({ link: url }, threadId, asThreadType(opts.group));
3810
6724
  output(response, false);
6725
+ if (await shouldWriteToDb(profile)) {
6726
+ scheduleDbWrite(profile, command, "msg.link.db.persist_error", async () => {
6727
+ await persistOutgoingMessageBestEffort({
6728
+ profile,
6729
+ api,
6730
+ threadId,
6731
+ group: opts.group,
6732
+ text: url,
6733
+ msgType: "link",
6734
+ response,
6735
+ rawPayload: { link: url }
6736
+ });
6737
+ });
6738
+ }
3811
6739
  })
3812
6740
  );
3813
6741
  msg.command("card <threadId> <contactId>").option("-g, --group", "Send to group").description("Send contact card").action(
@@ -3947,8 +6875,8 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
3947
6875
  {
3948
6876
  threadId,
3949
6877
  explicitGroupFlag: Boolean(opts.group),
3950
- isGroup: threadResolution.type === ThreadType2.Group,
3951
- threadType: threadResolution.type === ThreadType2.Group ? "group" : "user",
6878
+ isGroup: threadResolution.type === ThreadType3.Group,
6879
+ threadType: threadResolution.type === ThreadType3.Group ? "group" : "user",
3952
6880
  threadTypeReason: threadResolution.reason,
3953
6881
  localFiles,
3954
6882
  urlInputs
@@ -3976,7 +6904,7 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
3976
6904
  "msg.upload.ipc.done",
3977
6905
  {
3978
6906
  threadId,
3979
- threadType: threadResolution.type === ThreadType2.Group ? "group" : "user"
6907
+ threadType: threadResolution.type === ThreadType3.Group ? "group" : "user"
3980
6908
  },
3981
6909
  command
3982
6910
  );
@@ -3987,7 +6915,7 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
3987
6915
  "msg.upload.ipc.fallback",
3988
6916
  {
3989
6917
  threadId,
3990
- threadType: threadResolution.type === ThreadType2.Group ? "group" : "user",
6918
+ threadType: threadResolution.type === ThreadType3.Group ? "group" : "user",
3991
6919
  reason: ipcResult.reason
3992
6920
  },
3993
6921
  command
@@ -4020,40 +6948,52 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
4020
6948
  }
4021
6949
  )
4022
6950
  );
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(
6951
+ 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
6952
  wrapAction(
4025
6953
  async (threadId, opts, command) => {
4026
- const { api } = await requireApi(command);
6954
+ const { api, profile } = await requireApi(command);
4027
6955
  const parsedCount = Number(opts.count);
4028
6956
  const count = Number.isFinite(parsedCount) ? Math.min(Math.max(Math.trunc(parsedCount), 1), 200) : 20;
4029
- const threadType = opts.group ? ThreadType2.Group : ThreadType2.User;
4030
- const messages = opts.group ? await fetchRecentGroupMessagesViaApi(api, threadId, count) : await fetchRecentUserMessagesViaListener(
4031
- api,
6957
+ const threadType = opts.group ? ThreadType3.Group : ThreadType3.User;
6958
+ const source = (opts.source ?? "live").trim().toLowerCase();
6959
+ if (!["live", "db", "auto"].includes(source)) {
6960
+ throw new Error("--source must be one of: live, db, auto");
6961
+ }
6962
+ let rows = source === "db" || source === "auto" ? await listRecentMessages({
6963
+ profile,
4032
6964
  threadId,
6965
+ threadType: opts.group ? "group" : "user",
4033
6966
  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: {
6967
+ }) : [];
6968
+ if (source === "live" || source === "auto" && rows.length === 0) {
6969
+ const messages = opts.group ? await fetchRecentGroupMessagesViaApi(api, threadId, count) : await fetchRecentUserMessagesViaListener(
6970
+ api,
6971
+ threadId,
6972
+ count
6973
+ );
6974
+ rows = messages.map((message) => ({
4045
6975
  msgId: message.data.msgId,
4046
6976
  cliMsgId: message.data.cliMsgId,
4047
6977
  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
- }));
6978
+ threadType: message.type === ThreadType3.Group ? "group" : "user",
6979
+ senderId: message.data.uidFrom,
6980
+ senderName: message.data.dName ?? "",
6981
+ ts: message.data.ts,
6982
+ msgType: message.data.msgType,
6983
+ undo: {
6984
+ msgId: message.data.msgId,
6985
+ cliMsgId: message.data.cliMsgId,
6986
+ threadId: message.threadId || threadId,
6987
+ group: message.type === ThreadType3.Group
6988
+ },
6989
+ content: typeof message.data.content === "string" ? message.data.content : JSON.stringify(message.data.content)
6990
+ }));
6991
+ }
4052
6992
  if (opts.json) {
4053
6993
  output(
4054
6994
  {
4055
6995
  threadId,
4056
- threadType: threadType === ThreadType2.Group ? "group" : "user",
6996
+ threadType: threadType === ThreadType3.Group ? "group" : "user",
4057
6997
  count: rows.length,
4058
6998
  messages: rows
4059
6999
  },
@@ -4073,7 +7013,7 @@ msg.command("pin <threadId>").option("-g, --group", "Pin group conversation").de
4073
7013
  output(
4074
7014
  {
4075
7015
  threadId,
4076
- threadType: type === ThreadType2.Group ? "group" : "user",
7016
+ threadType: type === ThreadType3.Group ? "group" : "user",
4077
7017
  pinned: true,
4078
7018
  response
4079
7019
  },
@@ -4089,7 +7029,7 @@ msg.command("unpin <threadId>").option("-g, --group", "Unpin group conversation"
4089
7029
  output(
4090
7030
  {
4091
7031
  threadId,
4092
- threadType: type === ThreadType2.Group ? "group" : "user",
7032
+ threadType: type === ThreadType3.Group ? "group" : "user",
4093
7033
  pinned: false,
4094
7034
  response
4095
7035
  },
@@ -4678,7 +7618,7 @@ me.command("last-online <userId>").description("Get last online of a user").acti
4678
7618
  output(await api.lastOnline(userId), false);
4679
7619
  })
4680
7620
  );
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(
7621
+ 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
7622
  "--supervised",
4683
7623
  "Supervisor mode (disable internal retry ownership; emit lifecycle events in --raw)"
4684
7624
  ).option(
@@ -4719,6 +7659,8 @@ program.command("listen").description("Listen for real-time incoming messages").
4719
7659
  process.env.OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA
4720
7660
  );
4721
7661
  const sessionId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
7662
+ const selfId = api.getOwnId();
7663
+ const dbWriteEnabled = await shouldWriteToDb(profile, getDbWriteOverride(opts));
4722
7664
  const emitLifecycle = (event, fields) => {
4723
7665
  if (!lifecycleEventsEnabled) return;
4724
7666
  console.log(
@@ -4927,7 +7869,7 @@ ${replyMediaText}` : replyMediaText;
4927
7869
  processedText = processedText.trim() ? `${processedText}
4928
7870
  ${replyContextText}` : replyContextText;
4929
7871
  }
4930
- const chatType = message.type === ThreadType2.Group ? "group" : "user";
7872
+ const chatType = message.type === ThreadType3.Group ? "group" : "user";
4931
7873
  const senderId = getStringCandidate(messageData, ["uidFrom"]) || message.data.uidFrom;
4932
7874
  const senderDisplayNameRaw = getStringCandidate(messageData, [
4933
7875
  "dName",
@@ -4936,7 +7878,7 @@ ${replyContextText}` : replyContextText;
4936
7878
  "displayName"
4937
7879
  ]);
4938
7880
  const senderDisplayName = senderDisplayNameRaw || void 0;
4939
- const senderNameForMetadata = message.type === ThreadType2.Group ? senderDisplayName : void 0;
7881
+ const senderNameForMetadata = message.type === ThreadType3.Group ? senderDisplayName : void 0;
4940
7882
  const toId = getStringCandidate(messageData, ["idTo"]) || void 0;
4941
7883
  const threadName = typeof messageData.threadName === "string" ? messageData.threadName : typeof messageData.tName === "string" ? messageData.tName : void 0;
4942
7884
  const mentions = extractInboundMentions({
@@ -4946,6 +7888,7 @@ ${replyContextText}` : replyContextText;
4946
7888
  });
4947
7889
  const mentionIds = mentions.map((item) => item.uid);
4948
7890
  const timestamp = toEpochSeconds(message.data.ts);
7891
+ const timestampMs = toEpochMs(message.data.ts);
4949
7892
  const payload = {
4950
7893
  threadId: message.threadId,
4951
7894
  targetId: message.threadId,
@@ -4973,7 +7916,7 @@ ${replyContextText}` : replyContextText;
4973
7916
  mentions: mentions.length > 0 ? mentions : void 0,
4974
7917
  mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
4975
7918
  metadata: {
4976
- isGroup: message.type === ThreadType2.Group,
7919
+ isGroup: message.type === ThreadType3.Group,
4977
7920
  chatType,
4978
7921
  threadId: message.threadId,
4979
7922
  targetId: message.threadId,
@@ -5011,6 +7954,52 @@ ${replyContextText}` : replyContextText;
5011
7954
  toId,
5012
7955
  ts: message.data.ts
5013
7956
  };
7957
+ if (dbWriteEnabled) {
7958
+ const mediaForDb = mediaEntries.map((entry) => ({
7959
+ mediaKind: mediaKind ?? void 0,
7960
+ mediaUrl: entry.mediaUrl,
7961
+ mediaPath: entry.mediaPath,
7962
+ mediaType: entry.mediaType,
7963
+ rawJson: JSON.stringify(entry)
7964
+ }));
7965
+ const mentionsForDb = mentions.map((mention) => ({
7966
+ uid: mention.uid,
7967
+ pos: mention.pos,
7968
+ len: mention.len,
7969
+ type: mention.type,
7970
+ rawJson: JSON.stringify(mention)
7971
+ }));
7972
+ scheduleDbWrite(profile, command, "listen.db.persist_error", async () => {
7973
+ await persistMessage(
7974
+ normalizeInboundListenRecord({
7975
+ profile,
7976
+ threadType: chatType,
7977
+ rawThreadId: message.threadId,
7978
+ senderId,
7979
+ senderName: senderDisplayName,
7980
+ toId,
7981
+ selfId,
7982
+ title: threadName,
7983
+ msgId: message.data.msgId,
7984
+ cliMsgId: message.data.cliMsgId,
7985
+ actionId: getStringCandidate(messageData, ["actionId"]),
7986
+ timestampMs,
7987
+ msgType: msgType || void 0,
7988
+ contentText: processedText || rawText || void 0,
7989
+ contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
7990
+ quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
7991
+ quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
7992
+ quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
7993
+ quoteText: quote?.msg,
7994
+ media: mediaForDb,
7995
+ mentions: mentionsForDb,
7996
+ rawMessage: message.data,
7997
+ rawPayload: payload,
7998
+ source: "listen"
7999
+ })
8000
+ );
8001
+ });
8002
+ }
5014
8003
  if (opts.raw) {
5015
8004
  console.log(JSON.stringify(payload));
5016
8005
  } else {
@@ -5191,4 +8180,4 @@ ${replyContextText}` : replyContextText;
5191
8180
  }
5192
8181
  )
5193
8182
  );
5194
- program.parseAsync(process.argv);
8183
+ program.parseAsync(normalizeCommandAliases(process.argv));