weflow-cli 1.16.0 → 1.17.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 (97) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +16 -4
  3. package/bin/weflow-cli.ts +348 -6
  4. package/dist/bin/weflow-cli.js +381 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/qchatImport.d.ts +89 -0
  26. package/dist/src/services/qchatImport.d.ts.map +1 -0
  27. package/dist/src/services/qchatImport.js +273 -0
  28. package/dist/src/services/qchatImport.js.map +1 -0
  29. package/dist/src/services/setupVenv.d.ts +43 -0
  30. package/dist/src/services/setupVenv.d.ts.map +1 -0
  31. package/dist/src/services/setupVenv.js +198 -0
  32. package/dist/src/services/setupVenv.js.map +1 -0
  33. package/dist/src/utils/python.d.ts +10 -0
  34. package/dist/src/utils/python.d.ts.map +1 -1
  35. package/dist/src/utils/python.js +27 -4
  36. package/dist/src/utils/python.js.map +1 -1
  37. package/docs/DATA_CONTRACT.md +25 -0
  38. package/docs/DECISIONS.md +92 -30
  39. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  40. package/docs/HEALTH-CHECK.md +104 -104
  41. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  42. package/docs/NEXT_ACTIONS.md +236 -236
  43. package/docs/NPM-PUBLISH.md +130 -130
  44. package/docs/POSITIONING.md +68 -68
  45. package/docs/PROJECT_STATE.md +6 -3
  46. package/docs/REPLAY.md +169 -0
  47. package/docs/SYNC_CONTRACT.md +197 -180
  48. package/package.json +89 -88
  49. package/requirements-3x.txt +4 -4
  50. package/requirements-voice.txt +15 -15
  51. package/resources/js/graph3d/NOTICE.txt +35 -35
  52. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  53. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  54. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  55. package/resources/js/graph3d/d3-octree.min.js +2 -2
  56. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  57. package/resources/js/graph3d/d3-timer.min.js +2 -2
  58. package/resources/js/graph3d/three.min.js +6 -6
  59. package/resources/js/graph3d/viewer2d.js +550 -448
  60. package/resources/panel/quick-menu.cjs +197 -197
  61. package/scripts/_utils.py +1338 -1338
  62. package/scripts/article_notes.py +423 -423
  63. package/scripts/chat_notes.py +86 -3
  64. package/scripts/check_readme_commands.py +427 -426
  65. package/scripts/contact_schema.py +339 -339
  66. package/scripts/create_reading_notes.py +11 -11
  67. package/scripts/daily_task.ps1 +213 -213
  68. package/scripts/fav_notes.py +321 -321
  69. package/scripts/graph_2d.py +156 -151
  70. package/scripts/graph_3d.py +672 -646
  71. package/scripts/health_check.py +473 -473
  72. package/scripts/imported_chats.py +68 -0
  73. package/scripts/install_tasks.ps1 +105 -105
  74. package/scripts/leads.py +179 -179
  75. package/scripts/network.py +206 -206
  76. package/scripts/nt_decrypt.py +135 -1
  77. package/scripts/on_this_day.py +178 -178
  78. package/scripts/opinion_timeline.py +138 -138
  79. package/scripts/promises.py +185 -185
  80. package/scripts/replay.py +803 -0
  81. package/scripts/run_hidden.vbs +25 -0
  82. package/scripts/semantic_search.py +73 -5
  83. package/scripts/user_notes.py +315 -315
  84. package/scripts/voice_transcribe.py +139 -139
  85. package/scripts/watch_issues.py +212 -212
  86. package/scripts/watch_mail.py +131 -131
  87. package/src/core/ntCore.ts +59 -0
  88. package/src/services/assistantService.ts +48 -8
  89. package/src/services/assistantTools.ts +73 -10
  90. package/src/services/configService.ts +3 -2
  91. package/src/services/cursorProbe.ts +222 -0
  92. package/src/services/qchatImport.ts +327 -0
  93. package/src/services/setupVenv.ts +238 -0
  94. package/src/services/syncService.ts +363 -363
  95. package/src/services/syncState.ts +275 -275
  96. package/src/services/wechatMedia.ts +166 -166
  97. package/src/utils/python.ts +33 -4
@@ -1,339 +1,339 @@
1
- # -*- coding: utf-8 -*-
2
- """解 `contact.db` / `chat_room.ext_buffer` 里**已经验证过**的那几个字段(只读)。
3
-
4
- 为什么只解"验证过的":顶层 `#3`/`#4` 的语义在本机实测里被三次证伪——不是
5
- `chatroom_member.member_id` 的最大/最小值、不是 `chat_room.id`、也不在消息库
6
- `message_*.db` 的任何 id 列里。语义没定就不给它编名字:本脚本把它们原样放进
7
- `unrecognized`,谁也别照着一个猜出来的名字写逻辑。结构笔记见
8
- `docs/CONTACT_DB_SCHEMA.md`,取舍见 D-067 / D-068。
9
-
10
- 已验证、因而输出的字段:
11
-
12
- RoomData
13
- #1 (重复) 成员 —— 每个成员:1=userName 2=displayName 3=status 4=inviter
14
- #5 (重复) = 成员里 **status 位 11 置位**的那批 id(本机 77/77 与该集合完全相等;
15
- 它**不是**"额外的参与者"——早前那么叫是错的,见 DECISIONS D-070)
16
-
17
- 联系人模式(`--contacts`):读 `contact.extra_buffer` —— 同样只输出**已验证**的字段:
18
-
19
- #5 (ld) 国家/地区代码(ISO 3166-1 alpha-2)—— 本机 25/25 落在码表、众数 CN 占 94%
20
- #13 (varint) = 该行 `biz_info.type`(账号服务类型)—— 本机 687/687 逐行相等
21
- #41 (varint) = 该行资料的"最近更新时间"(epoch 秒;0 表示显式未设置,按缺省处理)
22
-
23
- **故意不输出** `#4`/`#9`:它们已定性为"该账号自身资料文本的汇集"(含主体名/菜单按钮名),
24
- 是**内容不是字段名**,打进 CLI 输出等于把账号资料漏出去 —— 它们只以长度出现在 `unrecognized` 里。
25
- 其余字段未定名,一律原样进 `unrecognized`(键就是字段号)。取舍见 D-068 / D-082 / D-084 / D-088。
26
-
27
- 用法:
28
- python scripts/contact_schema.py --json
29
- python scripts/contact_schema.py --limit 5 # 只看前 5 个群
30
- python scripts/contact_schema.py --room <群 username 或 id>
31
- python scripts/contact_schema.py --contacts --limit 5 # 改看联系人(#5 地区码 / #13 服务类型 / #41 更新时间)
32
- """
33
- import argparse
34
- import json
35
- import os
36
- import re
37
- import sys
38
- import time
39
-
40
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
42
-
43
- # 手跑时 stdout 是本机编码(Windows 是 GBK):中文会乱码、非 GBK 字符直接崩掉整次输出
44
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
45
-
46
-
47
- def varint(buf, i):
48
- val = 0
49
- shift = 0
50
- while True:
51
- if i >= len(buf):
52
- raise ValueError('varint 越界')
53
- b = buf[i]
54
- i += 1
55
- val |= (b & 0x7F) << shift
56
- if not (b & 0x80):
57
- return val, i
58
- shift += 7
59
- if shift > 63:
60
- raise ValueError('varint 过长')
61
-
62
-
63
- def walk(buf):
64
- """走一遍 wire format,返回 [(字段号, wire type, 值)]。
65
-
66
- **不认识的 wire type 一律抛错**:宁可整条报"解不动",也不把错位的字节当成字段值
67
- ——那正是"看着像"的来源。
68
- """
69
- i = 0
70
- out = []
71
- while i < len(buf):
72
- tag, i = varint(buf, i)
73
- fno, wt = tag >> 3, tag & 7
74
- if fno == 0 or wt in (3, 4):
75
- raise ValueError('字段号/wire type 异常(fno=%d wt=%d)' % (fno, wt))
76
- if wt == 0:
77
- v, i = varint(buf, i)
78
- out.append((fno, wt, v))
79
- elif wt == 2:
80
- ln, i = varint(buf, i)
81
- if i + ln > len(buf):
82
- raise ValueError('长度越界')
83
- out.append((fno, wt, buf[i:i + ln]))
84
- i += ln
85
- elif wt == 5:
86
- if i + 4 > len(buf):
87
- raise ValueError('fixed32 越界')
88
- out.append((fno, wt, buf[i:i + 4]))
89
- i += 4
90
- elif wt == 1:
91
- if i + 8 > len(buf):
92
- raise ValueError('fixed64 越界')
93
- out.append((fno, wt, buf[i:i + 8]))
94
- i += 8
95
- else:
96
- raise ValueError('未知 wire type %d' % wt)
97
- return out
98
-
99
-
100
- def _text(raw):
101
- if raw is None:
102
- return None
103
- try:
104
- return raw.decode('utf-8')
105
- except UnicodeDecodeError:
106
- return None # 不是 UTF-8 就别硬当字符串用(返回 None,调用方按未知处理)
107
-
108
-
109
- def decode_room(blob):
110
- """把一个 ext_buffer 解成 {members, statusBit11Ids, unrecognized};解不动就抛错。"""
111
- members = []
112
- extra_ids = []
113
- unrecognized = {}
114
- for fno, wt, value in walk(blob):
115
- if fno == 1 and wt == 2:
116
- inner = walk(value)
117
- # **键固定**:缺 #2 的成员也要有 displayName 这个键(值为 None),
118
- # 否则 JSON 里时有时无,下游每次都得先问"键在不在"——那种形状迟早被漏判成"没名字"。
119
- member = {'userName': None, 'displayName': None, 'status': None, 'inviter': None}
120
- for m_fno, m_wt, m_val in inner:
121
- if m_fno in (1, 2, 4) and m_wt == 2:
122
- key = {1: 'userName', 2: 'displayName', 4: 'inviter'}[m_fno]
123
- member[key] = _text(m_val)
124
- elif m_fno == 3 and m_wt == 0:
125
- member['status'] = m_val
126
- else:
127
- member.setdefault('unrecognized', {})['%d' % m_fno] = (
128
- len(m_val) if m_wt == 2 else m_val)
129
- members.append(member)
130
- elif fno == 5 and wt == 2:
131
- extra_ids.append(_text(value))
132
- else:
133
- # 不猜:原样放进来,键就是字段号
134
- unrecognized['%d' % fno] = (len(value) if wt == 2 else value)
135
- return {'members': members, 'statusBit11Ids': extra_ids, 'unrecognized': unrecognized}
136
-
137
-
138
- # `contact.extra_buffer` 的 `#5` 只在它是"恰好两个大写 ASCII 字母"时才当地区码输出。
139
- REGION_RE = re.compile(r'^[A-Z]{2}$')
140
-
141
-
142
- def decode_contact(blob):
143
- """解 `contact.extra_buffer` 里**已经验证过**的那几个字段;解不动就抛错。
144
-
145
- 只解"验证过的"(D-068 / D-082 / D-084),三个:
146
-
147
- #5 (ld) 国家/地区代码(ISO 3166-1 alpha-2)—— 本机 25/25 落在码表、众数 CN 占 94%
148
- #13 (varint) = 该行 `biz_info.type`(账号服务类型)—— 本机 687/687 逐行相等
149
- #41 (varint) epoch 秒 = 该行资料的"最近更新时间"—— 0 表示显式未设置,按缺省处理
150
-
151
- **故意不输出的**(说清楚,免得后人以为是漏了):
152
-
153
- - `#4` / `#9`:已定性为"该账号自身资料文本的汇集"(含主体名/菜单按钮名),它是**内容不是字段名** ——
154
- 塞进 CLI 输出等于把账号资料漏出去。它们在 `unrecognized` 里**只留长度**。
155
- - 顶层 `#1`/`#2` 那一族(`contact.extra_buffer` 里 44 行的 OpenIM 小形状):只报 `kind='openim'`,
156
- 其余按键原样放进 `unrecognized`。那两键在文档里是 openim 库的外键,对"读联系人"没用。
157
- - 其余 30 来个字段:**语义未定,一律原样进 `unrecognized`**,键就是字段号(同 `decode_room`)。
158
- """
159
- fields = {}
160
- for fno, wt, value in walk(blob):
161
- fields.setdefault(fno, (wt, value))
162
- if not fields:
163
- return {'kind': None, 'region': None, 'bizType': None, 'updatedAt': None,
164
- 'unrecognized': {}}
165
- raw = {'%d' % fno: (len(v) if wt == 2 else v) for fno, (wt, v) in fields.items()}
166
- # 小形状 = **字段号全 <= 9**(大 proto 一定有 #2..#38,不可能全 <= 9)。
167
- # ⚠️ 别再要求"必须有 #1":那族里有一行没有 #1,加上这个条件会把它错判成联系人
168
- # (全量核对时抓到:kind 分布变成 944/43,文档是 940/44 + 2 个残行)。
169
- if max(fields) <= 9:
170
- return {'kind': 'openim', 'region': None, 'bizType': None, 'updatedAt': None,
171
- 'unrecognized': raw}
172
-
173
- out = {'kind': 'contact', 'region': None, 'bizType': None, 'updatedAt': None,
174
- 'unrecognized': raw}
175
- wt5, v5 = fields.get(5, (None, None))
176
- if wt5 == 2 and isinstance(v5, bytes):
177
- text = _text(v5)
178
- if text and REGION_RE.match(text):
179
- out['region'] = text
180
- wt13, v13 = fields.get(13, (None, None))
181
- if wt13 == 0:
182
- out['bizType'] = v13
183
- wt41, v41 = fields.get(41, (None, None))
184
- if wt41 == 0 and v41: # 0 = 显式"未设置",不是 1970 年
185
- out['updatedAt'] = v41
186
- return out
187
-
188
-
189
- def load_rows(args):
190
- from _utils import get_db_config # noqa: E402
191
- from nt_decrypt import require_sqlcipher # noqa: E402
192
-
193
- cfg = get_db_config()
194
- db = args.db or cfg['contact_db']
195
- key = args.key or cfg['contact_key']
196
- salt = args.salt or cfg['contact_salt']
197
- if not db or not os.path.isfile(db):
198
- raise SystemExit('找不到 contact 库:%s' % (db or '(未配置)'))
199
- if not key or not salt:
200
- raise SystemExit('缺少库密钥(先跑 weflow-cli init,或显式给 --key/--salt)')
201
- conn = require_sqlcipher().connect(db)
202
- cur = conn.cursor()
203
- cur.execute('PRAGMA key = "x\'%s%s\'";' % (key, salt))
204
- sql = ('SELECT id, username, owner, ext_buffer FROM chat_room '
205
- 'WHERE ext_buffer IS NOT NULL AND length(ext_buffer) > 0')
206
- params = []
207
- if args.room:
208
- sql += ' AND (username = ? OR id = ?)'
209
- params = [args.room, args.room]
210
- sql += ' ORDER BY id LIMIT ?'
211
- params.append(args.limit)
212
- rows = cur.execute(sql, params).fetchall()
213
- conn.close()
214
- return rows
215
-
216
-
217
- def load_contacts(args):
218
- from _utils import get_db_config # noqa: E402
219
- from nt_decrypt import require_sqlcipher # noqa: E402
220
-
221
- cfg = get_db_config()
222
- db = args.db or cfg['contact_db']
223
- key = args.key or cfg['contact_key']
224
- salt = args.salt or cfg['contact_salt']
225
- if not db or not os.path.isfile(db):
226
- raise SystemExit('找不到 contact 库:%s' % (db or '(未配置)'))
227
- if not key or not salt:
228
- raise SystemExit('缺少库密钥(先跑 weflow-cli init,或显式给 --key/--salt)')
229
- conn = require_sqlcipher().connect(db)
230
- cur = conn.cursor()
231
- cur.execute('PRAGMA key = "x\'%s%s\'";' % (key, salt))
232
- sql = ('SELECT id, username, local_type, extra_buffer FROM contact '
233
- 'WHERE extra_buffer IS NOT NULL AND length(extra_buffer) > 0')
234
- params = []
235
- if args.room:
236
- sql += ' AND (username = ? OR id = ?)'
237
- params = [args.room, args.room]
238
- sql += ' ORDER BY id LIMIT ?'
239
- params.append(args.limit)
240
- rows = cur.execute(sql, params).fetchall()
241
- conn.close()
242
- return rows
243
-
244
-
245
- def main(argv=None):
246
- ap = argparse.ArgumentParser(
247
- description='读 contact.db 的 ext_buffer(只读,不联网、不调模型):默认读**群**,--contacts 改读**联系人**')
248
- ap.add_argument('--limit', type=int, default=20, help='最多看几个(群 / 联系人,默认 20)')
249
- ap.add_argument('--room', help='只看某个(群 / 联系人 的 username 或 id)')
250
- ap.add_argument('--contacts', action='store_true',
251
- help='改读联系人的 extra_buffer(#5 地区码 / #13 服务类型 / #41 资料更新时间),而不是群')
252
- ap.add_argument('--db', help='contact.db 路径(默认取配置)')
253
- ap.add_argument('--key', help='库密钥(默认取配置)')
254
- ap.add_argument('--salt', help='库盐(默认取配置)')
255
- ap.add_argument('--json', action='store_true', help='输出机器可读结果')
256
- args = ap.parse_args(argv)
257
-
258
- try:
259
- rows = load_contacts(args) if args.contacts else load_rows(args)
260
- except SystemExit:
261
- raise
262
- except Exception as exc:
263
- if args.json:
264
- print(json.dumps({'success': False, 'error': str(exc)[:200]}, ensure_ascii=False))
265
- else:
266
- print('读库失败:%s' % str(exc)[:200], file=sys.stderr)
267
- return 1
268
-
269
- if args.contacts:
270
- contacts = []
271
- failures = []
272
- for rid, username, local_type, blob in rows:
273
- try:
274
- decoded = decode_contact(bytes(blob))
275
- except Exception as exc:
276
- # 同群那条:解不动就说解不动,不返回一条"什么都没有"的联系人
277
- failures.append({'id': rid, 'username': username, 'error': str(exc)[:80]})
278
- continue
279
- contacts.append({'id': rid, 'username': username, 'localType': local_type,
280
- 'kind': decoded['kind'], 'region': decoded['region'],
281
- 'bizType': decoded['bizType'], 'updatedAt': decoded['updatedAt'],
282
- 'unrecognized': decoded['unrecognized']})
283
- if args.json:
284
- print(json.dumps({'success': True, 'action': 'contact-schema',
285
- 'contacts': contacts, 'failures': failures,
286
- 'readsLocalData': True, 'invokesAI': False,
287
- 'sendsNothing': True}, ensure_ascii=False))
288
- else:
289
- print('看了 %d 个联系人;解不动 %d 个' % (len(contacts), len(failures)))
290
- print('(只输出已验证字段:#5 地区码 / #13 服务类型 / #41 资料更新时间;'
291
- '#4/#9 是账号资料文本,属内容,不打印,只在"未识别"里留长度)')
292
- for c in contacts:
293
- when = time.strftime('%Y-%m-%d', time.gmtime(c['updatedAt'])) if c['updatedAt'] else '-'
294
- print(' %-28s local_type=%-3s kind=%-8s 地区=%-3s 服务类型=%-4s 更新=%-11s 未识别 %d 个'
295
- % (c['username'], c['localType'], c['kind'], c['region'] or '-',
296
- c['bizType'] if c['bizType'] is not None else '-', when,
297
- len(c['unrecognized'])))
298
- for f in failures:
299
- print('解不动: id=%s %s' % (f['id'], f['error']), file=sys.stderr)
300
- return 0
301
-
302
- rooms = []
303
- failures = []
304
- for rid, username, owner, blob in rows:
305
- try:
306
- decoded = decode_room(bytes(blob))
307
- except Exception as exc:
308
- # **解不动就说解不动**,不返回空成员表冒充"这个群没人"
309
- failures.append({'id': rid, 'username': username, 'error': str(exc)[:80]})
310
- continue
311
- rooms.append({'id': rid, 'username': username, 'owner': owner,
312
- 'memberCount': len(decoded['members']),
313
- 'members': decoded['members'],
314
- 'statusBit11Ids': decoded['statusBit11Ids'],
315
- 'unrecognized': decoded['unrecognized']})
316
-
317
- if args.json:
318
- print(json.dumps({'success': True, 'action': 'contact-schema', 'rooms': rooms,
319
- 'failures': failures, 'readsLocalData': True,
320
- 'invokesAI': False, 'sendsNothing': True}, ensure_ascii=False))
321
- else:
322
- print('看了 %d 个群;解不动 %d 个' % (len(rooms), len(failures)))
323
- for room in rooms:
324
- print('\n群 %s(id=%s)成员 %d 个;未识别字段 %s' % (
325
- room['username'], room['id'], room['memberCount'],
326
- ','.join(sorted(room['unrecognized'])) or '无'))
327
- for m in room['members'][:5]:
328
- print(' %-28s %-12s status=%-8s inviter=%s' % (
329
- m.get('userName'), m.get('displayName'), m.get('status'), m.get('inviter')))
330
- if room['statusBit11Ids']:
331
- print(' #5(status 位 11 的成员): %s'
332
- % ', '.join(x or '?' for x in room['statusBit11Ids'][:5]))
333
- for f in failures:
334
- print('解不动: id=%s %s' % (f['id'], f['error']), file=sys.stderr)
335
- return 0
336
-
337
-
338
- if __name__ == '__main__':
339
- sys.exit(main())
1
+ # -*- coding: utf-8 -*-
2
+ """解 `contact.db` / `chat_room.ext_buffer` 里**已经验证过**的那几个字段(只读)。
3
+
4
+ 为什么只解"验证过的":顶层 `#3`/`#4` 的语义在本机实测里被三次证伪——不是
5
+ `chatroom_member.member_id` 的最大/最小值、不是 `chat_room.id`、也不在消息库
6
+ `message_*.db` 的任何 id 列里。语义没定就不给它编名字:本脚本把它们原样放进
7
+ `unrecognized`,谁也别照着一个猜出来的名字写逻辑。结构笔记见
8
+ `docs/CONTACT_DB_SCHEMA.md`,取舍见 D-067 / D-068。
9
+
10
+ 已验证、因而输出的字段:
11
+
12
+ RoomData
13
+ #1 (重复) 成员 —— 每个成员:1=userName 2=displayName 3=status 4=inviter
14
+ #5 (重复) = 成员里 **status 位 11 置位**的那批 id(本机 77/77 与该集合完全相等;
15
+ 它**不是**"额外的参与者"——早前那么叫是错的,见 DECISIONS D-070)
16
+
17
+ 联系人模式(`--contacts`):读 `contact.extra_buffer` —— 同样只输出**已验证**的字段:
18
+
19
+ #5 (ld) 国家/地区代码(ISO 3166-1 alpha-2)—— 本机 25/25 落在码表、众数 CN 占 94%
20
+ #13 (varint) = 该行 `biz_info.type`(账号服务类型)—— 本机 687/687 逐行相等
21
+ #41 (varint) = 该行资料的"最近更新时间"(epoch 秒;0 表示显式未设置,按缺省处理)
22
+
23
+ **故意不输出** `#4`/`#9`:它们已定性为"该账号自身资料文本的汇集"(含主体名/菜单按钮名),
24
+ 是**内容不是字段名**,打进 CLI 输出等于把账号资料漏出去 —— 它们只以长度出现在 `unrecognized` 里。
25
+ 其余字段未定名,一律原样进 `unrecognized`(键就是字段号)。取舍见 D-068 / D-082 / D-084 / D-088。
26
+
27
+ 用法:
28
+ python scripts/contact_schema.py --json
29
+ python scripts/contact_schema.py --limit 5 # 只看前 5 个群
30
+ python scripts/contact_schema.py --room <群 username 或 id>
31
+ python scripts/contact_schema.py --contacts --limit 5 # 改看联系人(#5 地区码 / #13 服务类型 / #41 更新时间)
32
+ """
33
+ import argparse
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
42
+
43
+ # 手跑时 stdout 是本机编码(Windows 是 GBK):中文会乱码、非 GBK 字符直接崩掉整次输出
44
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
45
+
46
+
47
+ def varint(buf, i):
48
+ val = 0
49
+ shift = 0
50
+ while True:
51
+ if i >= len(buf):
52
+ raise ValueError('varint 越界')
53
+ b = buf[i]
54
+ i += 1
55
+ val |= (b & 0x7F) << shift
56
+ if not (b & 0x80):
57
+ return val, i
58
+ shift += 7
59
+ if shift > 63:
60
+ raise ValueError('varint 过长')
61
+
62
+
63
+ def walk(buf):
64
+ """走一遍 wire format,返回 [(字段号, wire type, 值)]。
65
+
66
+ **不认识的 wire type 一律抛错**:宁可整条报"解不动",也不把错位的字节当成字段值
67
+ ——那正是"看着像"的来源。
68
+ """
69
+ i = 0
70
+ out = []
71
+ while i < len(buf):
72
+ tag, i = varint(buf, i)
73
+ fno, wt = tag >> 3, tag & 7
74
+ if fno == 0 or wt in (3, 4):
75
+ raise ValueError('字段号/wire type 异常(fno=%d wt=%d)' % (fno, wt))
76
+ if wt == 0:
77
+ v, i = varint(buf, i)
78
+ out.append((fno, wt, v))
79
+ elif wt == 2:
80
+ ln, i = varint(buf, i)
81
+ if i + ln > len(buf):
82
+ raise ValueError('长度越界')
83
+ out.append((fno, wt, buf[i:i + ln]))
84
+ i += ln
85
+ elif wt == 5:
86
+ if i + 4 > len(buf):
87
+ raise ValueError('fixed32 越界')
88
+ out.append((fno, wt, buf[i:i + 4]))
89
+ i += 4
90
+ elif wt == 1:
91
+ if i + 8 > len(buf):
92
+ raise ValueError('fixed64 越界')
93
+ out.append((fno, wt, buf[i:i + 8]))
94
+ i += 8
95
+ else:
96
+ raise ValueError('未知 wire type %d' % wt)
97
+ return out
98
+
99
+
100
+ def _text(raw):
101
+ if raw is None:
102
+ return None
103
+ try:
104
+ return raw.decode('utf-8')
105
+ except UnicodeDecodeError:
106
+ return None # 不是 UTF-8 就别硬当字符串用(返回 None,调用方按未知处理)
107
+
108
+
109
+ def decode_room(blob):
110
+ """把一个 ext_buffer 解成 {members, statusBit11Ids, unrecognized};解不动就抛错。"""
111
+ members = []
112
+ extra_ids = []
113
+ unrecognized = {}
114
+ for fno, wt, value in walk(blob):
115
+ if fno == 1 and wt == 2:
116
+ inner = walk(value)
117
+ # **键固定**:缺 #2 的成员也要有 displayName 这个键(值为 None),
118
+ # 否则 JSON 里时有时无,下游每次都得先问"键在不在"——那种形状迟早被漏判成"没名字"。
119
+ member = {'userName': None, 'displayName': None, 'status': None, 'inviter': None}
120
+ for m_fno, m_wt, m_val in inner:
121
+ if m_fno in (1, 2, 4) and m_wt == 2:
122
+ key = {1: 'userName', 2: 'displayName', 4: 'inviter'}[m_fno]
123
+ member[key] = _text(m_val)
124
+ elif m_fno == 3 and m_wt == 0:
125
+ member['status'] = m_val
126
+ else:
127
+ member.setdefault('unrecognized', {})['%d' % m_fno] = (
128
+ len(m_val) if m_wt == 2 else m_val)
129
+ members.append(member)
130
+ elif fno == 5 and wt == 2:
131
+ extra_ids.append(_text(value))
132
+ else:
133
+ # 不猜:原样放进来,键就是字段号
134
+ unrecognized['%d' % fno] = (len(value) if wt == 2 else value)
135
+ return {'members': members, 'statusBit11Ids': extra_ids, 'unrecognized': unrecognized}
136
+
137
+
138
+ # `contact.extra_buffer` 的 `#5` 只在它是"恰好两个大写 ASCII 字母"时才当地区码输出。
139
+ REGION_RE = re.compile(r'^[A-Z]{2}$')
140
+
141
+
142
+ def decode_contact(blob):
143
+ """解 `contact.extra_buffer` 里**已经验证过**的那几个字段;解不动就抛错。
144
+
145
+ 只解"验证过的"(D-068 / D-082 / D-084),三个:
146
+
147
+ #5 (ld) 国家/地区代码(ISO 3166-1 alpha-2)—— 本机 25/25 落在码表、众数 CN 占 94%
148
+ #13 (varint) = 该行 `biz_info.type`(账号服务类型)—— 本机 687/687 逐行相等
149
+ #41 (varint) epoch 秒 = 该行资料的"最近更新时间"—— 0 表示显式未设置,按缺省处理
150
+
151
+ **故意不输出的**(说清楚,免得后人以为是漏了):
152
+
153
+ - `#4` / `#9`:已定性为"该账号自身资料文本的汇集"(含主体名/菜单按钮名),它是**内容不是字段名** ——
154
+ 塞进 CLI 输出等于把账号资料漏出去。它们在 `unrecognized` 里**只留长度**。
155
+ - 顶层 `#1`/`#2` 那一族(`contact.extra_buffer` 里 44 行的 OpenIM 小形状):只报 `kind='openim'`,
156
+ 其余按键原样放进 `unrecognized`。那两键在文档里是 openim 库的外键,对"读联系人"没用。
157
+ - 其余 30 来个字段:**语义未定,一律原样进 `unrecognized`**,键就是字段号(同 `decode_room`)。
158
+ """
159
+ fields = {}
160
+ for fno, wt, value in walk(blob):
161
+ fields.setdefault(fno, (wt, value))
162
+ if not fields:
163
+ return {'kind': None, 'region': None, 'bizType': None, 'updatedAt': None,
164
+ 'unrecognized': {}}
165
+ raw = {'%d' % fno: (len(v) if wt == 2 else v) for fno, (wt, v) in fields.items()}
166
+ # 小形状 = **字段号全 <= 9**(大 proto 一定有 #2..#38,不可能全 <= 9)。
167
+ # ⚠️ 别再要求"必须有 #1":那族里有一行没有 #1,加上这个条件会把它错判成联系人
168
+ # (全量核对时抓到:kind 分布变成 944/43,文档是 940/44 + 2 个残行)。
169
+ if max(fields) <= 9:
170
+ return {'kind': 'openim', 'region': None, 'bizType': None, 'updatedAt': None,
171
+ 'unrecognized': raw}
172
+
173
+ out = {'kind': 'contact', 'region': None, 'bizType': None, 'updatedAt': None,
174
+ 'unrecognized': raw}
175
+ wt5, v5 = fields.get(5, (None, None))
176
+ if wt5 == 2 and isinstance(v5, bytes):
177
+ text = _text(v5)
178
+ if text and REGION_RE.match(text):
179
+ out['region'] = text
180
+ wt13, v13 = fields.get(13, (None, None))
181
+ if wt13 == 0:
182
+ out['bizType'] = v13
183
+ wt41, v41 = fields.get(41, (None, None))
184
+ if wt41 == 0 and v41: # 0 = 显式"未设置",不是 1970 年
185
+ out['updatedAt'] = v41
186
+ return out
187
+
188
+
189
+ def load_rows(args):
190
+ from _utils import get_db_config # noqa: E402
191
+ from nt_decrypt import require_sqlcipher # noqa: E402
192
+
193
+ cfg = get_db_config()
194
+ db = args.db or cfg['contact_db']
195
+ key = args.key or cfg['contact_key']
196
+ salt = args.salt or cfg['contact_salt']
197
+ if not db or not os.path.isfile(db):
198
+ raise SystemExit('找不到 contact 库:%s' % (db or '(未配置)'))
199
+ if not key or not salt:
200
+ raise SystemExit('缺少库密钥(先跑 weflow-cli init,或显式给 --key/--salt)')
201
+ conn = require_sqlcipher().connect(db)
202
+ cur = conn.cursor()
203
+ cur.execute('PRAGMA key = "x\'%s%s\'";' % (key, salt))
204
+ sql = ('SELECT id, username, owner, ext_buffer FROM chat_room '
205
+ 'WHERE ext_buffer IS NOT NULL AND length(ext_buffer) > 0')
206
+ params = []
207
+ if args.room:
208
+ sql += ' AND (username = ? OR id = ?)'
209
+ params = [args.room, args.room]
210
+ sql += ' ORDER BY id LIMIT ?'
211
+ params.append(args.limit)
212
+ rows = cur.execute(sql, params).fetchall()
213
+ conn.close()
214
+ return rows
215
+
216
+
217
+ def load_contacts(args):
218
+ from _utils import get_db_config # noqa: E402
219
+ from nt_decrypt import require_sqlcipher # noqa: E402
220
+
221
+ cfg = get_db_config()
222
+ db = args.db or cfg['contact_db']
223
+ key = args.key or cfg['contact_key']
224
+ salt = args.salt or cfg['contact_salt']
225
+ if not db or not os.path.isfile(db):
226
+ raise SystemExit('找不到 contact 库:%s' % (db or '(未配置)'))
227
+ if not key or not salt:
228
+ raise SystemExit('缺少库密钥(先跑 weflow-cli init,或显式给 --key/--salt)')
229
+ conn = require_sqlcipher().connect(db)
230
+ cur = conn.cursor()
231
+ cur.execute('PRAGMA key = "x\'%s%s\'";' % (key, salt))
232
+ sql = ('SELECT id, username, local_type, extra_buffer FROM contact '
233
+ 'WHERE extra_buffer IS NOT NULL AND length(extra_buffer) > 0')
234
+ params = []
235
+ if args.room:
236
+ sql += ' AND (username = ? OR id = ?)'
237
+ params = [args.room, args.room]
238
+ sql += ' ORDER BY id LIMIT ?'
239
+ params.append(args.limit)
240
+ rows = cur.execute(sql, params).fetchall()
241
+ conn.close()
242
+ return rows
243
+
244
+
245
+ def main(argv=None):
246
+ ap = argparse.ArgumentParser(
247
+ description='读 contact.db 的 ext_buffer(只读,不联网、不调模型):默认读**群**,--contacts 改读**联系人**')
248
+ ap.add_argument('--limit', type=int, default=20, help='最多看几个(群 / 联系人,默认 20)')
249
+ ap.add_argument('--room', help='只看某个(群 / 联系人 的 username 或 id)')
250
+ ap.add_argument('--contacts', action='store_true',
251
+ help='改读联系人的 extra_buffer(#5 地区码 / #13 服务类型 / #41 资料更新时间),而不是群')
252
+ ap.add_argument('--db', help='contact.db 路径(默认取配置)')
253
+ ap.add_argument('--key', help='库密钥(默认取配置)')
254
+ ap.add_argument('--salt', help='库盐(默认取配置)')
255
+ ap.add_argument('--json', action='store_true', help='输出机器可读结果')
256
+ args = ap.parse_args(argv)
257
+
258
+ try:
259
+ rows = load_contacts(args) if args.contacts else load_rows(args)
260
+ except SystemExit:
261
+ raise
262
+ except Exception as exc:
263
+ if args.json:
264
+ print(json.dumps({'success': False, 'error': str(exc)[:200]}, ensure_ascii=False))
265
+ else:
266
+ print('读库失败:%s' % str(exc)[:200], file=sys.stderr)
267
+ return 1
268
+
269
+ if args.contacts:
270
+ contacts = []
271
+ failures = []
272
+ for rid, username, local_type, blob in rows:
273
+ try:
274
+ decoded = decode_contact(bytes(blob))
275
+ except Exception as exc:
276
+ # 同群那条:解不动就说解不动,不返回一条"什么都没有"的联系人
277
+ failures.append({'id': rid, 'username': username, 'error': str(exc)[:80]})
278
+ continue
279
+ contacts.append({'id': rid, 'username': username, 'localType': local_type,
280
+ 'kind': decoded['kind'], 'region': decoded['region'],
281
+ 'bizType': decoded['bizType'], 'updatedAt': decoded['updatedAt'],
282
+ 'unrecognized': decoded['unrecognized']})
283
+ if args.json:
284
+ print(json.dumps({'success': True, 'action': 'contact-schema',
285
+ 'contacts': contacts, 'failures': failures,
286
+ 'readsLocalData': True, 'invokesAI': False,
287
+ 'sendsNothing': True}, ensure_ascii=False))
288
+ else:
289
+ print('看了 %d 个联系人;解不动 %d 个' % (len(contacts), len(failures)))
290
+ print('(只输出已验证字段:#5 地区码 / #13 服务类型 / #41 资料更新时间;'
291
+ '#4/#9 是账号资料文本,属内容,不打印,只在"未识别"里留长度)')
292
+ for c in contacts:
293
+ when = time.strftime('%Y-%m-%d', time.gmtime(c['updatedAt'])) if c['updatedAt'] else '-'
294
+ print(' %-28s local_type=%-3s kind=%-8s 地区=%-3s 服务类型=%-4s 更新=%-11s 未识别 %d 个'
295
+ % (c['username'], c['localType'], c['kind'], c['region'] or '-',
296
+ c['bizType'] if c['bizType'] is not None else '-', when,
297
+ len(c['unrecognized'])))
298
+ for f in failures:
299
+ print('解不动: id=%s %s' % (f['id'], f['error']), file=sys.stderr)
300
+ return 0
301
+
302
+ rooms = []
303
+ failures = []
304
+ for rid, username, owner, blob in rows:
305
+ try:
306
+ decoded = decode_room(bytes(blob))
307
+ except Exception as exc:
308
+ # **解不动就说解不动**,不返回空成员表冒充"这个群没人"
309
+ failures.append({'id': rid, 'username': username, 'error': str(exc)[:80]})
310
+ continue
311
+ rooms.append({'id': rid, 'username': username, 'owner': owner,
312
+ 'memberCount': len(decoded['members']),
313
+ 'members': decoded['members'],
314
+ 'statusBit11Ids': decoded['statusBit11Ids'],
315
+ 'unrecognized': decoded['unrecognized']})
316
+
317
+ if args.json:
318
+ print(json.dumps({'success': True, 'action': 'contact-schema', 'rooms': rooms,
319
+ 'failures': failures, 'readsLocalData': True,
320
+ 'invokesAI': False, 'sendsNothing': True}, ensure_ascii=False))
321
+ else:
322
+ print('看了 %d 个群;解不动 %d 个' % (len(rooms), len(failures)))
323
+ for room in rooms:
324
+ print('\n群 %s(id=%s)成员 %d 个;未识别字段 %s' % (
325
+ room['username'], room['id'], room['memberCount'],
326
+ ','.join(sorted(room['unrecognized'])) or '无'))
327
+ for m in room['members'][:5]:
328
+ print(' %-28s %-12s status=%-8s inviter=%s' % (
329
+ m.get('userName'), m.get('displayName'), m.get('status'), m.get('inviter')))
330
+ if room['statusBit11Ids']:
331
+ print(' #5(status 位 11 的成员): %s'
332
+ % ', '.join(x or '?' for x in room['statusBit11Ids'][:5]))
333
+ for f in failures:
334
+ print('解不动: id=%s %s' % (f['id'], f['error']), file=sys.stderr)
335
+ return 0
336
+
337
+
338
+ if __name__ == '__main__':
339
+ sys.exit(main())