weflow-cli 1.17.0 → 1.19.1

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 (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. package/src/services/wechatMedia.ts +166 -166
@@ -1,131 +1,131 @@
1
- #!/usr/bin/env python3
2
- """Queue instructions the user sends to the agent mailbox.
3
-
4
- A pure script - no model, no tokens. It runs from Windows Task Scheduler and
5
- its only job is to notice new mail from an allowlisted sender and append it to
6
- a local queue file. Acting on those instructions happens later, from a normal
7
- session, which is when a model is running anyway.
8
-
9
- That split matters: waking a model every few minutes just to discover there is
10
- no mail costs tokens for nothing. This script does the waiting for free.
11
-
12
- ~/.weflow-mail-queue.jsonl one JSON object per pending instruction
13
- ~/.weflow-mail-state.json message ids already queued
14
-
15
- Only mail whose From address is exactly the allowlisted one is ever read or
16
- queued. Mail from anyone else is left untouched - not read, not marked, not
17
- queued.
18
- """
19
- import json
20
- import os
21
- import subprocess
22
- import sys
23
-
24
- STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-state.json')
25
- QUEUE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-queue.jsonl')
26
-
27
- # Exact address, not a display name: a sender name can say anything.
28
- ALLOWED_SENDERS = {'1473517806@qq.com'}
29
-
30
- AGENTLY_CANDIDATES = [
31
- # The npm shim is a .cmd on Windows; subprocess needs the extension spelled
32
- # out, since a bare `agently-cli` is not an executable image.
33
- r'C:\Users\Administrator\AppData\Roaming\npm\agently-cli.cmd',
34
- 'agently-cli.cmd',
35
- 'agently-cli',
36
- ]
37
-
38
-
39
- def _runnable(candidate):
40
- if os.path.isabs(candidate):
41
- return os.path.isfile(candidate)
42
- from shutil import which
43
- return which(candidate) is not None
44
-
45
-
46
- def agently(args):
47
- """Run agently-cli and return its parsed JSON, or None."""
48
- executable = next((c for c in AGENTLY_CANDIDATES if _runnable(c)), None)
49
- if not executable:
50
- print('未找到 agently-cli,跳过。')
51
- return None
52
- try:
53
- result = subprocess.run(
54
- [executable, *args], capture_output=True, timeout=90, encoding='utf-8')
55
- except (OSError, subprocess.SubprocessError) as error:
56
- print(f'agently-cli 调用失败: {type(error).__name__}')
57
- return None
58
- # agently-cli pretty-prints its JSON across many lines, so parse the whole
59
- # payload from the first brace rather than looking for a single-line blob.
60
- text = result.stdout.strip()
61
- start = text.find('{')
62
- if start < 0:
63
- return None
64
- try:
65
- return json.JSONDecoder().raw_decode(text[start:])[0]
66
- except ValueError:
67
- return None
68
-
69
-
70
- def load_json(path, fallback):
71
- try:
72
- with open(path, encoding='utf-8') as handle:
73
- return json.load(handle)
74
- except (OSError, ValueError):
75
- return fallback
76
-
77
-
78
- def main():
79
- state = load_json(STATE_PATH, {'seen': []})
80
- seen = set(state.get('seen', []))
81
-
82
- listing = agently(['message', '+list', '--limit', '20'])
83
- if not listing or not listing.get('ok'):
84
- print('读取邮箱失败(未授权或网络不通),本次跳过。')
85
- return 0
86
-
87
- messages = (listing.get('data') or {}).get('data') or []
88
- queued = 0
89
- for summary in messages:
90
- sender = (summary.get('from') or {}).get('email', '')
91
- if sender not in ALLOWED_SENDERS:
92
- continue
93
- message_id = summary.get('message_id', '')
94
- if not message_id or message_id in seen:
95
- continue
96
-
97
- detail = agently(['message', '+read', '--id', message_id])
98
- body = ''
99
- if detail and detail.get('ok'):
100
- body = ((detail.get('data') or {}).get('text')
101
- or (detail.get('data') or {}).get('body') or '')
102
- if not body:
103
- body = summary.get('snippet', '')
104
-
105
- entry = {
106
- 'message_id': message_id,
107
- 'from': sender,
108
- 'subject': summary.get('subject', ''),
109
- 'created_at': summary.get('created_at', ''),
110
- 'body': body,
111
- }
112
- with open(QUEUE_PATH, 'a', encoding='utf-8') as handle:
113
- handle.write(json.dumps(entry, ensure_ascii=False) + '\n')
114
- seen.add(message_id)
115
- queued += 1
116
-
117
- if queued:
118
- with open(STATE_PATH, 'w', encoding='utf-8') as handle:
119
- json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
120
- print(f'已排队 {queued} 封来自授权发件人的邮件。')
121
- else:
122
- # Still persist first-run state so the backlog is not queued later.
123
- if not os.path.exists(STATE_PATH):
124
- with open(STATE_PATH, 'w', encoding='utf-8') as handle:
125
- json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
126
- print('无新指令邮件。')
127
- return 0
128
-
129
-
130
- if __name__ == '__main__':
131
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """Queue instructions the user sends to the agent mailbox.
3
+
4
+ A pure script - no model, no tokens. It runs from Windows Task Scheduler and
5
+ its only job is to notice new mail from an allowlisted sender and append it to
6
+ a local queue file. Acting on those instructions happens later, from a normal
7
+ session, which is when a model is running anyway.
8
+
9
+ That split matters: waking a model every few minutes just to discover there is
10
+ no mail costs tokens for nothing. This script does the waiting for free.
11
+
12
+ ~/.weflow-mail-queue.jsonl one JSON object per pending instruction
13
+ ~/.weflow-mail-state.json message ids already queued
14
+
15
+ Only mail whose From address is exactly the allowlisted one is ever read or
16
+ queued. Mail from anyone else is left untouched - not read, not marked, not
17
+ queued.
18
+ """
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import sys
23
+
24
+ STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-state.json')
25
+ QUEUE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-queue.jsonl')
26
+
27
+ # Exact address, not a display name: a sender name can say anything.
28
+ ALLOWED_SENDERS = {'1473517806@qq.com'}
29
+
30
+ AGENTLY_CANDIDATES = [
31
+ # The npm shim is a .cmd on Windows; subprocess needs the extension spelled
32
+ # out, since a bare `agently-cli` is not an executable image.
33
+ r'C:\Users\Administrator\AppData\Roaming\npm\agently-cli.cmd',
34
+ 'agently-cli.cmd',
35
+ 'agently-cli',
36
+ ]
37
+
38
+
39
+ def _runnable(candidate):
40
+ if os.path.isabs(candidate):
41
+ return os.path.isfile(candidate)
42
+ from shutil import which
43
+ return which(candidate) is not None
44
+
45
+
46
+ def agently(args):
47
+ """Run agently-cli and return its parsed JSON, or None."""
48
+ executable = next((c for c in AGENTLY_CANDIDATES if _runnable(c)), None)
49
+ if not executable:
50
+ print('未找到 agently-cli,跳过。')
51
+ return None
52
+ try:
53
+ result = subprocess.run(
54
+ [executable, *args], capture_output=True, timeout=90, encoding='utf-8')
55
+ except (OSError, subprocess.SubprocessError) as error:
56
+ print(f'agently-cli 调用失败: {type(error).__name__}')
57
+ return None
58
+ # agently-cli pretty-prints its JSON across many lines, so parse the whole
59
+ # payload from the first brace rather than looking for a single-line blob.
60
+ text = result.stdout.strip()
61
+ start = text.find('{')
62
+ if start < 0:
63
+ return None
64
+ try:
65
+ return json.JSONDecoder().raw_decode(text[start:])[0]
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def load_json(path, fallback):
71
+ try:
72
+ with open(path, encoding='utf-8') as handle:
73
+ return json.load(handle)
74
+ except (OSError, ValueError):
75
+ return fallback
76
+
77
+
78
+ def main():
79
+ state = load_json(STATE_PATH, {'seen': []})
80
+ seen = set(state.get('seen', []))
81
+
82
+ listing = agently(['message', '+list', '--limit', '20'])
83
+ if not listing or not listing.get('ok'):
84
+ print('读取邮箱失败(未授权或网络不通),本次跳过。')
85
+ return 0
86
+
87
+ messages = (listing.get('data') or {}).get('data') or []
88
+ queued = 0
89
+ for summary in messages:
90
+ sender = (summary.get('from') or {}).get('email', '')
91
+ if sender not in ALLOWED_SENDERS:
92
+ continue
93
+ message_id = summary.get('message_id', '')
94
+ if not message_id or message_id in seen:
95
+ continue
96
+
97
+ detail = agently(['message', '+read', '--id', message_id])
98
+ body = ''
99
+ if detail and detail.get('ok'):
100
+ body = ((detail.get('data') or {}).get('text')
101
+ or (detail.get('data') or {}).get('body') or '')
102
+ if not body:
103
+ body = summary.get('snippet', '')
104
+
105
+ entry = {
106
+ 'message_id': message_id,
107
+ 'from': sender,
108
+ 'subject': summary.get('subject', ''),
109
+ 'created_at': summary.get('created_at', ''),
110
+ 'body': body,
111
+ }
112
+ with open(QUEUE_PATH, 'a', encoding='utf-8') as handle:
113
+ handle.write(json.dumps(entry, ensure_ascii=False) + '\n')
114
+ seen.add(message_id)
115
+ queued += 1
116
+
117
+ if queued:
118
+ with open(STATE_PATH, 'w', encoding='utf-8') as handle:
119
+ json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
120
+ print(f'已排队 {queued} 封来自授权发件人的邮件。')
121
+ else:
122
+ # Still persist first-run state so the backlog is not queued later.
123
+ if not os.path.exists(STATE_PATH):
124
+ with open(STATE_PATH, 'w', encoding='utf-8') as handle:
125
+ json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
126
+ print('无新指令邮件。')
127
+ return 0
128
+
129
+
130
+ if __name__ == '__main__':
131
+ sys.exit(main())
@@ -0,0 +1,185 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 head_image.db:联系人的头像(JPEG/PNG 完整字节内嵌库里,按 username 键控)。
3
+
4
+ 为什么从库里拿而不是文件缓存:缓存在 resource 下按 md5 命名、随清理消失,
5
+ 而库里永远在、按 username 键控、带 update_time——**一人多行 = 头像变更历史**
6
+ (2026-10-26 普查:本机 1083 行,见 docs/NT_DATABASES.md)。
7
+
8
+ 只读、本地、不联网。输出元数据不含图片内容(--json 也只报路径与字节数)。
9
+ """
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+ import nt_common # noqa: E402
18
+ import nt_decrypt # noqa: E402
19
+ from _utils import decrypt_lock, load_config # noqa: E402
20
+
21
+
22
+ def head_image_db_path(message_db):
23
+ """message_0.db 同级的 head_image/head_image.db(与 contact.db 同一层推导)。"""
24
+ msg_dir = Path(message_db).parent.parent
25
+ return msg_dir / 'head_image' / 'head_image.db'
26
+
27
+
28
+ def connect(db_path, key_hex, salt_hex, passphrase=''):
29
+ import sqlcipher3
30
+ derived_key, derived_salt = nt_common.derive_database_key(
31
+ str(db_path), key_hex, salt_hex, passphrase)
32
+ conn = sqlcipher3.connect(str(db_path))
33
+ c = conn.cursor()
34
+ c.execute("PRAGMA key = \"x'%s%s'\"" % (derived_key, derived_salt))
35
+ # 探测解密是否真的成功(读 sqlite_master 才触发解密;错误钥匙在这里报 file is not a database)
36
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()
37
+ return conn
38
+
39
+
40
+ def avatar_rows(conn, username):
41
+ """一个 username 的全部头像,新→旧。返回 [(update_time, bytes)]。"""
42
+ c = conn.cursor()
43
+ rows = c.execute(
44
+ 'SELECT update_time, image_buffer FROM head_image WHERE username = ? '
45
+ 'ORDER BY update_time DESC', (username,)).fetchall()
46
+ return [(r[0], bytes(r[1])) for r in rows if r[1]]
47
+
48
+
49
+ def user_rows(conn):
50
+ """全部 username:[(username, 头像数, 最新 update_time)],最新在前。"""
51
+ c = conn.cursor()
52
+ return c.execute(
53
+ 'SELECT username, COUNT(*), MAX(update_time) FROM head_image '
54
+ 'GROUP BY username ORDER BY MAX(update_time) DESC').fetchall()
55
+
56
+
57
+ def resolve_username(conn, query, name_map):
58
+ """查询词 → head_image 里的 username。精确 id 命中优先;否则用 contact 名字反查,
59
+ 歧义/找不到都报错并给候选——不猜(同 resolveTalker 的纪律)。"""
60
+ usernames = {r[0] for r in user_rows(conn)}
61
+ if query in usernames:
62
+ return query
63
+ hits = [u for u, display in name_map.items()
64
+ if display == query and u in usernames]
65
+ if len(hits) == 1:
66
+ return hits[0]
67
+ if len(hits) > 1:
68
+ raise SystemExit('「%s」匹配到多个头像所有者(%s)——请用完整 id 指定'
69
+ % (query, '、'.join(hits[:5])))
70
+ raise SystemExit('没有「%s」的头像(head_image 库里共 %d 个 username;'
71
+ '没有头像或名字对不上都可能是原因)' % (query, len(usernames)))
72
+
73
+
74
+ def ext_of(buf):
75
+ return '.png' if buf[:4] == b'\x89PNG' else '.jpg'
76
+
77
+
78
+ def main():
79
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
80
+ ap = argparse.ArgumentParser(
81
+ description='联系人头像(读 head_image.db;本地只读,不联网)')
82
+ ap.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'),
83
+ help='head_image.db 路径(默认从 WEFLOW_DB_PATH/message 库推导)')
84
+ ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'),
85
+ help='联系人(id 或备注/昵称;--all 时不需要)')
86
+ ap.add_argument('--all', action='store_true',
87
+ help='导出所有 username 的最新头像到 --out 目录')
88
+ ap.add_argument('--out', default=os.environ.get('WEFLOW_AVATAR_OUT') or '',
89
+ help='输出文件(单联系人)或目录(--all)')
90
+ ap.add_argument('--list', action='store_true', help='只列出 username 与头像数')
91
+ ap.add_argument('--json', action='store_true')
92
+ args = ap.parse_args()
93
+
94
+ config = load_config()
95
+ message_db = config.get('ntDbPath', '')
96
+ # --db / WEFLOW_DB_PATH 两种都认:直接给 head_image.db,或给 message 库(推导同级)
97
+ if args.db:
98
+ p = Path(args.db)
99
+ db = p if p.name == 'head_image.db' else head_image_db_path(p)
100
+ elif message_db:
101
+ db = head_image_db_path(message_db)
102
+ else:
103
+ db = None
104
+ if not db or not db.exists():
105
+ print(json.dumps({'success': False, 'error': '找不到 head_image.db(先 weflow-cli init)'}))
106
+ return 1
107
+ passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
108
+ try:
109
+ conn = connect(db, decrypt_lock(config.get('ntKey') or ''),
110
+ config.get('ntSalt') or '', passphrase)
111
+ except Exception as e:
112
+ print(json.dumps({'success': False, 'error': 'head_image.db 打不开:%s' % str(e)[:80]}))
113
+ return 1
114
+
115
+ try:
116
+ if args.list:
117
+ rows = user_rows(conn)
118
+ if args.json:
119
+ print(json.dumps({'success': True, 'users': [
120
+ {'username': u, 'avatars': n, 'newestUpdateTime': t}
121
+ for u, n, t in rows]}, ensure_ascii=False))
122
+ else:
123
+ print('共 %d 个 username 有头像' % len(rows))
124
+ for u, n, t in rows[:20]:
125
+ print(' %s(%d 张,最新 %s)' % (u, n, t))
126
+ return 0
127
+
128
+ if args.all:
129
+ out_dir = Path(args.out or 'output/avatars')
130
+ out_dir.mkdir(parents=True, exist_ok=True)
131
+ written = 0
132
+ for username, _n, _t in user_rows(conn):
133
+ rows = avatar_rows(conn, username)
134
+ if not rows:
135
+ continue
136
+ _ts, buf = rows[0]
137
+ safe = ''.join(ch if ch.isalnum() or ch in '_-@.' else '_'
138
+ for ch in username)[:80]
139
+ path = out_dir / (safe + ext_of(buf))
140
+ path.write_bytes(buf)
141
+ written += 1
142
+ result = {'success': True, 'action': 'avatar.export-all',
143
+ 'dir': str(out_dir), 'written': written,
144
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True}
145
+ print(json.dumps(result, ensure_ascii=False) if args.json
146
+ else '已导出 %d 个最新头像到 %s' % (written, out_dir))
147
+ return 0
148
+
149
+ if not args.talker:
150
+ print(json.dumps({'success': False, 'error': '缺 --talker(或 WEFLOW_TALKER)' }))
151
+ return 1
152
+ name_map = {}
153
+ contact_db = nt_decrypt.find_contact_db_path(str(message_db)) if message_db else None
154
+ if contact_db:
155
+ name_map = nt_decrypt.load_contact_names(
156
+ contact_db, decrypt_lock(config.get('contactKey') or ''),
157
+ config.get('contactSalt') or '')
158
+ username = resolve_username(conn, args.talker, name_map)
159
+ rows = avatar_rows(conn, username)
160
+ if not rows:
161
+ print(json.dumps({'success': False, 'error': '「%s」在库里但头像字节为空' % username},
162
+ ensure_ascii=False))
163
+ return 1
164
+ out = Path(args.out or ('output/avatars/' + username + ext_of(rows[0][1])))
165
+ out.parent.mkdir(parents=True, exist_ok=True)
166
+ out.write_bytes(rows[0][1])
167
+ result = {
168
+ 'success': True, 'action': 'avatar.export', 'username': username,
169
+ 'out': str(out), 'bytes': len(rows[0][1]),
170
+ 'historyCount': len(rows),
171
+ 'newestUpdateTime': rows[0][0],
172
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True,
173
+ }
174
+ if args.json:
175
+ print(json.dumps(result, ensure_ascii=False))
176
+ else:
177
+ print('已导出「%s」的最新头像(%d 字节,历史 %d 张)→ %s'
178
+ % (username, len(rows[0][1]), len(rows), out))
179
+ return 0
180
+ finally:
181
+ conn.close()
182
+
183
+
184
+ if __name__ == '__main__':
185
+ sys.exit(main())
@@ -0,0 +1,190 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 general.db 的资金流水:红包(redEnvelopeTable)与转账(transferTable)。
3
+
4
+ 2026-10-26 普查产物(docs/NT_DATABASES.md)。两个如实的限制(不猜、不补):
5
+ · 红包表**没有时间列、没有金额列**——时间/金额都在消息正文里。行按
6
+ message_server_id 排序(服务端 id 单调增,可看先后),溯源字段随行输出,
7
+ 关联消息分片是后续工作。
8
+ · 状态枚举(hb_status/receive_status/pay_sub_type)的语义**未逐值验证**,
9
+ 原样输出数字并注明,不翻译成没有根据的词。
10
+ 只读、本地、不联网;方向判定按 wxid 前缀匹配(配置 wxid 可能带 Name2Id 没有的
11
+ 后缀,1.15.1 的教训)。
12
+ """
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import nt_common # noqa: E402
22
+ import nt_decrypt # noqa: E402
23
+ from _utils import decrypt_lock, load_config # noqa: E402
24
+
25
+
26
+ def general_db_path(message_db):
27
+ """message_0.db 同级的 general/general.db。"""
28
+ return Path(message_db).parent.parent / 'general' / 'general.db'
29
+
30
+
31
+ def connect(db_path, key_hex, salt_hex, passphrase=''):
32
+ import sqlcipher3
33
+ derived_key, derived_salt = nt_common.derive_database_key(
34
+ str(db_path), key_hex, salt_hex, passphrase)
35
+ conn = sqlcipher3.connect(str(db_path))
36
+ c = conn.cursor()
37
+ c.execute("PRAGMA key = \"x'%s%s'\"" % (derived_key, derived_salt))
38
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()
39
+ return conn
40
+
41
+
42
+ def is_own(name, own_wxid):
43
+ if not name or not own_wxid:
44
+ return False
45
+ return name == own_wxid or name.startswith(own_wxid) or own_wxid.startswith(name)
46
+
47
+
48
+ def ledger_rows(conn, name_map=None, session_filter=None, own_wxid=''):
49
+ """两表 → 归一化行(原样字段 + 方向 + 显示名),新→旧。
50
+
51
+ 红包没有时间列,用 message_server_id 兜底排序(服务端 id 单调增);
52
+ 转账按 begin_transfer_time 排。两表合并后统一按 sortKey 降序。
53
+ """
54
+ name_map = name_map or {}
55
+ c = conn.cursor()
56
+ rows = []
57
+ for sid, session, sender, send_id, hb_status, hb_type, receive_status in c.execute(
58
+ 'SELECT message_server_id, session_name, sender_user_name, send_id, '
59
+ 'hb_status, hb_type, receive_status FROM redEnvelopeTable'):
60
+ if session_filter and session_filter not in (session, name_map.get(session, session)):
61
+ continue
62
+ rows.append({
63
+ 'kind': '红包',
64
+ 'session': session,
65
+ 'sessionDisplay': name_map.get(session, session),
66
+ 'direction': '我发' if is_own(sender, own_wxid) else '对方发',
67
+ # 状态原样:语义未逐值验证(本机观测 hb_status ∈ {1,4}、receive_status ∈ {0,1,2})
68
+ 'hbStatus': hb_status, 'hbType': hb_type, 'receiveStatus': receive_status,
69
+ 'time': None, # 库内无时间列——要时间得按 messageServerId 关联消息分片(未实现)
70
+ 'messageServerId': str(sid or ''),
71
+ 'sortKey': int(sid or 0),
72
+ })
73
+ for tid, _txn, sid, session, sub_type, receiver, payer, begin_ts, invalid_ts in c.execute(
74
+ 'SELECT transfer_id, transcation_id, message_server_id, session_name, '
75
+ 'pay_sub_type, pay_receiver, pay_payer, begin_transfer_time, invalid_time '
76
+ 'FROM transferTable'):
77
+ if session_filter and session_filter not in (session, name_map.get(session, session)):
78
+ continue
79
+ direction = '未知'
80
+ if is_own(payer, own_wxid) and not is_own(receiver, own_wxid):
81
+ direction = '我付'
82
+ elif is_own(receiver, own_wxid) and not is_own(payer, own_wxid):
83
+ direction = '我收'
84
+ rows.append({
85
+ 'kind': '转账',
86
+ 'session': session,
87
+ 'sessionDisplay': name_map.get(session, session),
88
+ 'direction': direction,
89
+ 'paySubType': sub_type, # 本机观测全为 3;语义未验证
90
+ 'counterparty': (receiver if direction == '我付'
91
+ else payer if direction == '我收' else ''),
92
+ 'counterpartyDisplay': name_map.get(
93
+ receiver if direction == '我付' else payer if direction == '我收' else '', ''),
94
+ 'time': int(begin_ts or 0),
95
+ 'invalidTime': int(invalid_ts or 0), # >0 的语义(过期/退还)未验证,原样给出
96
+ 'messageServerId': str(sid or ''),
97
+ 'transferId': str(tid or ''),
98
+ 'sortKey': int(begin_ts or 0) or int(sid or 0),
99
+ })
100
+ rows.sort(key=lambda r: r['sortKey'], reverse=True)
101
+ return rows
102
+
103
+
104
+ def keep_within_days(rows, cutoff):
105
+ """--days 过滤:只滤**有时间戳**的行;红包无时间列不受限(如实保留)。
106
+
107
+ 独立成函数以便测试钉住"红包不被时间窗误杀"——那是把两类行的可用字段
108
+ 混为一谈的错误。
109
+ """
110
+ return [r for r in rows if r['time'] is None or r['time'] >= cutoff]
111
+
112
+
113
+ def main():
114
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
115
+ ap = argparse.ArgumentParser(
116
+ description='资金流水:红包与转账(读 general.db;本地只读,不联网)')
117
+ ap.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'),
118
+ help='general.db 路径(默认从 message 库推导同级)')
119
+ ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'),
120
+ help='可选:只看某个会话(id 或备注/昵称子串)')
121
+ ap.add_argument('--days', type=int, default=0,
122
+ help='只看最近 N 天(按转账时间过滤;红包无时间列不受此限)')
123
+ ap.add_argument('--json', action='store_true')
124
+ args = ap.parse_args()
125
+
126
+ config = load_config()
127
+ message_db = config.get('ntDbPath', '')
128
+ if args.db:
129
+ p = Path(args.db)
130
+ db = p if p.name == 'general.db' else general_db_path(p)
131
+ elif message_db:
132
+ db = general_db_path(message_db)
133
+ else:
134
+ db = None
135
+ if not db or not db.exists():
136
+ print(json.dumps({'success': False, 'error': '找不到 general.db(先 weflow-cli init)'}))
137
+ return 1
138
+ passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
139
+ try:
140
+ conn = connect(db, decrypt_lock(config.get('ntKey') or ''),
141
+ config.get('ntSalt') or '', passphrase)
142
+ except Exception as e:
143
+ print(json.dumps({'success': False, 'error': 'general.db 打不开:%s' % str(e)[:80]}))
144
+ return 1
145
+
146
+ try:
147
+ name_map = {}
148
+ contact_db = nt_decrypt.find_contact_db_path(message_db) if message_db else None
149
+ if contact_db:
150
+ name_map = nt_decrypt.load_contact_names(
151
+ contact_db, decrypt_lock(config.get('contactKey') or ''),
152
+ config.get('contactSalt') or '')
153
+ rows = ledger_rows(conn, name_map, args.talker or None,
154
+ config.get('wxid', ''))
155
+ if args.days and args.days > 0:
156
+ rows = keep_within_days(rows, int(time.time()) - args.days * 86400)
157
+ red = [r for r in rows if r['kind'] == '红包']
158
+ transfers = [r for r in rows if r['kind'] == '转账']
159
+ result = {
160
+ 'success': True, 'action': 'ledger',
161
+ 'redEnvelopes': len(red), 'transfers': len(transfers),
162
+ 'notes': [
163
+ '红包表无时间/金额列:时间与金额在消息正文里,行按 messageServerId 排序(可看先后)',
164
+ '状态枚举(hbStatus/receiveStatus/paySubType)语义未逐值验证,原样输出',
165
+ ],
166
+ 'rows': rows,
167
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True,
168
+ }
169
+ if args.json:
170
+ print(json.dumps(result, ensure_ascii=False))
171
+ else:
172
+ print('红包 %d 笔、转账 %d 笔(状态原样,语义未验证)' % (len(red), len(transfers)))
173
+ for r in rows[:40]:
174
+ when = (time.strftime('%Y-%m-%d %H:%M', time.localtime(r['time']))
175
+ if r.get('time') else '(无时间列)')
176
+ extra = ('status=%s/%s' % (r['hbStatus'], r['receiveStatus'])
177
+ if r['kind'] == '红包' else
178
+ ('对方=%s invalid=%s' % (r.get('counterpartyDisplay') or r.get('counterparty') or '?',
179
+ r.get('invalidTime') or 0)))
180
+ print(' [%s] %s · %s · %s · %s' % (
181
+ when, r['kind'], r['sessionDisplay'], r['direction'], extra))
182
+ if len(rows) > 40:
183
+ print(' …共 %d 行(--json 看全表)' % len(rows))
184
+ return 0
185
+ finally:
186
+ conn.close()
187
+
188
+
189
+ if __name__ == '__main__':
190
+ sys.exit(main())