weflow-cli 1.6.3 → 1.7.0

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 (43) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/OPERATIONS.md +36 -1
  3. package/README.en.md +1 -1
  4. package/README.md +1 -1
  5. package/bin/weflow-cli.ts +312 -0
  6. package/dist/bin/weflow-cli.js +352 -0
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +26 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +21 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/chatService.d.ts +13 -0
  13. package/dist/src/services/chatService.d.ts.map +1 -1
  14. package/dist/src/services/chatService.js +20 -0
  15. package/dist/src/services/chatService.js.map +1 -1
  16. package/dist/src/services/messageQuery.d.ts +35 -0
  17. package/dist/src/services/messageQuery.d.ts.map +1 -1
  18. package/dist/src/services/messageQuery.js +61 -0
  19. package/dist/src/services/messageQuery.js.map +1 -1
  20. package/dist/src/services/syncService.d.ts +121 -0
  21. package/dist/src/services/syncService.d.ts.map +1 -0
  22. package/dist/src/services/syncService.js +244 -0
  23. package/dist/src/services/syncService.js.map +1 -0
  24. package/dist/src/services/syncState.d.ts +113 -0
  25. package/dist/src/services/syncState.d.ts.map +1 -0
  26. package/dist/src/services/syncState.js +214 -0
  27. package/dist/src/services/syncState.js.map +1 -0
  28. package/docs/DECISIONS.md +60 -20
  29. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  30. package/docs/HEALTH-CHECK.md +6 -5
  31. package/docs/MCP.md +1 -1
  32. package/docs/NPM-PUBLISH.md +12 -4
  33. package/docs/PROJECT_STATE.md +8 -6
  34. package/docs/SETUP.md +1 -1
  35. package/docs/SYNC_CONTRACT.md +152 -0
  36. package/package.json +3 -3
  37. package/scripts/export_chat_html.py +242 -26
  38. package/scripts/nt_decrypt.py +123 -15
  39. package/src/core/ntCore.ts +42 -0
  40. package/src/services/chatService.ts +26 -0
  41. package/src/services/messageQuery.ts +97 -0
  42. package/src/services/syncService.ts +335 -0
  43. package/src/services/syncState.ts +275 -0
@@ -510,14 +510,24 @@ def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
510
510
  return fallback_key, fallback_salt
511
511
 
512
512
 
513
- def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
514
- """Open the configured database plus every sibling shard.
513
+ def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
514
+ """Open every shard and report what happened to each one.
515
515
 
516
- Returns the connections that actually opened. A shard that fails is
517
- skipped rather than fatal: a partially readable transcript beats a
518
- command that refuses to run at all.
516
+ Returns (pairs, failures):
517
+ pairs [(shard_path, conn)] for the shards that opened
518
+ failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
519
+
520
+ A shard that fails is still skipped rather than fatal - a partially
521
+ readable transcript beats a command that refuses to run at all - but the
522
+ failure stops being invisible. `shardsFailed` is the difference between
523
+ "read everything" and "read what happened to be reachable", which is the
524
+ whole point of the coverage report.
525
+
526
+ Only the basename is reported: the caller stores this in a state file, and
527
+ absolute paths must not end up there.
519
528
  """
520
- opened = []
529
+ pairs = []
530
+ failures = []
521
531
  for shard in discover_message_shards(db_path):
522
532
  derived_key, derived_salt = derive_database_key(
523
533
  shard, key_hex, salt_hex, passphrase)
@@ -526,16 +536,60 @@ def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
526
536
  candidates = [(derived_key, derived_salt)]
527
537
  if (derived_key, derived_salt) != (key_hex, salt_hex):
528
538
  candidates.append((key_hex, salt_hex))
539
+ failure_reason = 'OPEN_FAILED'
529
540
  for candidate_key, candidate_salt in candidates:
541
+ conn = None
530
542
  try:
531
543
  conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
532
544
  # PRAGMA key alone never fails; only a read surfaces a bad key.
533
545
  conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
534
546
  except Exception:
547
+ # Reached the database but could not decrypt it: the key is
548
+ # wrong, not the file. Close before retrying, or the handle
549
+ # leaks and on Windows the file stays locked.
550
+ if conn is not None:
551
+ try:
552
+ conn.close()
553
+ except Exception:
554
+ pass
555
+ failure_reason = 'KEY_REJECTED'
535
556
  continue
536
- opened.append(conn)
557
+ pairs.append((shard, conn))
537
558
  break
538
- return opened
559
+ else:
560
+ failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
561
+ return pairs, failures
562
+
563
+
564
+ def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
565
+ """Open the configured database plus every sibling shard.
566
+
567
+ Returns the connections that actually opened. A shard that fails is
568
+ skipped rather than fatal: a partially readable transcript beats a
569
+ command that refuses to run at all.
570
+
571
+ Behaviour-preserving delegate: callers that do not ask for the failure
572
+ detail get exactly what they got before.
573
+ """
574
+ pairs, _failures = connect_message_shards_detailed(
575
+ db_path, key_hex, salt_hex, passphrase)
576
+ return [conn for _path, conn in pairs]
577
+
578
+
579
+ def count_talker_rows(conn, msg_table):
580
+ """Rows this conversation has in one shard, or None if it has no table there.
581
+
582
+ Distinguishes "this shard holds nothing for the conversation" from "this
583
+ shard could not be read" - without it both look like zero.
584
+ """
585
+ try:
586
+ cur = conn.execute(
587
+ "SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
588
+ if cur.fetchone()[0] == 0:
589
+ return None
590
+ return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
591
+ except Exception:
592
+ return None
539
593
 
540
594
 
541
595
  def msg_tables(conn):
@@ -808,12 +862,17 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
808
862
  }
809
863
 
810
864
 
811
- def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None):
865
+ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
866
+ shard_names=None, shard_report=None):
812
867
  """Get messages for a specific talker, merged across every shard.
813
868
 
814
869
  Args:
815
870
  name_map: optional {wxid: display_name} dict for resolving sender names
816
871
  own_wxid: account owner wxid for self-message detection
872
+ shard_names: optional basename per connection, parallel to `conns`
873
+ shard_report: optional list to append one outcome dict per shard to.
874
+ Purely additive: the returned messages are identical with or
875
+ without it, which is asserted by test/nt_decrypt_shards_test.py.
817
876
  """
818
877
  if name_map is None:
819
878
  name_map = {}
@@ -821,19 +880,36 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
821
880
  msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
822
881
  is_group = '@chatroom' in talker
823
882
 
883
+ def record(index, opened, has_table, rows_for_talker, reason):
884
+ if shard_report is None:
885
+ return
886
+ name = shard_names[index] if shard_names and index < len(shard_names) else ''
887
+ shard_report.append({
888
+ 'name': name,
889
+ 'opened': opened,
890
+ 'hasTalkerTable': has_table,
891
+ 'rowsForTalker': rows_for_talker,
892
+ 'reason': reason,
893
+ })
894
+
824
895
  # Each shard only needs to yield its newest window: once every shard's rows
825
896
  # are merged and re-sorted, nothing older than that can reach this page.
826
897
  window = 0 if limit <= 0 else limit + offset
827
898
  collected = []
828
899
  found = False
829
900
 
830
- for conn in conns:
901
+ for index, conn in enumerate(conns):
831
902
  c = conn.cursor()
832
903
  try:
833
904
  c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
834
905
  if c.fetchone()[0] == 0:
906
+ # The shard is readable and simply holds nothing for this
907
+ # conversation - distinct from not being readable at all.
908
+ record(index, True, False, None, None)
835
909
  continue
836
910
  found = True
911
+ rows_for_talker = c.execute(
912
+ 'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
837
913
 
838
914
  sql = f'''
839
915
  SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
@@ -852,7 +928,11 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
852
928
  c.execute("SELECT rowid, user_name FROM Name2Id")
853
929
  sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
854
930
  except Exception:
931
+ # This shard had the conversation's table but could not be read.
932
+ # Previously this was indistinguishable from "no rows here".
933
+ record(index, True, None, None, 'READ_FAILED')
855
934
  continue
935
+ record(index, True, True, rows_for_talker, None)
856
936
 
857
937
  for row in rows:
858
938
  collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
@@ -1138,6 +1218,9 @@ def main():
1138
1218
  msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1139
1219
  msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1140
1220
  msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1221
+ # Opt-in: without it the JSON is byte-identical to before this flag existed.
1222
+ msg_parser.add_argument('--report-shards', action='store_true',
1223
+ help='Add a per-shard read report to the result (additive)')
1141
1224
 
1142
1225
  # contacts command
1143
1226
  contacts_parser = sub.add_parser('contacts', help='List contacts')
@@ -1282,12 +1365,37 @@ def main():
1282
1365
  conn.close()
1283
1366
 
1284
1367
  elif args.command == 'messages':
1285
- conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1286
- if not conns:
1287
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1288
- return
1368
+ passphrase = getattr(args, 'passphrase', '') or ''
1289
1369
  own_wxid = getattr(args, 'own_wxid', None)
1290
- result = get_messages(conns, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
1370
+
1371
+ if getattr(args, 'report_shards', False):
1372
+ pairs, failures = connect_message_shards_detailed(
1373
+ args.db, args.key, args.salt, passphrase)
1374
+ if not pairs:
1375
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1376
+ return
1377
+ conns = [conn for _path, conn in pairs]
1378
+ names = [os.path.basename(path) for path, _conn in pairs]
1379
+ shard_items = []
1380
+ result = get_messages(conns, args.talker, args.limit, args.offset,
1381
+ contact_name_map, own_wxid,
1382
+ shard_names=names, shard_report=shard_items)
1383
+ # Failure entries carry no opened connection, so they are not in
1384
+ # `conns` and could not be recorded by get_messages itself.
1385
+ result['shards'] = {
1386
+ 'scanned': len(pairs) + len(failures),
1387
+ 'opened': len(pairs),
1388
+ 'failed': len(failures),
1389
+ 'items': failures + shard_items,
1390
+ }
1391
+ else:
1392
+ conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
1393
+ if not conns:
1394
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1395
+ return
1396
+ result = get_messages(conns, args.talker, args.limit, args.offset,
1397
+ contact_name_map, own_wxid)
1398
+
1291
1399
  print(json.dumps(result, ensure_ascii=True))
1292
1400
  for conn in conns:
1293
1401
  conn.close()
@@ -30,8 +30,28 @@ export interface NtSessionsResult extends NtResult {
30
30
  sessions?: ChatSession[]
31
31
  }
32
32
 
33
+ /** One shard's outcome, as reported by `nt_decrypt.py --report-shards`. */
34
+ export interface ShardOutcome {
35
+ /** basename only - the child never reports a full path */
36
+ name: string
37
+ opened: boolean
38
+ /** null when the shard could not be read at all */
39
+ hasTalkerTable: boolean | null
40
+ rowsForTalker: number | null
41
+ /** null | KEY_REJECTED | OPEN_FAILED | READ_FAILED */
42
+ reason: string | null
43
+ }
44
+
45
+ export interface ShardReport {
46
+ scanned: number
47
+ opened: number
48
+ failed: number
49
+ items: ShardOutcome[]
50
+ }
51
+
33
52
  export interface NtMessagesResult extends NtResult {
34
53
  messages?: Message[]
54
+ shards?: ShardReport
35
55
  }
36
56
 
37
57
  export interface NtContactsResult extends NtResult {
@@ -282,6 +302,28 @@ export class NtCore {
282
302
  return { success: true, messages: result.messages || [] }
283
303
  }
284
304
 
305
+ /**
306
+ * `getMessages` plus the per-shard read report.
307
+ *
308
+ * Separate method rather than a flag on `getMessages`: the extra argv token
309
+ * changes what the child returns, and every existing caller is better off
310
+ * with the smaller payload it already parses.
311
+ */
312
+ async getMessagesWithShards(talker: string, limit = 100, offset = 0): Promise<NtMessagesResult> {
313
+ const args: string[] = [
314
+ 'messages',
315
+ '--limit', String(limit),
316
+ '--offset', String(offset),
317
+ '--report-shards',
318
+ ]
319
+ const ownWxid = configService.get('wxid')
320
+ const result = await this.callPython(args, { talker, ownWxid })
321
+ if (result.error) {
322
+ return { success: false, error: result.error }
323
+ }
324
+ return { success: true, messages: result.messages || [], shards: result.shards }
325
+ }
326
+
285
327
  async getContacts(keyword?: string, limit = 200): Promise<NtContactsResult> {
286
328
  const args: string[] = [
287
329
  'contacts',
@@ -3,6 +3,7 @@ import { existsSync } from 'fs'
3
3
  import { WcdbCore } from '../core/wcdbCore.js'
4
4
  import { SqlcipherCore } from '../core/sqlcipherCore.js'
5
5
  import { NtCore } from '../core/ntCore.js'
6
+ import type { ShardReport } from '../core/ntCore.js'
6
7
  import { configService } from './configService.js'
7
8
  import type { ChatSession, Message, Contact, DataVersion } from '../types.js'
8
9
  import { collectMessagesInRange } from './messageQuery.js'
@@ -239,6 +240,31 @@ export class ChatService {
239
240
  return []
240
241
  }
241
242
 
243
+ /**
244
+ * Messages plus a per-shard read report, when the backend can produce one.
245
+ *
246
+ * Only the NT path (Python) knows about shards. The legacy 3.x, raw-4.x and
247
+ * WCDB paths return `shards: undefined`, which callers must read as "this
248
+ * backend cannot tell you" rather than "everything was read" - see
249
+ * `coverage: 'unverified'` in docs/SYNC_CONTRACT.md.
250
+ */
251
+ async getMessagesWithShards(
252
+ talker: string,
253
+ limit = 0,
254
+ offset = 0
255
+ ): Promise<{ messages: Message[]; shards?: ShardReport }> {
256
+ const conn = await this.connect()
257
+ if (!conn.success) return { messages: [] }
258
+
259
+ if (this.ntCore) {
260
+ const result = await this.ntCore.getMessagesWithShards(talker, limit, offset)
261
+ if (!result.success || !result.messages) return { messages: [] }
262
+ return { messages: result.messages, shards: result.shards }
263
+ }
264
+
265
+ return { messages: await this.getMessages(talker, limit, offset) }
266
+ }
267
+
242
268
  async getMessagesInRange(
243
269
  talker: string,
244
270
  limit = 0,
@@ -36,3 +36,100 @@ export async function collectMessagesInRange(
36
36
 
37
37
  return result
38
38
  }
39
+
40
+ /** Why the paging loop stopped. The first two are expected; the last two are not. */
41
+ export type StopReason =
42
+ /** Reached the requested `from` bound. */
43
+ | 'range'
44
+ /** A page came back empty - there is nothing older. */
45
+ | 'exhausted'
46
+ /** Collected the caller's limit. */
47
+ | 'limit'
48
+ /** A page shorter than pageSize ended the loop before `from` was reached. */
49
+ | 'short-page'
50
+
51
+ export interface DetailedMessageCollection {
52
+ messages: Message[]
53
+ pages: number
54
+ stoppedBy: StopReason
55
+ mayHaveMore: boolean
56
+ /**
57
+ * A short page ended the loop while the requested `from` was still ahead
58
+ * of us.
59
+ *
60
+ * `collectMessagesInRange` breaks on a short page, which is right when the
61
+ * page is short because the conversation ended - but a live database can
62
+ * also return a short page while rows are being written, because offset
63
+ * paging shifts underneath. The two are indistinguishable from the result
64
+ * alone, so this records the difference instead of assuming the benign one.
65
+ */
66
+ truncatedEarly: boolean
67
+ }
68
+
69
+ /**
70
+ * `collectMessagesInRange` plus the reason it stopped.
71
+ *
72
+ * Kept separate rather than folded into the original: that one is covered by
73
+ * existing tests and used by every export path, and changing what it returns
74
+ * would change those callers.
75
+ */
76
+ export async function collectMessagesInRangeDetailed(
77
+ fetchPage: (limit: number, offset: number) => Promise<Message[]>,
78
+ limit: number,
79
+ range: MessageTimeRange,
80
+ pageSize = 500,
81
+ ): Promise<DetailedMessageCollection> {
82
+ const messages: Message[] = []
83
+ let offset = 0
84
+ let pages = 0
85
+ let stoppedBy: StopReason = 'exhausted'
86
+ let reachedFrom = range.from === undefined
87
+
88
+ while (true) {
89
+ const page = await fetchPage(pageSize, offset)
90
+ pages += 1
91
+ if (page.length === 0) {
92
+ stoppedBy = 'exhausted'
93
+ reachedFrom = true
94
+ break
95
+ }
96
+
97
+ let oldestTimestamp = Number.POSITIVE_INFINITY
98
+ for (const message of page) {
99
+ const timestamp = Number(message.createTime)
100
+ if (!Number.isFinite(timestamp)) continue
101
+ oldestTimestamp = Math.min(oldestTimestamp, timestamp)
102
+ if (range.from !== undefined && timestamp < range.from) continue
103
+ if (range.to !== undefined && timestamp > range.to) continue
104
+ messages.push(message)
105
+ if (limit > 0 && messages.length >= limit) {
106
+ return { messages, pages, stoppedBy: 'limit', mayHaveMore: true, truncatedEarly: false }
107
+ }
108
+ }
109
+
110
+ offset += page.length
111
+ if (range.from !== undefined && oldestTimestamp < range.from) {
112
+ stoppedBy = 'range'
113
+ reachedFrom = true
114
+ break
115
+ }
116
+ if (page.length < pageSize) {
117
+ // A short *first* page is unambiguous: nothing has shifted yet, so the
118
+ // conversation simply has fewer than pageSize messages. A short later
119
+ // page is the ambiguous one offset paging can produce on a live
120
+ // database, so it keeps its own name.
121
+ stoppedBy = pages === 1 ? 'exhausted' : 'short-page'
122
+ break
123
+ }
124
+ }
125
+
126
+ return {
127
+ messages,
128
+ pages,
129
+ stoppedBy,
130
+ // A limit hit is handled above; anything else that is not 'range' may
131
+ // still have older matching rows we never looked at.
132
+ mayHaveMore: stoppedBy !== 'range' && stoppedBy !== 'exhausted',
133
+ truncatedEarly: stoppedBy === 'short-page' && !reachedFrom,
134
+ }
135
+ }