weflow-cli 1.16.0 → 1.19.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.
- package/CHANGELOG.md +72 -0
- package/README.md +19 -4
- package/bin/weflow-cli.ts +515 -6
- package/dist/bin/weflow-cli.js +580 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/diagnosticReport.d.ts +6 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -1
- package/dist/src/services/diagnosticReport.js +16 -0
- package/dist/src/services/diagnosticReport.js.map +1 -1
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/services/wechatCompat.d.ts +29 -0
- package/dist/src/services/wechatCompat.d.ts.map +1 -0
- package/dist/src/services/wechatCompat.js +65 -0
- package/dist/src/services/wechatCompat.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +118 -30
- package/docs/NT_DATABASES.md +47 -0
- package/docs/PROJECT_STATE.md +7 -3
- package/docs/RELEASING.md +4 -0
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/scripts/annual_report.py +3 -1
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/extract_todos.py +3 -1
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/imported_chats.py +68 -0
- package/scripts/mcp_bridge.py +3 -1
- package/scripts/media_index.py +150 -0
- package/scripts/nt_common.py +32 -0
- package/scripts/nt_decrypt.py +154 -16
- package/scripts/on_this_day.py +178 -178
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +78 -6
- package/scripts/wechat_avatar.py +185 -0
- package/scripts/wechat_ledger.py +190 -0
- package/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/diagnosticReport.ts +22 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/wechatCompat.ts +67 -0
- package/src/utils/python.ts +33 -4
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
' Silent launcher for the WeFlow daily task.
|
|
2
|
+
'
|
|
3
|
+
' Why this file exists: a scheduled action that runs "powershell.exe -File ..." starts a
|
|
4
|
+
' CONSOLE program, so Windows creates a console window before PowerShell can hide it - you
|
|
5
|
+
' see a black box appear, and it is especially intrusive over a full-screen game. wscript.exe
|
|
6
|
+
' is a GUI process with no console at all, and WshShell.Run with window style 0 keeps the
|
|
7
|
+
' child hidden too.
|
|
8
|
+
'
|
|
9
|
+
' Arguments are forwarded verbatim, so "run_hidden.vbs noon" is the noon pass.
|
|
10
|
+
'
|
|
11
|
+
' ASCII only on purpose: Windows Script Host reads this file with the ANSI code page.
|
|
12
|
+
|
|
13
|
+
Dim fso, here, script, cmd, sh
|
|
14
|
+
Set fso = CreateObject("Scripting.FileSystemObject")
|
|
15
|
+
here = fso.GetParentFolderName(WScript.ScriptFullName)
|
|
16
|
+
script = fso.BuildPath(here, "daily_task.ps1")
|
|
17
|
+
|
|
18
|
+
cmd = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & script & """"
|
|
19
|
+
If WScript.Arguments.Count > 0 Then
|
|
20
|
+
cmd = cmd & " " & WScript.Arguments(0)
|
|
21
|
+
End If
|
|
22
|
+
|
|
23
|
+
Set sh = CreateObject("WScript.Shell")
|
|
24
|
+
' 0 = hidden window, True = wait for the child so the task's last result reflects the run.
|
|
25
|
+
sh.Run cmd, 0, True
|
|
@@ -63,6 +63,7 @@ except:
|
|
|
63
63
|
|
|
64
64
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
65
65
|
from _utils import load_config, decrypt_lock, get_dashscope_key, get_api_key, LINE_LABELS # noqa: E402
|
|
66
|
+
from nt_common import decode_content # noqa: E402
|
|
66
67
|
from jev_client import create_client
|
|
67
68
|
|
|
68
69
|
OUTPUT_ROOT = 'output'
|
|
@@ -248,7 +249,10 @@ def collect_chat_messages(conns, name_map, days=90, per_talker=500):
|
|
|
248
249
|
except Exception:
|
|
249
250
|
continue
|
|
250
251
|
for rid, ts, sender, content in rows:
|
|
251
|
-
|
|
252
|
+
# 正文可能是 zstd 压缩的 BLOB(issue #12):先解码再判断长度,
|
|
253
|
+
# 否则这些消息会被整条跳过、索引里永远缺一段。
|
|
254
|
+
content = decode_content(content)
|
|
255
|
+
if len(content) < 10:
|
|
252
256
|
continue
|
|
253
257
|
item_id = f"chat:{talker}:{ts}:{rid}"
|
|
254
258
|
if item_id in seen:
|
|
@@ -268,6 +272,36 @@ def collect_chat_messages(conns, name_map, days=90, per_talker=500):
|
|
|
268
272
|
return items
|
|
269
273
|
|
|
270
274
|
|
|
275
|
+
def collect_imported_chat_items(days=90):
|
|
276
|
+
"""导入的 QQ 会话 → 与微信聊天同形状的索引记录(--include-imports)。
|
|
277
|
+
|
|
278
|
+
形状必须与 collect_chat_messages 逐字段一致(id/type/talker/sender/time/text),
|
|
279
|
+
否则同一条记录在两半索引里语义不同。id 带 qq: 段:与微信记录的
|
|
280
|
+
chat:<talker>:<ts>:<rid> 天然不撞,也永远可分辨来源。文本阈值(>=10 字)与
|
|
281
|
+
截断(500 字)同微信侧——索引是要花钱嵌的,两侧口径不同就是两种相关性。
|
|
282
|
+
"""
|
|
283
|
+
import imported_chats
|
|
284
|
+
|
|
285
|
+
items = []
|
|
286
|
+
for sess in imported_chats.sessions():
|
|
287
|
+
meta = sess.get('session') or {}
|
|
288
|
+
talker = 'QQ-%s' % (meta.get('name') or meta.get('id') or '未命名')
|
|
289
|
+
for m in imported_chats.session_messages(sess, days=days):
|
|
290
|
+
text = (m.get('parsedContent') or '').strip()
|
|
291
|
+
if len(text) < 10:
|
|
292
|
+
continue
|
|
293
|
+
dt = datetime.fromtimestamp(m.get('createTime') or 0, tz=TZ)
|
|
294
|
+
items.append({
|
|
295
|
+
'id': 'chat:qq:%s:%s' % (meta.get('id') or '', m.get('serverId') or ''),
|
|
296
|
+
'type': 'chat',
|
|
297
|
+
'talker': talker,
|
|
298
|
+
'sender': m.get('senderName') or '',
|
|
299
|
+
'time': dt.strftime('%Y-%m-%d %H:%M'),
|
|
300
|
+
'text': text[:500],
|
|
301
|
+
})
|
|
302
|
+
return items
|
|
303
|
+
|
|
304
|
+
|
|
271
305
|
_ARTICLE_BOILERPLATE = frozenset({
|
|
272
306
|
'在小说阅读器读本章', '去阅读', '在公众号小说中沉浸阅读',
|
|
273
307
|
})
|
|
@@ -396,7 +430,7 @@ def _save_index(base_vectors, base_meta, pairs):
|
|
|
396
430
|
os.replace(tmp_m, META_FILE)
|
|
397
431
|
return len(meta)
|
|
398
432
|
|
|
399
|
-
def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30):
|
|
433
|
+
def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30, include_imports: bool = False):
|
|
400
434
|
"""Build or update the semantic index.
|
|
401
435
|
|
|
402
436
|
`days` / `article_days` 决定这个索引**记多久**:它们原先写死在两处调用里
|
|
@@ -421,8 +455,12 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
421
455
|
# Load DB
|
|
422
456
|
config = load_config()
|
|
423
457
|
nt_db = config.get('ntDbPath', '')
|
|
424
|
-
|
|
458
|
+
# --include-imports 时没有微信库也能建索引(纯 QQ 用户是真实场景);
|
|
459
|
+
# 但要说出"聊天半边只有导入的",不然窗口行读起来像微信也收了。
|
|
460
|
+
if not nt_db and not include_imports:
|
|
425
461
|
return {"error": "未初始化,请先运行 weflow-cli init"}
|
|
462
|
+
if not nt_db and include_imports:
|
|
463
|
+
print('[WARN] 未配置微信库:聊天半边只用导入的会话', file=sys.stderr)
|
|
426
464
|
|
|
427
465
|
nt_key = decrypt_lock(config.get('ntKey', ''))
|
|
428
466
|
nt_salt = config.get('ntSalt', '')
|
|
@@ -439,6 +477,16 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
439
477
|
# Collect items
|
|
440
478
|
items = []
|
|
441
479
|
shards_failed = []
|
|
480
|
+
if not nt_db:
|
|
481
|
+
# 没有微信库:跳过 NT 那半(name_map/分片都不存在),导入与文章照收。
|
|
482
|
+
# 收完直接走同一个收尾(嵌入与写盘),不在这里复制粘贴后半段。
|
|
483
|
+
if include_imports:
|
|
484
|
+
items.extend(collect_imported_chat_items(days=days))
|
|
485
|
+
items.extend(collect_articles(article_days=article_days))
|
|
486
|
+
new_items = [item for item in items if item['id'] not in existing_ids]
|
|
487
|
+
print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
|
|
488
|
+
return _build_embed_and_write(api_key, items, new_items, existing_ids,
|
|
489
|
+
full, days, article_days, shards_failed=[])
|
|
442
490
|
try:
|
|
443
491
|
import nt_decrypt # 懒加载:纯逻辑路径(关键词/重排/测试)不该被它拖住
|
|
444
492
|
passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
|
|
@@ -460,13 +508,29 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
460
508
|
except Exception as e:
|
|
461
509
|
print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
|
|
462
510
|
|
|
511
|
+
if include_imports:
|
|
512
|
+
imported = collect_imported_chat_items(days=days)
|
|
513
|
+
items.extend(imported)
|
|
514
|
+
print(f"导入会话: +{len(imported)} 条", file=sys.stderr)
|
|
515
|
+
|
|
463
516
|
article_items = collect_articles(article_days=article_days)
|
|
464
517
|
items.extend(article_items)
|
|
465
518
|
|
|
466
|
-
# Filter new items
|
|
467
519
|
new_items = [item for item in items if item['id'] not in existing_ids]
|
|
468
520
|
print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
|
|
469
521
|
|
|
522
|
+
return _build_embed_and_write(api_key, items, new_items, existing_ids,
|
|
523
|
+
full, days, article_days, shards_failed)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _build_embed_and_write(api_key, items, new_items, existing_ids, full,
|
|
528
|
+
days, article_days, shards_failed):
|
|
529
|
+
"""build_index 的收尾:去重过滤在调用方做完了,这里管嵌入、断点落盘与返回。
|
|
530
|
+
|
|
531
|
+
抽出来是因为聊天半边现在有两条收集路(微信库 / 导入会话),尾巴必须只有一份——
|
|
532
|
+
复制一份的代价是断点续跑、全零批次不落盘这些坑只在一半路径上修过。
|
|
533
|
+
"""
|
|
470
534
|
if not new_items:
|
|
471
535
|
return {"status": "up_to_date", "total": len(items),
|
|
472
536
|
"chatDays": days, "articleDays": article_days,
|
|
@@ -1522,11 +1586,15 @@ def main():
|
|
|
1522
1586
|
p = subparsers.add_parser('build')
|
|
1523
1587
|
p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
|
|
1524
1588
|
p.add_argument('--full', action='store_true', help='全量重建')
|
|
1589
|
+
p.add_argument('--include-imports', action='store_true',
|
|
1590
|
+
help='把 weflow-cli import qchat 导入的 QQ 会话也收进索引(聊天半边;id 带 qq: 段)')
|
|
1525
1591
|
add_window_args(p)
|
|
1526
1592
|
|
|
1527
1593
|
# update
|
|
1528
1594
|
p = subparsers.add_parser('update')
|
|
1529
1595
|
p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
|
|
1596
|
+
p.add_argument('--include-imports', action='store_true',
|
|
1597
|
+
help='同 build:把导入的 QQ 会话收进增量')
|
|
1530
1598
|
add_window_args(p)
|
|
1531
1599
|
|
|
1532
1600
|
# search
|
|
@@ -1582,9 +1650,13 @@ def main():
|
|
|
1582
1650
|
or get_dashscope_key(config))
|
|
1583
1651
|
|
|
1584
1652
|
if args.command == 'build':
|
|
1585
|
-
result = build_index(api_key, full=args.full, days=args.days,
|
|
1653
|
+
result = build_index(api_key, full=args.full, days=args.days,
|
|
1654
|
+
article_days=args.article_days,
|
|
1655
|
+
include_imports=getattr(args, 'include_imports', False))
|
|
1586
1656
|
elif args.command == 'update':
|
|
1587
|
-
result = build_index(api_key, full=False, days=args.days,
|
|
1657
|
+
result = build_index(api_key, full=False, days=args.days,
|
|
1658
|
+
article_days=args.article_days,
|
|
1659
|
+
include_imports=getattr(args, 'include_imports', False))
|
|
1588
1660
|
elif args.command == 'phrase-build':
|
|
1589
1661
|
result = build_phrase_index(line=args.line)
|
|
1590
1662
|
result['success'] = 'error' not in result
|
|
@@ -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())
|
package/src/core/ntCore.ts
CHANGED
|
@@ -75,6 +75,42 @@ export interface NtMessagesResult extends NtResult {
|
|
|
75
75
|
shards?: ShardReport
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** One shard's outcome in a `cursor-probe` run (nt_decrypt.py cursor-probe). */
|
|
79
|
+
export interface NtCursorProbeShard {
|
|
80
|
+
/** basename only - the child never reports a full path */
|
|
81
|
+
name: string
|
|
82
|
+
opened: boolean
|
|
83
|
+
reason: string | null
|
|
84
|
+
/** number of Msg_* tables; null when the shard could not be read at all */
|
|
85
|
+
tables: number | null
|
|
86
|
+
tablesSampled?: number
|
|
87
|
+
/** per-table missing anchor-column sets, deduplicated; empty = every table has all three */
|
|
88
|
+
anchorColumnsMissing?: string[][]
|
|
89
|
+
rowsSampled?: number
|
|
90
|
+
/** sampled rows whose server_id was 0 or NULL (identity degrades for them) */
|
|
91
|
+
serverIdMissing?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NtCursorProbeSample {
|
|
95
|
+
tablesPerShard: number
|
|
96
|
+
rowsPerTable: number
|
|
97
|
+
exhaustive: boolean
|
|
98
|
+
tablesTotal: number
|
|
99
|
+
tablesSampled: number
|
|
100
|
+
rowsSampled: number
|
|
101
|
+
serverIdMissing: number
|
|
102
|
+
/** sampled nonzero server ids seen in more than one (shard, table) location */
|
|
103
|
+
duplicateServerIds: number
|
|
104
|
+
duplicateServerIdSamples?: Array<{ serverId: string; locations: string[] }>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface NtCursorProbeResult extends NtResult {
|
|
108
|
+
shards?: NtCursorProbeShard[]
|
|
109
|
+
/** shards with at least one message table missing an anchor column */
|
|
110
|
+
schemaAnchorMissingTables?: number
|
|
111
|
+
sample?: NtCursorProbeSample
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
export interface NtContactsResult extends NtResult {
|
|
79
115
|
contacts?: Contact[]
|
|
80
116
|
}
|
|
@@ -392,6 +428,29 @@ export class NtCore {
|
|
|
392
428
|
return { success: true, contacts: result.contacts || [] }
|
|
393
429
|
}
|
|
394
430
|
|
|
431
|
+
/**
|
|
432
|
+
* The NT backend's data-side answer to D-027's stable-cursor question:
|
|
433
|
+
* anchor columns per message table (exhaustive) and a bounded sample of
|
|
434
|
+
* `server_id` coverage at both rowid ends of a few tables per shard.
|
|
435
|
+
* Read-only; the sampling bounds are labelled `exhaustive: false` in the
|
|
436
|
+
* result because they are a measurement, not a guarantee.
|
|
437
|
+
*/
|
|
438
|
+
async cursorProbe(opts: { tablesPerShard?: number; rowsPerTable?: number } = {}): Promise<NtCursorProbeResult> {
|
|
439
|
+
const args: string[] = ['cursor-probe']
|
|
440
|
+
if (opts.tablesPerShard != null) args.push('--tables-per-shard', String(opts.tablesPerShard))
|
|
441
|
+
if (opts.rowsPerTable != null) args.push('--rows-per-table', String(opts.rowsPerTable))
|
|
442
|
+
const result = await this.callPython(args)
|
|
443
|
+
if (result.error) {
|
|
444
|
+
return { success: false, error: result.error }
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
success: true,
|
|
448
|
+
shards: result.shards || [],
|
|
449
|
+
schemaAnchorMissingTables: result.schemaAnchorMissingTables,
|
|
450
|
+
sample: result.sample,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
395
454
|
// ====== SNS (朋友圈) ======
|
|
396
455
|
|
|
397
456
|
/** 查询 SNS 朋友圈时间线 */
|