openzca 0.1.53 → 0.1.54

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,99 @@ 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
+ async function hydrateUnknownLiveGroup(params) {
4418
+ const existing = await getThreadInfo({
4419
+ profile: params.profile,
4420
+ threadId: params.groupId,
4421
+ threadType: "group"
4422
+ });
4423
+ if (existing && (existing.title || typeof existing.memberCount === "number" && existing.memberCount > 0)) {
4424
+ return;
4425
+ }
4426
+ try {
4427
+ const info = await params.api.getGroupInfo(params.groupId);
4428
+ const group2 = info.gridInfoMap[params.groupId];
4429
+ const title = typeof group2?.name === "string" && group2.name.trim() ? group2.name.trim() : typeof group2?.groupName === "string" && group2.groupName.trim() ? group2.groupName.trim() : params.fallbackTitle?.trim() || void 0;
4430
+ await persistThread({
4431
+ profile: params.profile,
4432
+ scopeThreadId: params.groupId,
4433
+ rawThreadId: params.groupId,
4434
+ threadType: "group",
4435
+ title,
4436
+ rawJson: group2 ? JSON.stringify(group2) : void 0
4437
+ });
4438
+ await persistGroupMembersSnapshot(params.profile, params.groupId, params.api);
4439
+ } catch {
4440
+ if (params.fallbackTitle?.trim()) {
4441
+ await persistThread({
4442
+ profile: params.profile,
4443
+ scopeThreadId: params.groupId,
4444
+ rawThreadId: params.groupId,
4445
+ threadType: "group",
4446
+ title: params.fallbackTitle.trim()
4447
+ });
4448
+ }
4449
+ }
4450
+ }
4205
4451
  async function syncDbGroupHistoryFull(params) {
4206
4452
  if (params.targetGroupIds.size === 0) {
4207
4453
  return;
@@ -4580,17 +4826,26 @@ async function listGroupMemberRows(api, groupId) {
4580
4826
  if (!profile) continue;
4581
4827
  const normalizedKey = normalizeGroupMemberId(key);
4582
4828
  if (normalizedKey && !profileMap.has(normalizedKey)) {
4583
- profileMap.set(normalizedKey, profile);
4829
+ profileMap.set(normalizedKey, {
4830
+ ...profile,
4831
+ rawJson: JSON.stringify(profile)
4832
+ });
4584
4833
  }
4585
4834
  const profileId = normalizeGroupMemberId(profile.id);
4586
4835
  if (profileId && !profileMap.has(profileId)) {
4587
- profileMap.set(profileId, profile);
4836
+ profileMap.set(profileId, {
4837
+ ...profile,
4838
+ rawJson: JSON.stringify(profile)
4839
+ });
4588
4840
  }
4589
4841
  }
4590
4842
  return ids.map((id) => ({
4591
4843
  userId: id,
4592
4844
  displayName: profileMap.get(id)?.displayName ?? currentMemberMap.get(id)?.displayName ?? "",
4593
- zaloName: profileMap.get(id)?.zaloName ?? currentMemberMap.get(id)?.zaloName ?? ""
4845
+ zaloName: profileMap.get(id)?.zaloName ?? currentMemberMap.get(id)?.zaloName ?? "",
4846
+ avatar: profileMap.get(id)?.avatar,
4847
+ accountStatus: profileMap.get(id)?.accountStatus,
4848
+ rawJson: profileMap.get(id)?.rawJson
4594
4849
  }));
4595
4850
  }
4596
4851
  async function listGroupMentionMembers(api, threadId) {
@@ -6555,52 +6810,69 @@ dbGroup.command("messages <groupId>").option("--since <duration>", "Rolling wind
6555
6810
  );
6556
6811
  })
6557
6812
  );
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
- );
6813
+ function registerDbContactQueryCommand(params) {
6814
+ params.command.command("list").option("-j, --json", "JSON output").description(`List ${params.label} stored in the local DB`).action(
6815
+ wrapAction(async (opts, command) => {
6816
+ const profile = await currentProfile(command);
6817
+ const rows = params.relationship === "friend" ? await listFriends(profile) : await listContacts({ profile });
6818
+ output(rows, Boolean(opts.json));
6819
+ })
6820
+ );
6821
+ params.command.command("find <query>").option("-j, --json", "JSON output").description(`Find stored ${params.label} by ID or name`).action(
6822
+ wrapAction(async (query, opts, command) => {
6823
+ const profile = await currentProfile(command);
6824
+ const rows = params.relationship === "friend" ? await findFriends({ profile, query }) : await findContacts({ profile, query });
6825
+ output(rows, Boolean(opts.json));
6826
+ })
6827
+ );
6828
+ params.command.command("info <userId>").option("-j, --json", "JSON output").description(`Show stored info for a ${params.label.slice(0, -1)}`).action(
6829
+ wrapAction(async (userId, opts, command) => {
6830
+ const profile = await currentProfile(command);
6831
+ const row = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
6832
+ if (!row) {
6833
+ throw new Error(`${params.label.slice(0, -1).replace(/^./, (value) => value.toUpperCase())} not found in DB: ${userId}`);
6834
+ }
6835
+ output(row, Boolean(opts.json));
6836
+ })
6837
+ );
6838
+ 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(
6839
+ wrapAction(async (userId, opts, command) => {
6840
+ const profile = await currentProfile(command);
6841
+ const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
6842
+ const contact = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
6843
+ const threadId = contact && typeof contact.chatId === "string" && contact.chatId.trim() ? contact.chatId : userId;
6844
+ const rows = await listMessages({
6845
+ profile,
6846
+ threadId,
6847
+ threadType: "user",
6848
+ sinceMs,
6849
+ untilMs,
6850
+ limit,
6851
+ newestFirst
6852
+ });
6853
+ output(
6854
+ {
6855
+ userId,
6856
+ chatId: threadId,
6857
+ count: rows.length,
6858
+ messages: rows
6859
+ },
6860
+ Boolean(opts.json)
6861
+ );
6862
+ })
6863
+ );
6864
+ }
6865
+ var dbContact = dbCmd.command("contact").description("Query stored contact data");
6866
+ registerDbContactQueryCommand({
6867
+ command: dbContact,
6868
+ label: "contacts"
6869
+ });
6870
+ var dbFriend = dbCmd.command("friend").description("Query stored confirmed friend contacts");
6871
+ registerDbContactQueryCommand({
6872
+ command: dbFriend,
6873
+ label: "friends",
6874
+ relationship: "friend"
6875
+ });
6604
6876
  var dbChat = dbCmd.command("chat").description("Query stored conversation data");
6605
6877
  dbChat.command("list").option("-j, --json", "JSON output").description("List chats stored in the local DB").action(
6606
6878
  wrapAction(async (opts, command) => {
@@ -8409,34 +8681,54 @@ ${replyContextText}` : replyContextText;
8409
8681
  rawJson: JSON.stringify(mention)
8410
8682
  }));
8411
8683
  scheduleDbWrite(profile, command, "listen.db.persist_error", async () => {
8412
- await persistMessage(
8413
- normalizeInboundListenRecord({
8684
+ const normalizedRecord = normalizeInboundListenRecord({
8685
+ profile,
8686
+ threadType: chatType,
8687
+ rawThreadId: message.threadId,
8688
+ senderId,
8689
+ senderName: senderDisplayName,
8690
+ toId,
8691
+ selfId,
8692
+ title: chatType === "group" ? threadName : senderDisplayName,
8693
+ msgId: message.data.msgId,
8694
+ cliMsgId: message.data.cliMsgId,
8695
+ actionId: getStringCandidate(messageData, ["actionId"]),
8696
+ timestampMs,
8697
+ msgType: msgType || void 0,
8698
+ contentText: processedText || rawText || void 0,
8699
+ contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
8700
+ quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
8701
+ quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
8702
+ quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
8703
+ quoteText: quote?.msg,
8704
+ media: mediaForDb,
8705
+ mentions: mentionsForDb,
8706
+ rawMessage: message.data,
8707
+ rawPayload: payload,
8708
+ source: "listen"
8709
+ });
8710
+ if (chatType === "group") {
8711
+ await hydrateUnknownLiveGroup({
8712
+ profile,
8713
+ api,
8714
+ groupId: normalizedRecord.scopeThreadId,
8715
+ fallbackTitle: threadName
8716
+ });
8717
+ } else {
8718
+ await persistLiveDmContact({
8414
8719
  profile,
8415
- threadType: chatType,
8416
- rawThreadId: message.threadId,
8417
- senderId,
8720
+ api,
8721
+ peerId: normalizedRecord.scopeThreadId,
8722
+ senderDisplayName,
8418
8723
  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
8724
  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
- );
8725
+ rawJson: JSON.stringify({
8726
+ userId: normalizedRecord.scopeThreadId,
8727
+ displayName: senderDisplayName
8728
+ })
8729
+ });
8730
+ }
8731
+ await persistMessage(normalizedRecord);
8440
8732
  });
8441
8733
  }
8442
8734
  if (opts.raw) {
@@ -0,0 +1,52 @@
1
+ // src/lib/db-migrations.ts
2
+ import fs from "fs/promises";
3
+ var MIGRATIONS = [
4
+ { version: "001", file: "001-initial-schema.sql" },
5
+ { version: "002", file: "002-add-contacts.sql" },
6
+ { version: "003", file: "003-backfill-contacts.sql" }
7
+ ];
8
+ var CREATE_MIGRATIONS_TABLE_SQL = `
9
+ CREATE TABLE IF NOT EXISTS schema_migrations (
10
+ version TEXT NOT NULL PRIMARY KEY,
11
+ applied_at TEXT NOT NULL
12
+ )
13
+ `;
14
+ function nowIso() {
15
+ return (/* @__PURE__ */ new Date()).toISOString();
16
+ }
17
+ async function readMigrationSql(file) {
18
+ return await fs.readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8");
19
+ }
20
+ function listAppliedVersions(db) {
21
+ const rows = db.prepare("SELECT version FROM schema_migrations").all();
22
+ return new Set(
23
+ rows.map((row) => typeof row.version === "string" ? row.version : "").filter(Boolean)
24
+ );
25
+ }
26
+ async function runMigrations(db) {
27
+ db.exec(CREATE_MIGRATIONS_TABLE_SQL);
28
+ const applied = listAppliedVersions(db);
29
+ for (const migration of MIGRATIONS) {
30
+ if (applied.has(migration.version)) {
31
+ continue;
32
+ }
33
+ const sql = await readMigrationSql(migration.file);
34
+ db.exec("BEGIN");
35
+ try {
36
+ db.exec(sql);
37
+ db.prepare(
38
+ "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)"
39
+ ).run(migration.version, nowIso());
40
+ db.exec("COMMIT");
41
+ } catch (error) {
42
+ try {
43
+ db.exec("ROLLBACK");
44
+ } catch {
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ }
50
+ export {
51
+ runMigrations
52
+ };
package/dist/db-worker.js CHANGED
@@ -1,150 +1,5 @@
1
1
  // src/lib/db-worker.ts
2
2
  import { parentPort, workerData } from "worker_threads";
3
- var INIT_SQL = `
4
- PRAGMA journal_mode = WAL;
5
- PRAGMA synchronous = FULL;
6
- PRAGMA busy_timeout = 5000;
7
- PRAGMA foreign_keys = ON;
8
-
9
- CREATE TABLE IF NOT EXISTS threads (
10
- profile TEXT NOT NULL,
11
- scope_thread_id TEXT NOT NULL,
12
- raw_thread_id TEXT NOT NULL,
13
- thread_type TEXT NOT NULL,
14
- peer_id TEXT,
15
- title TEXT,
16
- is_pinned INTEGER NOT NULL DEFAULT 0,
17
- is_hidden INTEGER NOT NULL DEFAULT 0,
18
- is_archived INTEGER NOT NULL DEFAULT 0,
19
- raw_json TEXT,
20
- created_at TEXT NOT NULL,
21
- updated_at TEXT NOT NULL,
22
- PRIMARY KEY (profile, scope_thread_id)
23
- );
24
-
25
- CREATE TABLE IF NOT EXISTS thread_members (
26
- profile TEXT NOT NULL,
27
- scope_thread_id TEXT NOT NULL,
28
- user_id TEXT NOT NULL,
29
- display_name TEXT,
30
- zalo_name TEXT,
31
- avatar TEXT,
32
- account_status INTEGER,
33
- member_type INTEGER,
34
- raw_json TEXT,
35
- snapshot_at_ms INTEGER NOT NULL,
36
- created_at TEXT NOT NULL,
37
- updated_at TEXT NOT NULL,
38
- PRIMARY KEY (profile, scope_thread_id, user_id)
39
- );
40
-
41
- CREATE TABLE IF NOT EXISTS friends (
42
- profile TEXT NOT NULL,
43
- user_id TEXT NOT NULL,
44
- display_name TEXT,
45
- zalo_name TEXT,
46
- avatar TEXT,
47
- account_status INTEGER,
48
- raw_json TEXT,
49
- created_at TEXT NOT NULL,
50
- updated_at TEXT NOT NULL,
51
- PRIMARY KEY (profile, user_id)
52
- );
53
-
54
- CREATE TABLE IF NOT EXISTS self_profiles (
55
- profile TEXT NOT NULL,
56
- user_id TEXT NOT NULL,
57
- display_name TEXT,
58
- info_json TEXT,
59
- created_at TEXT NOT NULL,
60
- updated_at TEXT NOT NULL,
61
- PRIMARY KEY (profile)
62
- );
63
-
64
- CREATE TABLE IF NOT EXISTS messages (
65
- profile TEXT NOT NULL,
66
- message_uid TEXT NOT NULL,
67
- scope_thread_id TEXT NOT NULL,
68
- raw_thread_id TEXT NOT NULL,
69
- thread_type TEXT NOT NULL,
70
- msg_id TEXT,
71
- cli_msg_id TEXT,
72
- action_id TEXT,
73
- sender_id TEXT,
74
- sender_name TEXT,
75
- to_id TEXT,
76
- timestamp_ms INTEGER NOT NULL,
77
- msg_type TEXT,
78
- content_text TEXT,
79
- content_json TEXT,
80
- quote_msg_id TEXT,
81
- quote_cli_msg_id TEXT,
82
- quote_owner_id TEXT,
83
- quote_text TEXT,
84
- source TEXT NOT NULL,
85
- raw_message_json TEXT,
86
- raw_payload_json TEXT,
87
- created_at TEXT NOT NULL,
88
- updated_at TEXT NOT NULL,
89
- PRIMARY KEY (profile, message_uid)
90
- );
91
-
92
- CREATE TABLE IF NOT EXISTS message_media (
93
- profile TEXT NOT NULL,
94
- message_uid TEXT NOT NULL,
95
- item_index INTEGER NOT NULL,
96
- media_kind TEXT,
97
- media_url TEXT,
98
- media_path TEXT,
99
- media_type TEXT,
100
- raw_json TEXT,
101
- created_at TEXT NOT NULL,
102
- updated_at TEXT NOT NULL,
103
- PRIMARY KEY (profile, message_uid, item_index)
104
- );
105
-
106
- CREATE TABLE IF NOT EXISTS message_mentions (
107
- profile TEXT NOT NULL,
108
- message_uid TEXT NOT NULL,
109
- item_index INTEGER NOT NULL,
110
- target_user_id TEXT NOT NULL,
111
- pos INTEGER,
112
- len INTEGER,
113
- mention_type INTEGER,
114
- raw_json TEXT,
115
- created_at TEXT NOT NULL,
116
- updated_at TEXT NOT NULL,
117
- PRIMARY KEY (profile, message_uid, item_index)
118
- );
119
-
120
- CREATE TABLE IF NOT EXISTS sync_state (
121
- profile TEXT NOT NULL,
122
- scope TEXT NOT NULL,
123
- scope_thread_id TEXT NOT NULL,
124
- thread_type TEXT NOT NULL,
125
- status TEXT NOT NULL,
126
- completeness TEXT,
127
- cursor TEXT,
128
- last_sync_at TEXT,
129
- error TEXT,
130
- created_at TEXT NOT NULL,
131
- updated_at TEXT NOT NULL,
132
- PRIMARY KEY (profile, scope)
133
- );
134
-
135
- CREATE INDEX IF NOT EXISTS idx_messages_thread_time
136
- ON messages (profile, scope_thread_id, timestamp_ms DESC);
137
- CREATE INDEX IF NOT EXISTS idx_messages_msg_id
138
- ON messages (profile, msg_id);
139
- CREATE INDEX IF NOT EXISTS idx_messages_cli_msg_id
140
- ON messages (profile, cli_msg_id);
141
- CREATE INDEX IF NOT EXISTS idx_threads_type
142
- ON threads (profile, thread_type, updated_at DESC);
143
- CREATE INDEX IF NOT EXISTS idx_members_thread
144
- ON thread_members (profile, scope_thread_id);
145
- CREATE INDEX IF NOT EXISTS idx_friends_name
146
- ON friends (profile, display_name, zalo_name, user_id);
147
- `;
148
3
  if (!parentPort) {
149
4
  throw new Error("DB worker requires parentPort");
150
5
  }
@@ -180,6 +35,12 @@ async function loadSqliteModule() {
180
35
  process.emitWarning = originalEmitWarning;
181
36
  }
182
37
  }
38
+ async function loadMigrationModule() {
39
+ const currentUrl = new URL(import.meta.url);
40
+ const specifier = currentUrl.pathname.endsWith("/src/lib/db-worker.ts") ? new URL("./db-migrations.ts", currentUrl).href : new URL("./db-migrations.js", currentUrl).href;
41
+ const importDynamic = new Function("specifier", "return import(specifier);");
42
+ return await importDynamic(specifier);
43
+ }
183
44
  function setDefensiveMode(db) {
184
45
  const maybeDb = db;
185
46
  if (typeof maybeDb.enableDefensive === "function") {
@@ -197,9 +58,16 @@ function allStatement(db, statement) {
197
58
  }
198
59
  async function main() {
199
60
  const { DatabaseSync } = await loadSqliteModule();
61
+ const { runMigrations } = await loadMigrationModule();
200
62
  const { filename } = workerData;
201
63
  const db = new DatabaseSync(filename);
202
- db.exec(INIT_SQL);
64
+ db.exec(`
65
+ PRAGMA journal_mode = WAL;
66
+ PRAGMA synchronous = FULL;
67
+ PRAGMA busy_timeout = 5000;
68
+ PRAGMA foreign_keys = ON;
69
+ `);
70
+ await runMigrations(db);
203
71
  setDefensiveMode(db);
204
72
  port.postMessage({ type: "ready" });
205
73
  port.on("message", (message) => {
@@ -0,0 +1,138 @@
1
+ CREATE TABLE IF NOT EXISTS threads (
2
+ profile TEXT NOT NULL,
3
+ scope_thread_id TEXT NOT NULL,
4
+ raw_thread_id TEXT NOT NULL,
5
+ thread_type TEXT NOT NULL,
6
+ peer_id TEXT,
7
+ title TEXT,
8
+ is_pinned INTEGER NOT NULL DEFAULT 0,
9
+ is_hidden INTEGER NOT NULL DEFAULT 0,
10
+ is_archived INTEGER NOT NULL DEFAULT 0,
11
+ raw_json TEXT,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL,
14
+ PRIMARY KEY (profile, scope_thread_id)
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS thread_members (
18
+ profile TEXT NOT NULL,
19
+ scope_thread_id TEXT NOT NULL,
20
+ user_id TEXT NOT NULL,
21
+ display_name TEXT,
22
+ zalo_name TEXT,
23
+ avatar TEXT,
24
+ account_status INTEGER,
25
+ member_type INTEGER,
26
+ raw_json TEXT,
27
+ snapshot_at_ms INTEGER NOT NULL,
28
+ created_at TEXT NOT NULL,
29
+ updated_at TEXT NOT NULL,
30
+ PRIMARY KEY (profile, scope_thread_id, user_id)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS friends (
34
+ profile TEXT NOT NULL,
35
+ user_id TEXT NOT NULL,
36
+ display_name TEXT,
37
+ zalo_name TEXT,
38
+ avatar TEXT,
39
+ account_status INTEGER,
40
+ raw_json TEXT,
41
+ created_at TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL,
43
+ PRIMARY KEY (profile, user_id)
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS self_profiles (
47
+ profile TEXT NOT NULL,
48
+ user_id TEXT NOT NULL,
49
+ display_name TEXT,
50
+ info_json TEXT,
51
+ created_at TEXT NOT NULL,
52
+ updated_at TEXT NOT NULL,
53
+ PRIMARY KEY (profile)
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS messages (
57
+ profile TEXT NOT NULL,
58
+ message_uid TEXT NOT NULL,
59
+ scope_thread_id TEXT NOT NULL,
60
+ raw_thread_id TEXT NOT NULL,
61
+ thread_type TEXT NOT NULL,
62
+ msg_id TEXT,
63
+ cli_msg_id TEXT,
64
+ action_id TEXT,
65
+ sender_id TEXT,
66
+ sender_name TEXT,
67
+ to_id TEXT,
68
+ timestamp_ms INTEGER NOT NULL,
69
+ msg_type TEXT,
70
+ content_text TEXT,
71
+ content_json TEXT,
72
+ quote_msg_id TEXT,
73
+ quote_cli_msg_id TEXT,
74
+ quote_owner_id TEXT,
75
+ quote_text TEXT,
76
+ source TEXT NOT NULL,
77
+ raw_message_json TEXT,
78
+ raw_payload_json TEXT,
79
+ created_at TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL,
81
+ PRIMARY KEY (profile, message_uid)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS message_media (
85
+ profile TEXT NOT NULL,
86
+ message_uid TEXT NOT NULL,
87
+ item_index INTEGER NOT NULL,
88
+ media_kind TEXT,
89
+ media_url TEXT,
90
+ media_path TEXT,
91
+ media_type TEXT,
92
+ raw_json TEXT,
93
+ created_at TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL,
95
+ PRIMARY KEY (profile, message_uid, item_index)
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS message_mentions (
99
+ profile TEXT NOT NULL,
100
+ message_uid TEXT NOT NULL,
101
+ item_index INTEGER NOT NULL,
102
+ target_user_id TEXT NOT NULL,
103
+ pos INTEGER,
104
+ len INTEGER,
105
+ mention_type INTEGER,
106
+ raw_json TEXT,
107
+ created_at TEXT NOT NULL,
108
+ updated_at TEXT NOT NULL,
109
+ PRIMARY KEY (profile, message_uid, item_index)
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS sync_state (
113
+ profile TEXT NOT NULL,
114
+ scope TEXT NOT NULL,
115
+ scope_thread_id TEXT NOT NULL,
116
+ thread_type TEXT NOT NULL,
117
+ status TEXT NOT NULL,
118
+ completeness TEXT,
119
+ cursor TEXT,
120
+ last_sync_at TEXT,
121
+ error TEXT,
122
+ created_at TEXT NOT NULL,
123
+ updated_at TEXT NOT NULL,
124
+ PRIMARY KEY (profile, scope)
125
+ );
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_time
128
+ ON messages (profile, scope_thread_id, timestamp_ms DESC);
129
+ CREATE INDEX IF NOT EXISTS idx_messages_msg_id
130
+ ON messages (profile, msg_id);
131
+ CREATE INDEX IF NOT EXISTS idx_messages_cli_msg_id
132
+ ON messages (profile, cli_msg_id);
133
+ CREATE INDEX IF NOT EXISTS idx_threads_type
134
+ ON threads (profile, thread_type, updated_at DESC);
135
+ CREATE INDEX IF NOT EXISTS idx_members_thread
136
+ ON thread_members (profile, scope_thread_id);
137
+ CREATE INDEX IF NOT EXISTS idx_friends_name
138
+ ON friends (profile, display_name, zalo_name, user_id);
@@ -0,0 +1,18 @@
1
+ CREATE TABLE IF NOT EXISTS contacts (
2
+ profile TEXT NOT NULL,
3
+ user_id TEXT NOT NULL,
4
+ display_name TEXT,
5
+ zalo_name TEXT,
6
+ avatar TEXT,
7
+ account_status INTEGER,
8
+ relationship TEXT NOT NULL DEFAULT 'unknown',
9
+ first_seen_at_ms INTEGER,
10
+ last_seen_at_ms INTEGER,
11
+ raw_json TEXT,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL,
14
+ PRIMARY KEY (profile, user_id)
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_contacts_name
18
+ ON contacts (profile, display_name, zalo_name, user_id);
@@ -0,0 +1,158 @@
1
+ INSERT INTO contacts (
2
+ profile,
3
+ user_id,
4
+ display_name,
5
+ zalo_name,
6
+ avatar,
7
+ account_status,
8
+ relationship,
9
+ first_seen_at_ms,
10
+ last_seen_at_ms,
11
+ raw_json,
12
+ created_at,
13
+ updated_at
14
+ )
15
+ SELECT
16
+ profile,
17
+ user_id,
18
+ display_name,
19
+ zalo_name,
20
+ avatar,
21
+ account_status,
22
+ 'friend',
23
+ NULL,
24
+ NULL,
25
+ raw_json,
26
+ created_at,
27
+ updated_at
28
+ FROM friends
29
+ WHERE 1 = 1
30
+ ON CONFLICT(profile, user_id) DO UPDATE SET
31
+ display_name = COALESCE(excluded.display_name, contacts.display_name),
32
+ zalo_name = COALESCE(excluded.zalo_name, contacts.zalo_name),
33
+ avatar = COALESCE(excluded.avatar, contacts.avatar),
34
+ account_status = COALESCE(excluded.account_status, contacts.account_status),
35
+ relationship = CASE
36
+ WHEN contacts.relationship = 'friend' OR excluded.relationship = 'friend' THEN 'friend'
37
+ WHEN contacts.relationship = 'seen_dm' OR excluded.relationship = 'seen_dm' THEN 'seen_dm'
38
+ WHEN contacts.relationship = 'seen_group' OR excluded.relationship = 'seen_group' THEN 'seen_group'
39
+ ELSE COALESCE(excluded.relationship, contacts.relationship, 'unknown')
40
+ END,
41
+ raw_json = COALESCE(excluded.raw_json, contacts.raw_json),
42
+ updated_at = excluded.updated_at;
43
+
44
+ INSERT INTO contacts (
45
+ profile,
46
+ user_id,
47
+ display_name,
48
+ zalo_name,
49
+ avatar,
50
+ account_status,
51
+ relationship,
52
+ first_seen_at_ms,
53
+ last_seen_at_ms,
54
+ raw_json,
55
+ created_at,
56
+ updated_at
57
+ )
58
+ SELECT
59
+ profile,
60
+ user_id,
61
+ NULLIF(MAX(display_name), ''),
62
+ NULLIF(MAX(zalo_name), ''),
63
+ NULLIF(MAX(avatar), ''),
64
+ MAX(account_status),
65
+ 'seen_group',
66
+ MIN(snapshot_at_ms),
67
+ MAX(snapshot_at_ms),
68
+ NULLIF(MAX(raw_json), ''),
69
+ MIN(created_at),
70
+ MAX(updated_at)
71
+ FROM thread_members
72
+ WHERE 1 = 1
73
+ GROUP BY profile, user_id
74
+ ON CONFLICT(profile, user_id) DO UPDATE SET
75
+ display_name = COALESCE(excluded.display_name, contacts.display_name),
76
+ zalo_name = COALESCE(excluded.zalo_name, contacts.zalo_name),
77
+ avatar = COALESCE(excluded.avatar, contacts.avatar),
78
+ account_status = COALESCE(excluded.account_status, contacts.account_status),
79
+ relationship = CASE
80
+ WHEN contacts.relationship = 'friend' OR excluded.relationship = 'friend' THEN 'friend'
81
+ WHEN contacts.relationship = 'seen_dm' OR excluded.relationship = 'seen_dm' THEN 'seen_dm'
82
+ WHEN contacts.relationship = 'seen_group' OR excluded.relationship = 'seen_group' THEN 'seen_group'
83
+ ELSE COALESCE(excluded.relationship, contacts.relationship, 'unknown')
84
+ END,
85
+ first_seen_at_ms = CASE
86
+ WHEN contacts.first_seen_at_ms IS NULL THEN excluded.first_seen_at_ms
87
+ WHEN excluded.first_seen_at_ms IS NULL THEN contacts.first_seen_at_ms
88
+ ELSE MIN(contacts.first_seen_at_ms, excluded.first_seen_at_ms)
89
+ END,
90
+ last_seen_at_ms = CASE
91
+ WHEN contacts.last_seen_at_ms IS NULL THEN excluded.last_seen_at_ms
92
+ WHEN excluded.last_seen_at_ms IS NULL THEN contacts.last_seen_at_ms
93
+ ELSE MAX(contacts.last_seen_at_ms, excluded.last_seen_at_ms)
94
+ END,
95
+ raw_json = COALESCE(excluded.raw_json, contacts.raw_json),
96
+ updated_at = excluded.updated_at;
97
+
98
+ INSERT INTO contacts (
99
+ profile,
100
+ user_id,
101
+ display_name,
102
+ zalo_name,
103
+ avatar,
104
+ account_status,
105
+ relationship,
106
+ first_seen_at_ms,
107
+ last_seen_at_ms,
108
+ raw_json,
109
+ created_at,
110
+ updated_at
111
+ )
112
+ SELECT
113
+ t.profile,
114
+ COALESCE(NULLIF(t.peer_id, ''), t.scope_thread_id) AS user_id,
115
+ COALESCE(
116
+ NULLIF(MAX(CASE
117
+ WHEN m.sender_id = COALESCE(NULLIF(t.peer_id, ''), t.scope_thread_id) THEN m.sender_name
118
+ ELSE NULL
119
+ END), ''),
120
+ NULLIF(MAX(t.title), '')
121
+ ) AS display_name,
122
+ NULL,
123
+ NULL,
124
+ NULL,
125
+ 'seen_dm',
126
+ MIN(m.timestamp_ms),
127
+ MAX(m.timestamp_ms),
128
+ t.raw_json,
129
+ MIN(t.created_at),
130
+ MAX(COALESCE(m.updated_at, t.updated_at))
131
+ FROM threads t
132
+ LEFT JOIN messages m
133
+ ON m.profile = t.profile
134
+ AND m.scope_thread_id = t.scope_thread_id
135
+ AND m.thread_type = 'user'
136
+ WHERE t.thread_type = 'user'
137
+ AND COALESCE(NULLIF(t.peer_id, ''), t.scope_thread_id) <> ''
138
+ GROUP BY t.profile, COALESCE(NULLIF(t.peer_id, ''), t.scope_thread_id), t.raw_json
139
+ ON CONFLICT(profile, user_id) DO UPDATE SET
140
+ display_name = COALESCE(excluded.display_name, contacts.display_name),
141
+ relationship = CASE
142
+ WHEN contacts.relationship = 'friend' OR excluded.relationship = 'friend' THEN 'friend'
143
+ WHEN contacts.relationship = 'seen_dm' OR excluded.relationship = 'seen_dm' THEN 'seen_dm'
144
+ WHEN contacts.relationship = 'seen_group' OR excluded.relationship = 'seen_group' THEN 'seen_group'
145
+ ELSE COALESCE(excluded.relationship, contacts.relationship, 'unknown')
146
+ END,
147
+ first_seen_at_ms = CASE
148
+ WHEN contacts.first_seen_at_ms IS NULL THEN excluded.first_seen_at_ms
149
+ WHEN excluded.first_seen_at_ms IS NULL THEN contacts.first_seen_at_ms
150
+ ELSE MIN(contacts.first_seen_at_ms, excluded.first_seen_at_ms)
151
+ END,
152
+ last_seen_at_ms = CASE
153
+ WHEN contacts.last_seen_at_ms IS NULL THEN excluded.last_seen_at_ms
154
+ WHEN excluded.last_seen_at_ms IS NULL THEN contacts.last_seen_at_ms
155
+ ELSE MAX(contacts.last_seen_at_ms, excluded.last_seen_at_ms)
156
+ END,
157
+ raw_json = COALESCE(excluded.raw_json, contacts.raw_json),
158
+ updated_at = excluded.updated_at;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "npm run build:cli && npm run build:worker",
18
18
  "build:cli": "tsup src/cli.ts --format esm --target node22 --out-dir dist --clean",
19
- "build:worker": "tsup src/lib/db-worker.ts --format esm --target node22 --out-dir dist",
19
+ "build:worker": "tsup src/lib/db-worker.ts src/lib/db-migrations.ts --format esm --target node22 --out-dir dist && node scripts/copy-db-migrations.mjs",
20
20
  "dev": "tsx src/cli.ts",
21
21
  "test": "tsx --test tests/*.test.ts",
22
22
  "typecheck": "tsc -p tsconfig.json",