openzca 0.1.53 → 0.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -438,6 +438,35 @@ var INSERT_THREAD_MEMBER_SQL = `
438
438
  account_status, member_type, raw_json, snapshot_at_ms, created_at, updated_at
439
439
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
440
440
  `;
441
+ var UPSERT_CONTACT_SQL = `
442
+ INSERT INTO contacts (
443
+ profile, user_id, display_name, zalo_name, avatar, account_status,
444
+ relationship, first_seen_at_ms, last_seen_at_ms, raw_json, created_at, updated_at
445
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
446
+ ON CONFLICT(profile, user_id) DO UPDATE SET
447
+ display_name = COALESCE(excluded.display_name, contacts.display_name),
448
+ zalo_name = COALESCE(excluded.zalo_name, contacts.zalo_name),
449
+ avatar = COALESCE(excluded.avatar, contacts.avatar),
450
+ account_status = COALESCE(excluded.account_status, contacts.account_status),
451
+ relationship = CASE
452
+ WHEN contacts.relationship = 'friend' OR excluded.relationship = 'friend' THEN 'friend'
453
+ WHEN contacts.relationship = 'seen_dm' OR excluded.relationship = 'seen_dm' THEN 'seen_dm'
454
+ WHEN contacts.relationship = 'seen_group' OR excluded.relationship = 'seen_group' THEN 'seen_group'
455
+ ELSE COALESCE(excluded.relationship, contacts.relationship, 'unknown')
456
+ END,
457
+ first_seen_at_ms = CASE
458
+ WHEN contacts.first_seen_at_ms IS NULL THEN excluded.first_seen_at_ms
459
+ WHEN excluded.first_seen_at_ms IS NULL THEN contacts.first_seen_at_ms
460
+ ELSE MIN(contacts.first_seen_at_ms, excluded.first_seen_at_ms)
461
+ END,
462
+ last_seen_at_ms = CASE
463
+ WHEN contacts.last_seen_at_ms IS NULL THEN excluded.last_seen_at_ms
464
+ WHEN excluded.last_seen_at_ms IS NULL THEN contacts.last_seen_at_ms
465
+ ELSE MAX(contacts.last_seen_at_ms, excluded.last_seen_at_ms)
466
+ END,
467
+ raw_json = COALESCE(excluded.raw_json, contacts.raw_json),
468
+ updated_at = excluded.updated_at
469
+ `;
441
470
  var UPSERT_MESSAGE_SQL = `
442
471
  INSERT INTO messages (
443
472
  profile, message_uid, scope_thread_id, raw_thread_id, thread_type,
@@ -500,6 +529,12 @@ function normalizeOptionalText(value) {
500
529
  const trimmed = value.trim();
501
530
  return trimmed || void 0;
502
531
  }
532
+ function normalizeRelationship(value) {
533
+ if (value !== "friend" && value !== "seen_dm" && value !== "seen_group" && value !== "unknown") {
534
+ return void 0;
535
+ }
536
+ return value;
537
+ }
503
538
  function normalizeSearchText(value) {
504
539
  return value.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().trim();
505
540
  }
@@ -755,35 +790,23 @@ async function replaceThreadMembers(profile, scopeThreadId, members) {
755
790
  ];
756
791
  await db.batch(commands, true);
757
792
  }
758
- async function persistFriend(record) {
793
+ async function persistContact(record) {
759
794
  const db = await getDb(record.profile);
760
795
  const now = nowIso2();
761
- await db.run(
762
- `
763
- INSERT INTO friends (
764
- profile, user_id, display_name, zalo_name, avatar,
765
- account_status, raw_json, created_at, updated_at
766
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
767
- ON CONFLICT(profile, user_id) DO UPDATE SET
768
- display_name = COALESCE(excluded.display_name, friends.display_name),
769
- zalo_name = COALESCE(excluded.zalo_name, friends.zalo_name),
770
- avatar = COALESCE(excluded.avatar, friends.avatar),
771
- account_status = COALESCE(excluded.account_status, friends.account_status),
772
- raw_json = COALESCE(excluded.raw_json, friends.raw_json),
773
- updated_at = excluded.updated_at
774
- `,
775
- [
776
- record.profile,
777
- record.userId,
778
- record.displayName ?? null,
779
- record.zaloName ?? null,
780
- record.avatar ?? null,
781
- record.accountStatus ?? null,
782
- record.rawJson ?? null,
783
- now,
784
- now
785
- ]
786
- );
796
+ await db.run(UPSERT_CONTACT_SQL, [
797
+ record.profile,
798
+ record.userId,
799
+ record.displayName ?? null,
800
+ record.zaloName ?? null,
801
+ record.avatar ?? null,
802
+ record.accountStatus ?? null,
803
+ normalizeRelationship(record.relationship) ?? "unknown",
804
+ record.firstSeenAtMs ?? null,
805
+ record.lastSeenAtMs ?? null,
806
+ record.rawJson ?? null,
807
+ now,
808
+ now
809
+ ]);
787
810
  }
788
811
  async function persistSelfProfile(params) {
789
812
  const db = await getDb(params.profile);
@@ -990,8 +1013,8 @@ async function listMessages(params) {
990
1013
  NULLIF(m.sender_name, ''),
991
1014
  NULLIF(tm.display_name, ''),
992
1015
  NULLIF(tm.zalo_name, ''),
993
- NULLIF(f.display_name, ''),
994
- NULLIF(f.zalo_name, '')
1016
+ NULLIF(c.display_name, ''),
1017
+ NULLIF(c.zalo_name, '')
995
1018
  ) AS sender_name,
996
1019
  m.to_id,
997
1020
  m.timestamp_ms,
@@ -1008,9 +1031,9 @@ async function listMessages(params) {
1008
1031
  ON tm.profile = m.profile
1009
1032
  AND tm.scope_thread_id = m.scope_thread_id
1010
1033
  AND tm.user_id = m.sender_id
1011
- LEFT JOIN friends f
1012
- ON f.profile = m.profile
1013
- AND f.user_id = m.sender_id
1034
+ LEFT JOIN contacts c
1035
+ ON c.profile = m.profile
1036
+ AND c.user_id = m.sender_id
1014
1037
  WHERE m.profile = ?
1015
1038
  AND m.scope_thread_id = ?
1016
1039
  AND m.thread_type = ?
@@ -1222,34 +1245,74 @@ async function getThreadInfo(params) {
1222
1245
  raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
1223
1246
  };
1224
1247
  }
1225
- async function listFriends(profile) {
1226
- const db = await getDb(profile);
1248
+ var CONTACT_CHAT_CTE = `
1249
+ WITH ranked_contact_threads AS (
1250
+ SELECT
1251
+ c.profile AS contact_profile,
1252
+ c.user_id,
1253
+ t.scope_thread_id,
1254
+ t.title,
1255
+ COUNT(m.message_uid) AS message_count,
1256
+ MAX(m.timestamp_ms) AS last_message_at_ms,
1257
+ ROW_NUMBER() OVER (
1258
+ PARTITION BY c.profile, c.user_id
1259
+ ORDER BY
1260
+ COALESCE(MAX(m.timestamp_ms), 0) DESC,
1261
+ CASE
1262
+ WHEN t.scope_thread_id = c.user_id THEN 0
1263
+ WHEN t.peer_id = c.user_id THEN 1
1264
+ WHEN t.raw_thread_id = c.user_id THEN 2
1265
+ ELSE 3
1266
+ END,
1267
+ t.updated_at DESC,
1268
+ t.scope_thread_id
1269
+ ) AS thread_rank
1270
+ FROM contacts c
1271
+ LEFT JOIN threads t
1272
+ ON t.profile = c.profile
1273
+ AND t.thread_type = 'user'
1274
+ AND (t.peer_id = c.user_id OR t.scope_thread_id = c.user_id OR t.raw_thread_id = c.user_id)
1275
+ LEFT JOIN messages m
1276
+ ON m.profile = t.profile
1277
+ AND m.scope_thread_id = t.scope_thread_id
1278
+ GROUP BY
1279
+ c.profile,
1280
+ c.user_id,
1281
+ t.scope_thread_id,
1282
+ t.title,
1283
+ t.updated_at,
1284
+ t.peer_id,
1285
+ t.raw_thread_id
1286
+ )
1287
+ `;
1288
+ async function listContacts(params) {
1289
+ const db = await getDb(params.profile);
1227
1290
  const rows = await db.all(
1228
1291
  `
1292
+ ${CONTACT_CHAT_CTE}
1229
1293
  SELECT
1230
- f.user_id,
1231
- f.display_name,
1232
- f.zalo_name,
1233
- f.avatar,
1234
- f.account_status,
1235
- t.scope_thread_id AS chat_id,
1236
- t.title,
1237
- COUNT(m.message_uid) AS message_count,
1238
- MAX(m.timestamp_ms) AS last_message_at_ms
1239
- FROM friends f
1240
- LEFT JOIN threads t
1241
- ON t.profile = f.profile
1242
- AND t.thread_type = 'user'
1243
- AND (t.peer_id = f.user_id OR t.scope_thread_id = f.user_id OR t.raw_thread_id = f.user_id)
1244
- LEFT JOIN messages m
1245
- ON m.profile = t.profile
1246
- AND m.scope_thread_id = t.scope_thread_id
1247
- WHERE f.profile = ?
1248
- GROUP BY
1249
- f.user_id, f.display_name, f.zalo_name, f.avatar, f.account_status, t.scope_thread_id, t.title
1250
- ORDER BY COALESCE(f.display_name, f.zalo_name, f.user_id), f.user_id
1294
+ c.user_id,
1295
+ c.display_name,
1296
+ c.zalo_name,
1297
+ c.avatar,
1298
+ c.account_status,
1299
+ c.relationship,
1300
+ c.first_seen_at_ms,
1301
+ c.last_seen_at_ms,
1302
+ r.scope_thread_id AS chat_id,
1303
+ r.title,
1304
+ COALESCE(r.message_count, 0) AS message_count,
1305
+ r.last_message_at_ms
1306
+ FROM contacts c
1307
+ LEFT JOIN ranked_contact_threads r
1308
+ ON r.contact_profile = c.profile
1309
+ AND r.user_id = c.user_id
1310
+ AND r.thread_rank = 1
1311
+ WHERE c.profile = ?
1312
+ AND (? IS NULL OR c.relationship = ?)
1313
+ ORDER BY COALESCE(c.display_name, c.zalo_name, c.user_id), c.user_id
1251
1314
  `,
1252
- [profile]
1315
+ [params.profile, params.relationship ?? null, params.relationship ?? null]
1253
1316
  );
1254
1317
  return rows.map((row) => ({
1255
1318
  userId: row.user_id,
@@ -1257,43 +1320,53 @@ async function listFriends(profile) {
1257
1320
  zaloName: row.zalo_name ?? void 0,
1258
1321
  avatar: row.avatar ?? void 0,
1259
1322
  accountStatus: row.account_status ?? void 0,
1323
+ relationship: normalizeRelationship(row.relationship) ?? "unknown",
1324
+ firstSeenAtMs: row.first_seen_at_ms ?? void 0,
1325
+ lastSeenAtMs: row.last_seen_at_ms ?? void 0,
1260
1326
  title: row.title ?? void 0,
1261
1327
  chatId: row.chat_id ?? row.user_id,
1262
1328
  messageCount: row.message_count,
1263
1329
  lastMessageAtMs: row.last_message_at_ms ?? void 0
1264
1330
  }));
1265
1331
  }
1266
- async function getFriendInfo(params) {
1332
+ async function listFriends(profile) {
1333
+ return await listContacts({ profile, relationship: "friend" });
1334
+ }
1335
+ async function getContactInfo(params) {
1267
1336
  const db = await getDb(params.profile);
1268
1337
  const row = await db.get(
1269
1338
  `
1339
+ ${CONTACT_CHAT_CTE}
1270
1340
  SELECT
1271
- f.user_id,
1272
- f.display_name,
1273
- f.zalo_name,
1274
- f.avatar,
1275
- f.account_status,
1276
- f.raw_json,
1277
- t.scope_thread_id AS chat_id,
1278
- t.title,
1279
- COUNT(m.message_uid) AS message_count,
1280
- MAX(m.timestamp_ms) AS last_message_at_ms
1281
- FROM friends f
1282
- LEFT JOIN threads t
1283
- ON t.profile = f.profile
1284
- AND t.thread_type = 'user'
1285
- AND (t.peer_id = f.user_id OR t.scope_thread_id = f.user_id OR t.raw_thread_id = f.user_id)
1286
- LEFT JOIN messages m
1287
- ON m.profile = t.profile
1288
- AND m.scope_thread_id = t.scope_thread_id
1289
- WHERE f.profile = ?
1290
- AND f.user_id = ?
1291
- GROUP BY
1292
- f.user_id, f.display_name, f.zalo_name, f.avatar, f.account_status,
1293
- f.raw_json, t.scope_thread_id, t.title
1341
+ c.user_id,
1342
+ c.display_name,
1343
+ c.zalo_name,
1344
+ c.avatar,
1345
+ c.account_status,
1346
+ c.relationship,
1347
+ c.first_seen_at_ms,
1348
+ c.last_seen_at_ms,
1349
+ c.raw_json,
1350
+ r.scope_thread_id AS chat_id,
1351
+ r.title,
1352
+ COALESCE(r.message_count, 0) AS message_count,
1353
+ r.last_message_at_ms
1354
+ FROM contacts c
1355
+ LEFT JOIN ranked_contact_threads r
1356
+ ON r.contact_profile = c.profile
1357
+ AND r.user_id = c.user_id
1358
+ AND r.thread_rank = 1
1359
+ WHERE c.profile = ?
1360
+ AND c.user_id = ?
1361
+ AND (? IS NULL OR c.relationship = ?)
1294
1362
  LIMIT 1
1295
1363
  `,
1296
- [params.profile, params.userId]
1364
+ [
1365
+ params.profile,
1366
+ params.userId,
1367
+ params.relationship ?? null,
1368
+ params.relationship ?? null
1369
+ ]
1297
1370
  );
1298
1371
  if (!row) {
1299
1372
  return null;
@@ -1304,6 +1377,9 @@ async function getFriendInfo(params) {
1304
1377
  zaloName: row.zalo_name ?? void 0,
1305
1378
  avatar: row.avatar ?? void 0,
1306
1379
  accountStatus: row.account_status ?? void 0,
1380
+ relationship: normalizeRelationship(row.relationship) ?? "unknown",
1381
+ firstSeenAtMs: row.first_seen_at_ms ?? void 0,
1382
+ lastSeenAtMs: row.last_seen_at_ms ?? void 0,
1307
1383
  title: row.title ?? void 0,
1308
1384
  chatId: row.chat_id ?? row.user_id,
1309
1385
  messageCount: row.message_count,
@@ -1311,12 +1387,22 @@ async function getFriendInfo(params) {
1311
1387
  raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
1312
1388
  };
1313
1389
  }
1314
- async function findFriends(params) {
1390
+ async function getFriendInfo(params) {
1391
+ return await getContactInfo({
1392
+ profile: params.profile,
1393
+ userId: params.userId,
1394
+ relationship: "friend"
1395
+ });
1396
+ }
1397
+ async function findContacts(params) {
1315
1398
  const query = normalizeSearchText(params.query);
1316
1399
  if (!query) {
1317
1400
  return [];
1318
1401
  }
1319
- const rows = await listFriends(params.profile);
1402
+ const rows = await listContacts({
1403
+ profile: params.profile,
1404
+ relationship: params.relationship
1405
+ });
1320
1406
  return rows.filter((row) => {
1321
1407
  const haystacks = [
1322
1408
  row.userId,
@@ -1327,6 +1413,52 @@ async function findFriends(params) {
1327
1413
  return haystacks.some((value) => matchesSearchPattern(value, query));
1328
1414
  });
1329
1415
  }
1416
+ async function findFriends(params) {
1417
+ return await findContacts({
1418
+ profile: params.profile,
1419
+ query: params.query,
1420
+ relationship: "friend"
1421
+ });
1422
+ }
1423
+ async function reconcileFriendRelationships(params) {
1424
+ const db = await getDb(params.profile);
1425
+ const now = nowIso2();
1426
+ const friendIds = Array.from(
1427
+ new Set(params.currentFriendIds.map((value) => normalizeId(value)).filter(Boolean))
1428
+ );
1429
+ const stalePredicate = friendIds.length > 0 ? `AND contacts.user_id NOT IN (${friendIds.map(() => "?").join(", ")})` : "";
1430
+ const sqlParams = [now, params.profile, ...friendIds];
1431
+ await db.run(
1432
+ `
1433
+ UPDATE contacts
1434
+ SET relationship = CASE
1435
+ WHEN EXISTS (
1436
+ SELECT 1
1437
+ FROM threads t
1438
+ WHERE t.profile = contacts.profile
1439
+ AND t.thread_type = 'user'
1440
+ AND (
1441
+ t.peer_id = contacts.user_id
1442
+ OR t.scope_thread_id = contacts.user_id
1443
+ OR t.raw_thread_id = contacts.user_id
1444
+ )
1445
+ ) THEN 'seen_dm'
1446
+ WHEN EXISTS (
1447
+ SELECT 1
1448
+ FROM thread_members tm
1449
+ WHERE tm.profile = contacts.profile
1450
+ AND tm.user_id = contacts.user_id
1451
+ ) THEN 'seen_group'
1452
+ ELSE 'unknown'
1453
+ END,
1454
+ updated_at = ?
1455
+ WHERE contacts.profile = ?
1456
+ AND contacts.relationship = 'friend'
1457
+ ${stalePredicate}
1458
+ `,
1459
+ sqlParams
1460
+ );
1461
+ }
1330
1462
  async function listChats(profile) {
1331
1463
  return listThreads({ profile });
1332
1464
  }
@@ -4018,6 +4150,20 @@ async function persistOutgoingMessageBestEffort(params) {
4018
4150
  async function persistGroupMembersSnapshot(profile, groupId, api) {
4019
4151
  const rows = await listGroupMemberRows(api, groupId);
4020
4152
  const snapshotAtMs = Date.now();
4153
+ for (const row of rows) {
4154
+ await persistContact({
4155
+ profile,
4156
+ userId: row.userId,
4157
+ displayName: row.displayName,
4158
+ zaloName: row.zaloName,
4159
+ avatar: row.avatar,
4160
+ accountStatus: row.accountStatus,
4161
+ relationship: "seen_group",
4162
+ firstSeenAtMs: snapshotAtMs,
4163
+ lastSeenAtMs: snapshotAtMs,
4164
+ rawJson: row.rawJson
4165
+ });
4166
+ }
4021
4167
  await replaceThreadMembers(
4022
4168
  profile,
4023
4169
  groupId,
@@ -4027,7 +4173,9 @@ async function persistGroupMembersSnapshot(profile, groupId, api) {
4027
4173
  userId: row.userId,
4028
4174
  displayName: row.displayName,
4029
4175
  zaloName: row.zaloName,
4030
- rawJson: JSON.stringify(row),
4176
+ avatar: row.avatar,
4177
+ accountStatus: row.accountStatus,
4178
+ rawJson: row.rawJson ?? JSON.stringify(row),
4031
4179
  snapshotAtMs
4032
4180
  }))
4033
4181
  );
@@ -4043,13 +4191,14 @@ async function persistFriendDirectory(profile, api) {
4043
4191
  const zaloName = typeof record.zaloName === "string" && record.zaloName.trim() ? record.zaloName.trim() : void 0;
4044
4192
  const avatar = typeof record.avatar === "string" && record.avatar.trim() ? record.avatar.trim() : void 0;
4045
4193
  const title = displayName || zaloName || userId;
4046
- await persistFriend({
4194
+ await persistContact({
4047
4195
  profile,
4048
4196
  userId,
4049
4197
  displayName,
4050
4198
  zaloName,
4051
4199
  avatar,
4052
4200
  accountStatus: typeof record.accountStatus === "number" && Number.isFinite(record.accountStatus) ? Math.trunc(record.accountStatus) : void 0,
4201
+ relationship: "friend",
4053
4202
  rawJson: JSON.stringify(friend2)
4054
4203
  });
4055
4204
  await persistThread({
@@ -4063,6 +4212,10 @@ async function persistFriendDirectory(profile, api) {
4063
4212
  });
4064
4213
  nameById.set(userId, title);
4065
4214
  }
4215
+ await reconcileFriendRelationships({
4216
+ profile,
4217
+ currentFriendIds: Array.from(nameById.keys())
4218
+ });
4066
4219
  return nameById;
4067
4220
  }
4068
4221
  function parseSinceDuration(label, value) {
@@ -4202,6 +4355,130 @@ async function prepareDbGroupTarget(params) {
4202
4355
  });
4203
4356
  await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
4204
4357
  }
4358
+ function resolveContactDisplayName(params) {
4359
+ return params.displayName?.trim() || params.zaloName?.trim() || params.fallbackTitle?.trim() || params.userId.trim() || void 0;
4360
+ }
4361
+ async function persistLiveDmContact(params) {
4362
+ if (!params.peerId) {
4363
+ return;
4364
+ }
4365
+ let displayName = params.senderDisplayName?.trim() || void 0;
4366
+ let zaloName = params.senderName?.trim() || void 0;
4367
+ let avatar;
4368
+ let accountStatus;
4369
+ let rawJson = params.rawJson;
4370
+ const existing = await getContactInfo({
4371
+ profile: params.profile,
4372
+ userId: params.peerId
4373
+ });
4374
+ if (!displayName || !existing?.avatar) {
4375
+ try {
4376
+ const response = await params.api.getUserInfo(params.peerId);
4377
+ const profiles = response.changed_profiles ?? {};
4378
+ const profile = profiles[params.peerId] ?? profiles[`${params.peerId}_0`] ?? Object.values(profiles).find((value) => normalizeCachedId(value?.userId ?? value?.uid) === params.peerId) ?? void 0;
4379
+ if (profile) {
4380
+ displayName = displayName || (typeof profile.displayName === "string" && profile.displayName.trim() ? profile.displayName.trim() : void 0) || (typeof profile.display_name === "string" && profile.display_name.trim() ? profile.display_name.trim() : void 0);
4381
+ zaloName = zaloName || (typeof profile.zaloName === "string" && profile.zaloName.trim() ? profile.zaloName.trim() : void 0) || (typeof profile.zalo_name === "string" && profile.zalo_name.trim() ? profile.zalo_name.trim() : void 0);
4382
+ avatar = typeof profile.avatar === "string" && profile.avatar.trim() ? profile.avatar.trim() : void 0;
4383
+ accountStatus = typeof profile.accountStatus === "number" && Number.isFinite(profile.accountStatus) ? Math.trunc(profile.accountStatus) : void 0;
4384
+ rawJson = JSON.stringify(profile);
4385
+ }
4386
+ } catch {
4387
+ }
4388
+ }
4389
+ const title = resolveContactDisplayName({
4390
+ userId: params.peerId,
4391
+ displayName,
4392
+ zaloName,
4393
+ fallbackTitle: typeof existing?.title === "string" ? existing.title : void 0
4394
+ });
4395
+ await persistContact({
4396
+ profile: params.profile,
4397
+ userId: params.peerId,
4398
+ displayName,
4399
+ zaloName,
4400
+ avatar,
4401
+ accountStatus,
4402
+ relationship: "seen_dm",
4403
+ firstSeenAtMs: params.timestampMs,
4404
+ lastSeenAtMs: params.timestampMs,
4405
+ rawJson
4406
+ });
4407
+ await persistThread({
4408
+ profile: params.profile,
4409
+ scopeThreadId: params.peerId,
4410
+ rawThreadId: params.peerId,
4411
+ threadType: "user",
4412
+ peerId: params.peerId,
4413
+ title,
4414
+ rawJson
4415
+ });
4416
+ }
4417
+ function extractGroupTitle(record) {
4418
+ if (!record) {
4419
+ return void 0;
4420
+ }
4421
+ return typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
4422
+ }
4423
+ async function findGroupDirectoryEntry(api, groupId) {
4424
+ const groups = await buildGroupsDetailed(api);
4425
+ return groups.find((item) => {
4426
+ const record = item;
4427
+ return normalizeCachedId(record.groupId ?? record.grid ?? record.threadId ?? record.id) === groupId;
4428
+ });
4429
+ }
4430
+ async function hydrateUnknownLiveGroup(params) {
4431
+ const existing = await getThreadInfo({
4432
+ profile: params.profile,
4433
+ threadId: params.groupId,
4434
+ threadType: "group"
4435
+ });
4436
+ if (existing && (existing.title || typeof existing.memberCount === "number" && existing.memberCount > 0)) {
4437
+ return;
4438
+ }
4439
+ let group2;
4440
+ let title = params.fallbackTitle?.trim() || void 0;
4441
+ try {
4442
+ const info = await params.api.getGroupInfo(params.groupId);
4443
+ group2 = info.gridInfoMap[params.groupId];
4444
+ title = extractGroupTitle(group2) ?? title;
4445
+ } catch {
4446
+ }
4447
+ if (!group2 || !title) {
4448
+ try {
4449
+ const directoryGroup = await findGroupDirectoryEntry(params.api, params.groupId);
4450
+ if (directoryGroup) {
4451
+ group2 = group2 ?? directoryGroup;
4452
+ title = extractGroupTitle(directoryGroup) ?? title;
4453
+ }
4454
+ } catch {
4455
+ }
4456
+ }
4457
+ if (group2 || title) {
4458
+ await persistThread({
4459
+ profile: params.profile,
4460
+ scopeThreadId: params.groupId,
4461
+ rawThreadId: params.groupId,
4462
+ threadType: "group",
4463
+ title,
4464
+ rawJson: group2 ? JSON.stringify(group2) : void 0
4465
+ });
4466
+ try {
4467
+ await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
4468
+ } catch {
4469
+ }
4470
+ return;
4471
+ }
4472
+ if (params.fallbackTitle?.trim()) {
4473
+ await persistThread({
4474
+ profile: params.profile,
4475
+ scopeThreadId: params.groupId,
4476
+ rawThreadId: params.groupId,
4477
+ threadType: "group",
4478
+ title: params.fallbackTitle.trim()
4479
+ });
4480
+ }
4481
+ }
4205
4482
  async function syncDbGroupHistoryFull(params) {
4206
4483
  if (params.targetGroupIds.size === 0) {
4207
4484
  return;
@@ -4408,128 +4685,132 @@ async function syncDbChatsBestEffort(params) {
4408
4685
  }
4409
4686
  async function runDbSync(params) {
4410
4687
  const { profile, api } = await requireApi(params.command);
4411
- const dbPath = await resolveDbPath(profile);
4412
- params.progress?.(`starting sync for profile ${profile}`);
4413
- const summary = createDbSyncSummary(
4414
- profile,
4415
- dbPath,
4416
- params.mode === "all" || params.mode === "chats" || params.mode === "chat" ? params.count : void 0
4417
- );
4418
- const selfId = api.getOwnId();
4419
- const selfInfo = normalizeMeInfoOutput(await api.fetchAccountInfo());
4420
- await persistSelfProfile({
4421
- profile,
4422
- userId: selfId,
4423
- displayName: typeof selfInfo.displayName === "string" && selfInfo.displayName.trim() ? selfInfo.displayName.trim() : void 0,
4424
- infoJson: JSON.stringify(selfInfo)
4425
- });
4426
- const { pinnedIds, hiddenIds } = await collectConversationIds(api);
4427
- let friendNames = /* @__PURE__ */ new Map();
4428
- if (params.mode === "all" || params.mode === "friends" || params.mode === "chats") {
4429
- friendNames = await syncDbFriendDirectory({
4688
+ try {
4689
+ const dbPath = await resolveDbPath(profile);
4690
+ params.progress?.(`starting sync for profile ${profile}`);
4691
+ const summary = createDbSyncSummary(
4430
4692
  profile,
4431
- api,
4432
- summary,
4433
- progress: params.progress
4693
+ dbPath,
4694
+ params.mode === "all" || params.mode === "chats" || params.mode === "chat" ? params.count : void 0
4695
+ );
4696
+ const selfId = api.getOwnId();
4697
+ const selfInfo = normalizeMeInfoOutput(await api.fetchAccountInfo());
4698
+ await persistSelfProfile({
4699
+ profile,
4700
+ userId: selfId,
4701
+ displayName: typeof selfInfo.displayName === "string" && selfInfo.displayName.trim() ? selfInfo.displayName.trim() : void 0,
4702
+ infoJson: JSON.stringify(selfInfo)
4434
4703
  });
4435
- }
4436
- if (params.mode === "all" || params.mode === "groups") {
4437
- const groups = await buildGroupsDetailed(api);
4438
- const targetGroupIds = /* @__PURE__ */ new Set();
4439
- const titleById = /* @__PURE__ */ new Map();
4440
- for (const group2 of groups) {
4441
- const record = group2;
4442
- const groupId = normalizeCachedId(record.groupId);
4443
- if (!groupId) continue;
4444
- const title = typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
4445
- targetGroupIds.add(groupId);
4446
- titleById.set(groupId, title);
4704
+ const { pinnedIds, hiddenIds } = await collectConversationIds(api);
4705
+ let friendNames = /* @__PURE__ */ new Map();
4706
+ if (params.mode === "all" || params.mode === "friends" || params.mode === "chats") {
4707
+ friendNames = await syncDbFriendDirectory({
4708
+ profile,
4709
+ api,
4710
+ summary,
4711
+ progress: params.progress
4712
+ });
4713
+ }
4714
+ if (params.mode === "all" || params.mode === "groups") {
4715
+ const groups = await buildGroupsDetailed(api);
4716
+ const targetGroupIds = /* @__PURE__ */ new Set();
4717
+ const titleById = /* @__PURE__ */ new Map();
4718
+ for (const group2 of groups) {
4719
+ const record = group2;
4720
+ const groupId = normalizeCachedId(record.groupId);
4721
+ if (!groupId) continue;
4722
+ const title = typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
4723
+ targetGroupIds.add(groupId);
4724
+ titleById.set(groupId, title);
4725
+ await prepareDbGroupTarget({
4726
+ profile,
4727
+ api,
4728
+ groupId,
4729
+ title,
4730
+ rawJson: JSON.stringify(group2),
4731
+ pinnedIds,
4732
+ hiddenIds
4733
+ });
4734
+ }
4735
+ await syncDbGroupHistoryFull({
4736
+ profile,
4737
+ api,
4738
+ selfId,
4739
+ targetGroupIds,
4740
+ titleById,
4741
+ summary,
4742
+ progress: params.progress
4743
+ });
4744
+ }
4745
+ if (params.mode === "group") {
4746
+ if (!params.groupId) {
4747
+ throw new Error("Missing group id for db sync group.");
4748
+ }
4749
+ const groupInfo = await api.getGroupInfo(params.groupId);
4750
+ const group2 = groupInfo.gridInfoMap[params.groupId];
4751
+ const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : void 0;
4447
4752
  await prepareDbGroupTarget({
4448
4753
  profile,
4449
4754
  api,
4450
- groupId,
4755
+ groupId: params.groupId,
4451
4756
  title,
4452
- rawJson: JSON.stringify(group2),
4757
+ rawJson: group2 ? JSON.stringify(group2) : void 0,
4453
4758
  pinnedIds,
4454
4759
  hiddenIds
4455
4760
  });
4761
+ await syncDbGroupHistoryFull({
4762
+ profile,
4763
+ api,
4764
+ selfId,
4765
+ targetGroupIds: /* @__PURE__ */ new Set([params.groupId]),
4766
+ titleById: /* @__PURE__ */ new Map([[params.groupId, title]]),
4767
+ summary,
4768
+ progress: params.progress
4769
+ });
4456
4770
  }
4457
- await syncDbGroupHistoryFull({
4458
- profile,
4459
- api,
4460
- selfId,
4461
- targetGroupIds,
4462
- titleById,
4463
- summary,
4464
- progress: params.progress
4465
- });
4466
- }
4467
- if (params.mode === "group") {
4468
- if (!params.groupId) {
4469
- throw new Error("Missing group id for db sync group.");
4470
- }
4471
- const groupInfo = await api.getGroupInfo(params.groupId);
4472
- const group2 = groupInfo.gridInfoMap[params.groupId];
4473
- const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : void 0;
4474
- await prepareDbGroupTarget({
4475
- profile,
4476
- api,
4477
- groupId: params.groupId,
4478
- title,
4479
- rawJson: group2 ? JSON.stringify(group2) : void 0,
4480
- pinnedIds,
4481
- hiddenIds
4482
- });
4483
- await syncDbGroupHistoryFull({
4484
- profile,
4485
- api,
4486
- selfId,
4487
- targetGroupIds: /* @__PURE__ */ new Set([params.groupId]),
4488
- titleById: /* @__PURE__ */ new Map([[params.groupId, title]]),
4489
- summary,
4490
- progress: params.progress
4491
- });
4492
- }
4493
- if (params.mode === "chat") {
4494
- if (!params.threadId) {
4495
- throw new Error("Missing chat id for db sync chat.");
4496
- }
4497
- if (friendNames.size === 0) {
4498
- friendNames = await persistFriendDirectory(profile, api);
4771
+ if (params.mode === "chat") {
4772
+ if (!params.threadId) {
4773
+ throw new Error("Missing chat id for db sync chat.");
4774
+ }
4775
+ if (friendNames.size === 0) {
4776
+ friendNames = await persistFriendDirectory(profile, api);
4777
+ }
4778
+ await syncDbChatThread({
4779
+ profile,
4780
+ api,
4781
+ selfId,
4782
+ threadId: params.threadId,
4783
+ count: params.count,
4784
+ title: friendNames.get(params.threadId),
4785
+ pinnedIds,
4786
+ hiddenIds,
4787
+ summary,
4788
+ progress: params.progress
4789
+ });
4499
4790
  }
4500
- await syncDbChatThread({
4501
- profile,
4502
- api,
4503
- selfId,
4504
- threadId: params.threadId,
4505
- count: params.count,
4506
- title: friendNames.get(params.threadId),
4507
- pinnedIds,
4508
- hiddenIds,
4509
- summary,
4510
- progress: params.progress
4511
- });
4512
- }
4513
- if (params.mode === "all" || params.mode === "chats") {
4514
- if (friendNames.size === 0) {
4515
- friendNames = await persistFriendDirectory(profile, api);
4791
+ if (params.mode === "all" || params.mode === "chats") {
4792
+ if (friendNames.size === 0) {
4793
+ friendNames = await persistFriendDirectory(profile, api);
4794
+ }
4795
+ await syncDbChatsBestEffort({
4796
+ profile,
4797
+ api,
4798
+ selfId,
4799
+ count: params.count,
4800
+ titleById: friendNames,
4801
+ pinnedIds,
4802
+ hiddenIds,
4803
+ summary,
4804
+ progress: params.progress
4805
+ });
4516
4806
  }
4517
- await syncDbChatsBestEffort({
4518
- profile,
4519
- api,
4520
- selfId,
4521
- count: params.count,
4522
- titleById: friendNames,
4523
- pinnedIds,
4524
- hiddenIds,
4525
- summary,
4526
- progress: params.progress
4527
- });
4807
+ params.progress?.(
4808
+ `done: groups=${summary.groupsSynced}, groupMessages=${summary.groupMessagesImported}, friends=${summary.friendsSynced}, chats=${summary.chatsSynced}, dmMessages=${summary.dmMessagesImported}`
4809
+ );
4810
+ return summary;
4811
+ } finally {
4812
+ await closeDb(profile);
4528
4813
  }
4529
- params.progress?.(
4530
- `done: groups=${summary.groupsSynced}, groupMessages=${summary.groupMessagesImported}, friends=${summary.friendsSynced}, chats=${summary.chatsSynced}, dmMessages=${summary.dmMessagesImported}`
4531
- );
4532
- return summary;
4533
4814
  }
4534
4815
  async function buildGroupsDetailed(api) {
4535
4816
  const groups = await api.getAllGroups();
@@ -4580,17 +4861,26 @@ async function listGroupMemberRows(api, groupId) {
4580
4861
  if (!profile) continue;
4581
4862
  const normalizedKey = normalizeGroupMemberId(key);
4582
4863
  if (normalizedKey && !profileMap.has(normalizedKey)) {
4583
- profileMap.set(normalizedKey, profile);
4864
+ profileMap.set(normalizedKey, {
4865
+ ...profile,
4866
+ rawJson: JSON.stringify(profile)
4867
+ });
4584
4868
  }
4585
4869
  const profileId = normalizeGroupMemberId(profile.id);
4586
4870
  if (profileId && !profileMap.has(profileId)) {
4587
- profileMap.set(profileId, profile);
4871
+ profileMap.set(profileId, {
4872
+ ...profile,
4873
+ rawJson: JSON.stringify(profile)
4874
+ });
4588
4875
  }
4589
4876
  }
4590
4877
  return ids.map((id) => ({
4591
4878
  userId: id,
4592
4879
  displayName: profileMap.get(id)?.displayName ?? currentMemberMap.get(id)?.displayName ?? "",
4593
- zaloName: profileMap.get(id)?.zaloName ?? currentMemberMap.get(id)?.zaloName ?? ""
4880
+ zaloName: profileMap.get(id)?.zaloName ?? currentMemberMap.get(id)?.zaloName ?? "",
4881
+ avatar: profileMap.get(id)?.avatar,
4882
+ accountStatus: profileMap.get(id)?.accountStatus,
4883
+ rawJson: profileMap.get(id)?.rawJson
4594
4884
  }));
4595
4885
  }
4596
4886
  async function listGroupMentionMembers(api, threadId) {
@@ -6555,52 +6845,69 @@ dbGroup.command("messages <groupId>").option("--since <duration>", "Rolling wind
6555
6845
  );
6556
6846
  })
6557
6847
  );
6558
- var dbFriend = dbCmd.command("friend").description("Query stored friend directory data");
6559
- dbFriend.command("list").option("-j, --json", "JSON output").description("List friends stored in the local DB").action(
6560
- wrapAction(async (opts, command) => {
6561
- const profile = await currentProfile(command);
6562
- output(await listFriends(profile), Boolean(opts.json));
6563
- })
6564
- );
6565
- dbFriend.command("find <query>").option("-j, --json", "JSON output").description("Find stored friends by ID or name").action(
6566
- wrapAction(async (query, opts, command) => {
6567
- const profile = await currentProfile(command);
6568
- output(await findFriends({ profile, query }), Boolean(opts.json));
6569
- })
6570
- );
6571
- dbFriend.command("info <userId>").option("-j, --json", "JSON output").description("Show stored info for a friend").action(
6572
- wrapAction(async (userId, opts, command) => {
6573
- const profile = await currentProfile(command);
6574
- const row = await getFriendInfo({ profile, userId });
6575
- if (!row) {
6576
- throw new Error(`Friend not found in DB: ${userId}`);
6577
- }
6578
- output(row, Boolean(opts.json));
6579
- })
6580
- );
6581
- 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(
6582
- wrapAction(async (userId, opts, command) => {
6583
- const profile = await currentProfile(command);
6584
- const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6585
- const rows = await listMessages({
6586
- profile,
6587
- threadId: userId,
6588
- threadType: "user",
6589
- sinceMs,
6590
- untilMs,
6591
- limit,
6592
- newestFirst
6593
- });
6594
- output(
6595
- {
6596
- userId,
6597
- count: rows.length,
6598
- messages: rows
6599
- },
6600
- Boolean(opts.json)
6601
- );
6602
- })
6603
- );
6848
+ function registerDbContactQueryCommand(params) {
6849
+ params.command.command("list").option("-j, --json", "JSON output").description(`List ${params.label} stored in the local DB`).action(
6850
+ wrapAction(async (opts, command) => {
6851
+ const profile = await currentProfile(command);
6852
+ const rows = params.relationship === "friend" ? await listFriends(profile) : await listContacts({ profile });
6853
+ output(rows, Boolean(opts.json));
6854
+ })
6855
+ );
6856
+ params.command.command("find <query>").option("-j, --json", "JSON output").description(`Find stored ${params.label} by ID or name`).action(
6857
+ wrapAction(async (query, opts, command) => {
6858
+ const profile = await currentProfile(command);
6859
+ const rows = params.relationship === "friend" ? await findFriends({ profile, query }) : await findContacts({ profile, query });
6860
+ output(rows, Boolean(opts.json));
6861
+ })
6862
+ );
6863
+ params.command.command("info <userId>").option("-j, --json", "JSON output").description(`Show stored info for a ${params.label.slice(0, -1)}`).action(
6864
+ wrapAction(async (userId, opts, command) => {
6865
+ const profile = await currentProfile(command);
6866
+ const row = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
6867
+ if (!row) {
6868
+ throw new Error(`${params.label.slice(0, -1).replace(/^./, (value) => value.toUpperCase())} not found in DB: ${userId}`);
6869
+ }
6870
+ output(row, Boolean(opts.json));
6871
+ })
6872
+ );
6873
+ params.command.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 ${params.label.slice(0, -1)}`).action(
6874
+ wrapAction(async (userId, opts, command) => {
6875
+ const profile = await currentProfile(command);
6876
+ const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6877
+ const contact = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
6878
+ const threadId = contact && typeof contact.chatId === "string" && contact.chatId.trim() ? contact.chatId : userId;
6879
+ const rows = await listMessages({
6880
+ profile,
6881
+ threadId,
6882
+ threadType: "user",
6883
+ sinceMs,
6884
+ untilMs,
6885
+ limit,
6886
+ newestFirst
6887
+ });
6888
+ output(
6889
+ {
6890
+ userId,
6891
+ chatId: threadId,
6892
+ count: rows.length,
6893
+ messages: rows
6894
+ },
6895
+ Boolean(opts.json)
6896
+ );
6897
+ })
6898
+ );
6899
+ }
6900
+ var dbContact = dbCmd.command("contact").description("Query stored contact data");
6901
+ registerDbContactQueryCommand({
6902
+ command: dbContact,
6903
+ label: "contacts"
6904
+ });
6905
+ var dbFriend = dbCmd.command("friend").description("Query stored confirmed friend contacts");
6906
+ registerDbContactQueryCommand({
6907
+ command: dbFriend,
6908
+ label: "friends",
6909
+ relationship: "friend"
6910
+ });
6604
6911
  var dbChat = dbCmd.command("chat").description("Query stored conversation data");
6605
6912
  dbChat.command("list").option("-j, --json", "JSON output").description("List chats stored in the local DB").action(
6606
6913
  wrapAction(async (opts, command) => {
@@ -8409,34 +8716,54 @@ ${replyContextText}` : replyContextText;
8409
8716
  rawJson: JSON.stringify(mention)
8410
8717
  }));
8411
8718
  scheduleDbWrite(profile, command, "listen.db.persist_error", async () => {
8412
- await persistMessage(
8413
- normalizeInboundListenRecord({
8719
+ const normalizedRecord = normalizeInboundListenRecord({
8720
+ profile,
8721
+ threadType: chatType,
8722
+ rawThreadId: message.threadId,
8723
+ senderId,
8724
+ senderName: senderDisplayName,
8725
+ toId,
8726
+ selfId,
8727
+ title: chatType === "group" ? threadName : senderDisplayName,
8728
+ msgId: message.data.msgId,
8729
+ cliMsgId: message.data.cliMsgId,
8730
+ actionId: getStringCandidate(messageData, ["actionId"]),
8731
+ timestampMs,
8732
+ msgType: msgType || void 0,
8733
+ contentText: processedText || rawText || void 0,
8734
+ contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
8735
+ quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
8736
+ quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
8737
+ quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
8738
+ quoteText: quote?.msg,
8739
+ media: mediaForDb,
8740
+ mentions: mentionsForDb,
8741
+ rawMessage: message.data,
8742
+ rawPayload: payload,
8743
+ source: "listen"
8744
+ });
8745
+ if (chatType === "group") {
8746
+ await hydrateUnknownLiveGroup({
8747
+ profile,
8748
+ api,
8749
+ groupId: normalizedRecord.scopeThreadId,
8750
+ fallbackTitle: threadName
8751
+ });
8752
+ } else {
8753
+ await persistLiveDmContact({
8414
8754
  profile,
8415
- threadType: chatType,
8416
- rawThreadId: message.threadId,
8417
- senderId,
8755
+ api,
8756
+ peerId: normalizedRecord.scopeThreadId,
8757
+ senderDisplayName,
8418
8758
  senderName: senderDisplayName,
8419
- toId,
8420
- selfId,
8421
- title: threadName,
8422
- msgId: message.data.msgId,
8423
- cliMsgId: message.data.cliMsgId,
8424
- actionId: getStringCandidate(messageData, ["actionId"]),
8425
8759
  timestampMs,
8426
- msgType: msgType || void 0,
8427
- contentText: processedText || rawText || void 0,
8428
- contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
8429
- quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
8430
- quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
8431
- quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
8432
- quoteText: quote?.msg,
8433
- media: mediaForDb,
8434
- mentions: mentionsForDb,
8435
- rawMessage: message.data,
8436
- rawPayload: payload,
8437
- source: "listen"
8438
- })
8439
- );
8760
+ rawJson: JSON.stringify({
8761
+ userId: normalizedRecord.scopeThreadId,
8762
+ displayName: senderDisplayName
8763
+ })
8764
+ });
8765
+ }
8766
+ await persistMessage(normalizedRecord);
8440
8767
  });
8441
8768
  }
8442
8769
  if (opts.raw) {