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,68 +1,68 @@
1
- # -*- coding: utf-8 -*-
2
- """读 `weflow-cli import qchat` 落下的存储(output/imports/qq/sessions/)。
3
-
4
- **单一读者**:chat_notes 与 semantic_search 都从这里取导入会话。两边各解析一遍格式,
5
- 漂了就是"知识卡看得见、搜索看不见"这类静默分叉——`nt_common` 存在的同款理由。
6
-
7
- 存储形状(写入方是 `src/services/qchatImport.ts`,字段语义对齐 weflow-message/v1):
8
- 每个会话一个 JSON:schema/session{id,name,type}/importedAt/sourceFiles/messages[],
9
- 消息字段 serverId(=qchat messageId,全局唯一)/createTime(unix 秒)/isSend(可缺省=
10
- 未知)/senderUsername(QQ 号,敏感)/senderName/messageType(13 稳定名)/parsedContent/
11
- rawContent。
12
-
13
- 路径:默认 `output/imports/qq/sessions`(cwd 相对,与 chat_notes 的 OUTPUT_ROOT 同一
14
- 约定);`WEFLOW_IMPORT_ROOT` 指到别处时用 `<root>/sessions`(测试用,TS 写入方读同
15
- 一个变量)。
16
- """
17
- import json
18
- import os
19
- from pathlib import Path
20
-
21
- SCHEMA = 'weflow-import/qq-v1'
22
-
23
-
24
- def sessions_dir(root=None):
25
- if root is None:
26
- root = os.environ.get('WEFLOW_IMPORT_ROOT')
27
- if root:
28
- return Path(root) / 'sessions'
29
- return Path('output/imports/qq/sessions')
30
-
31
-
32
- def sessions(root=None):
33
- """全部已导入会话(按导入时间新→旧)。坏文件/非本 schema 的文件跳过不挡路。
34
-
35
- 返回的是存储原文(dict),消息已按 createTime 升序。
36
- """
37
- d = sessions_dir(root)
38
- if not d.is_dir():
39
- return []
40
- out = []
41
- for p in sorted(d.glob('*.json')):
42
- try:
43
- data = json.loads(p.read_text(encoding='utf-8'))
44
- except Exception:
45
- continue
46
- if not isinstance(data, dict):
47
- continue
48
- if data.get('schema') != SCHEMA or not isinstance(data.get('messages'), list):
49
- continue
50
- out.append(data)
51
- out.sort(key=lambda s: s.get('importedAt') or '', reverse=True)
52
- return out
53
-
54
-
55
- def session_messages(sess, days=None, now=None):
56
- """一个会话的消息,可选按"最近 days 天"过滤。
57
-
58
- 时间语义与 chat_notes/semantic_search 的窗口一致:unix 秒、含当天。
59
- """
60
- from datetime import datetime, timedelta, timezone
61
-
62
- msgs = [m for m in sess.get('messages', []) if isinstance(m, dict)]
63
- if days is None:
64
- return msgs
65
- tz = timezone(timedelta(hours=8))
66
- now = now or datetime.now(tz)
67
- cutoff = int((now - timedelta(days=days)).timestamp())
68
- return [m for m in msgs if (m.get('createTime') or 0) >= cutoff]
1
+ # -*- coding: utf-8 -*-
2
+ """读 `weflow-cli import qchat` 落下的存储(output/imports/qq/sessions/)。
3
+
4
+ **单一读者**:chat_notes 与 semantic_search 都从这里取导入会话。两边各解析一遍格式,
5
+ 漂了就是"知识卡看得见、搜索看不见"这类静默分叉——`nt_common` 存在的同款理由。
6
+
7
+ 存储形状(写入方是 `src/services/qchatImport.ts`,字段语义对齐 weflow-message/v1):
8
+ 每个会话一个 JSON:schema/session{id,name,type}/importedAt/sourceFiles/messages[],
9
+ 消息字段 serverId(=qchat messageId,全局唯一)/createTime(unix 秒)/isSend(可缺省=
10
+ 未知)/senderUsername(QQ 号,敏感)/senderName/messageType(13 稳定名)/parsedContent/
11
+ rawContent。
12
+
13
+ 路径:默认 `output/imports/qq/sessions`(cwd 相对,与 chat_notes 的 OUTPUT_ROOT 同一
14
+ 约定);`WEFLOW_IMPORT_ROOT` 指到别处时用 `<root>/sessions`(测试用,TS 写入方读同
15
+ 一个变量)。
16
+ """
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+
21
+ SCHEMA = 'weflow-import/qq-v1'
22
+
23
+
24
+ def sessions_dir(root=None):
25
+ if root is None:
26
+ root = os.environ.get('WEFLOW_IMPORT_ROOT')
27
+ if root:
28
+ return Path(root) / 'sessions'
29
+ return Path('output/imports/qq/sessions')
30
+
31
+
32
+ def sessions(root=None):
33
+ """全部已导入会话(按导入时间新→旧)。坏文件/非本 schema 的文件跳过不挡路。
34
+
35
+ 返回的是存储原文(dict),消息已按 createTime 升序。
36
+ """
37
+ d = sessions_dir(root)
38
+ if not d.is_dir():
39
+ return []
40
+ out = []
41
+ for p in sorted(d.glob('*.json')):
42
+ try:
43
+ data = json.loads(p.read_text(encoding='utf-8'))
44
+ except Exception:
45
+ continue
46
+ if not isinstance(data, dict):
47
+ continue
48
+ if data.get('schema') != SCHEMA or not isinstance(data.get('messages'), list):
49
+ continue
50
+ out.append(data)
51
+ out.sort(key=lambda s: s.get('importedAt') or '', reverse=True)
52
+ return out
53
+
54
+
55
+ def session_messages(sess, days=None, now=None):
56
+ """一个会话的消息,可选按"最近 days 天"过滤。
57
+
58
+ 时间语义与 chat_notes/semantic_search 的窗口一致:unix 秒、含当天。
59
+ """
60
+ from datetime import datetime, timedelta, timezone
61
+
62
+ msgs = [m for m in sess.get('messages', []) if isinstance(m, dict)]
63
+ if days is None:
64
+ return msgs
65
+ tz = timezone(timedelta(hours=8))
66
+ now = now or datetime.now(tz)
67
+ cutoff = int((now - timedelta(days=days)).timestamp())
68
+ return [m for m in msgs if (m.get('createTime') or 0) >= cutoff]
@@ -1,105 +1,105 @@
1
- # Register / update the two scheduled tasks for the official-account daily report.
2
- # Idempotent: running it again overwrites the definitions with the same values.
3
- #
4
- # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
- # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
- # an elevated Administrator token, all three write paths said "Access is denied" while
7
- # reads kept working:
8
- # schtasks /create ... -> Access is denied
9
- # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
- # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
- # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
- # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
- # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
- # this file from your own PowerShell window.
15
- #
16
- # Usage:
17
- # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
- # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
- #
20
- # The two tasks:
21
- # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
- # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
- #
24
- # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
- # then lines up with the date directory the run writes into, and it does not collide with
26
- # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
- # moment that has the complete day.
28
- #
29
- # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
- # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
- param(
32
- [switch]$Uninstall
33
- )
34
-
35
- $ErrorActionPreference = 'Stop'
36
-
37
- $repo = Split-Path -Parent $PSScriptRoot
38
- $script = Join-Path $repo 'scripts\daily_task.ps1'
39
- if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
-
41
- $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
- [Security.Principal.WindowsIdentity]::GetCurrent())
43
- ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
- if (-not $isAdmin) {
45
- Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
- exit 1
47
- }
48
-
49
- if ($Uninstall) {
50
- foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
- if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
- Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
- Write-Host "removed: $name"
54
- }
55
- }
56
- exit 0
57
- }
58
-
59
- $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
- $psExe = 'powershell.exe'
61
- $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
62
-
63
- # Launch through wscript.exe, not powershell.exe directly.
64
- #
65
- # A scheduled action that runs powershell.exe is a *console* program: Windows creates the console
66
- # window before PowerShell can hide it, so every run flashes a black box - and while a game is in
67
- # the foreground that is a real interruption. wscript.exe is a GUI process with no console at all,
68
- # and run_hidden.vbs starts PowerShell with window style 0, so nothing appears.
69
- $wscript = Join-Path $env:SystemRoot 'System32\wscript.exe'
70
- $vbs = Join-Path $PSScriptRoot 'run_hidden.vbs'
71
- if (-not (Test-Path $vbs)) {
72
- Write-Host "Missing launcher: $vbs" -ForegroundColor Red
73
- exit 1
74
- }
75
- $action = '//B //Nologo "' + $vbs + '"'
76
-
77
- # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
78
- # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
79
- # no run record), so "it will catch up by itself" is not something to rely on.
80
- Register-ScheduledTask -TaskName 'WeFlow Daily' `
81
- -Action (New-ScheduledTaskAction -Execute $wscript -Argument $action) `
82
- -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
83
- (New-ScheduledTaskTrigger -AtLogOn)) `
84
- -Settings $settings `
85
- -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent, no window)' -Force | Out-Null
86
-
87
- # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
88
- # very thin "noon" report and write the .noon_done marker, which would then make the real
89
- # 13:00 run skip itself (the guard keys on that marker).
90
- Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
91
- -Action (New-ScheduledTaskAction -Execute $wscript -Argument ($action + ' noon')) `
92
- -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
93
- -Settings $settings `
94
- -Description 'WeChat daily report for today so far (13:00, no window)' -Force | Out-Null
95
-
96
- foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
97
- $t = Get-ScheduledTask -TaskName $name
98
- $i = Get-ScheduledTaskInfo -TaskName $name
99
- Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
100
- }
101
- Write-Host ''
102
- Write-Host 'Run one now instead of waiting:'
103
- Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
104
- Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
105
- Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
1
+ # Register / update the two scheduled tasks for the official-account daily report.
2
+ # Idempotent: running it again overwrites the definitions with the same values.
3
+ #
4
+ # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
+ # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
+ # an elevated Administrator token, all three write paths said "Access is denied" while
7
+ # reads kept working:
8
+ # schtasks /create ... -> Access is denied
9
+ # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
+ # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
+ # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
+ # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
+ # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
+ # this file from your own PowerShell window.
15
+ #
16
+ # Usage:
17
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
+ #
20
+ # The two tasks:
21
+ # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
+ # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
+ #
24
+ # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
+ # then lines up with the date directory the run writes into, and it does not collide with
26
+ # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
+ # moment that has the complete day.
28
+ #
29
+ # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
+ # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
+ param(
32
+ [switch]$Uninstall
33
+ )
34
+
35
+ $ErrorActionPreference = 'Stop'
36
+
37
+ $repo = Split-Path -Parent $PSScriptRoot
38
+ $script = Join-Path $repo 'scripts\daily_task.ps1'
39
+ if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
+
41
+ $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
+ [Security.Principal.WindowsIdentity]::GetCurrent())
43
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
+ if (-not $isAdmin) {
45
+ Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
+ exit 1
47
+ }
48
+
49
+ if ($Uninstall) {
50
+ foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
+ if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
+ Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
+ Write-Host "removed: $name"
54
+ }
55
+ }
56
+ exit 0
57
+ }
58
+
59
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
+ $psExe = 'powershell.exe'
61
+ $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
62
+
63
+ # Launch through wscript.exe, not powershell.exe directly.
64
+ #
65
+ # A scheduled action that runs powershell.exe is a *console* program: Windows creates the console
66
+ # window before PowerShell can hide it, so every run flashes a black box - and while a game is in
67
+ # the foreground that is a real interruption. wscript.exe is a GUI process with no console at all,
68
+ # and run_hidden.vbs starts PowerShell with window style 0, so nothing appears.
69
+ $wscript = Join-Path $env:SystemRoot 'System32\wscript.exe'
70
+ $vbs = Join-Path $PSScriptRoot 'run_hidden.vbs'
71
+ if (-not (Test-Path $vbs)) {
72
+ Write-Host "Missing launcher: $vbs" -ForegroundColor Red
73
+ exit 1
74
+ }
75
+ $action = '//B //Nologo "' + $vbs + '"'
76
+
77
+ # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
78
+ # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
79
+ # no run record), so "it will catch up by itself" is not something to rely on.
80
+ Register-ScheduledTask -TaskName 'WeFlow Daily' `
81
+ -Action (New-ScheduledTaskAction -Execute $wscript -Argument $action) `
82
+ -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
83
+ (New-ScheduledTaskTrigger -AtLogOn)) `
84
+ -Settings $settings `
85
+ -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent, no window)' -Force | Out-Null
86
+
87
+ # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
88
+ # very thin "noon" report and write the .noon_done marker, which would then make the real
89
+ # 13:00 run skip itself (the guard keys on that marker).
90
+ Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
91
+ -Action (New-ScheduledTaskAction -Execute $wscript -Argument ($action + ' noon')) `
92
+ -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
93
+ -Settings $settings `
94
+ -Description 'WeChat daily report for today so far (13:00, no window)' -Force | Out-Null
95
+
96
+ foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
97
+ $t = Get-ScheduledTask -TaskName $name
98
+ $i = Get-ScheduledTaskInfo -TaskName $name
99
+ Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
100
+ }
101
+ Write-Host ''
102
+ Write-Host 'Run one now instead of waiting:'
103
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
104
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
105
+ Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 密钥缓存层 - 参考 SIWX keystore.py 设计
4
+ 按 salt 索引缓存密钥,Windows 用 DPAPI 加密,其他平台用文件权限保护
5
+ """
6
+ import os
7
+ import sys
8
+ import json
9
+ import hashlib
10
+ from pathlib import Path
11
+
12
+ IS_WINDOWS = os.name == 'nt'
13
+
14
+ if IS_WINDOWS:
15
+ try:
16
+ import win32crypt
17
+ HAS_WIN32CRYPT = True
18
+ except ImportError:
19
+ HAS_WIN32CRYPT = False
20
+ else:
21
+ HAS_WIN32CRYPT = False
22
+
23
+
24
+ def get_cache_dir():
25
+ """获取密钥缓存目录"""
26
+ if IS_WINDOWS:
27
+ base = os.environ.get('USERPROFILE', os.path.expanduser('~'))
28
+ else:
29
+ base = os.path.expanduser('~')
30
+
31
+ cache_dir = os.path.join(base, '.weflow-cli', 'keys')
32
+ os.makedirs(cache_dir, exist_ok=True)
33
+
34
+ # 非 Windows 平台设置严格权限
35
+ if not IS_WINDOWS:
36
+ try:
37
+ os.chmod(cache_dir, 0o700)
38
+ except Exception as e:
39
+ # chmod 失败(文件系统不支持权限)不影响功能
40
+ pass
41
+
42
+ return cache_dir
43
+
44
+
45
+ def _encrypt_data(data_bytes):
46
+ """加密数据(Windows DPAPI / 其他平台 XOR)"""
47
+ if IS_WINDOWS and HAS_WIN32CRYPT:
48
+ try:
49
+ encrypted = win32crypt.CryptProtectData(data_bytes, None, None, None, None, 0)
50
+ return encrypted
51
+ except Exception as e:
52
+ # DPAPI 失败(用户上下文问题),降级到 XOR
53
+ pass
54
+
55
+ # Fallback: 简单 XOR(依赖文件权限保护)
56
+ key = hashlib.sha256(os.urandom(16)).digest()
57
+ result = bytearray(key) # 前 32 字节是 key
58
+ for i, b in enumerate(data_bytes):
59
+ result.append(b ^ key[i % len(key)])
60
+ return bytes(result)
61
+
62
+
63
+ def _decrypt_data(encrypted_bytes):
64
+ """解密数据(Windows DPAPI / 其他平台 XOR)"""
65
+ if IS_WINDOWS and HAS_WIN32CRYPT:
66
+ try:
67
+ _, decrypted = win32crypt.CryptUnprotectData(encrypted_bytes, None, None, None, 0)
68
+ return decrypted
69
+ except Exception as e:
70
+ # DPAPI 解密失败(用户上下文变更、数据损坏),降级到 XOR
71
+ pass
72
+
73
+ # Fallback: XOR 解密
74
+ if len(encrypted_bytes) < 32:
75
+ return None
76
+ key = encrypted_bytes[:32]
77
+ encrypted = encrypted_bytes[32:]
78
+ result = bytearray()
79
+ for i, b in enumerate(encrypted):
80
+ result.append(b ^ key[i % len(key)])
81
+ return bytes(result)
82
+
83
+
84
+ class KeyCache:
85
+ """密钥缓存(按 salt 索引)"""
86
+
87
+ def __init__(self):
88
+ self.cache_dir = get_cache_dir()
89
+ self.cache_file = os.path.join(self.cache_dir, 'keys.enc')
90
+ self._cache = {}
91
+ self._load()
92
+
93
+ def _load(self):
94
+ """从磁盘加载缓存"""
95
+ if not os.path.exists(self.cache_file):
96
+ return
97
+
98
+ try:
99
+ with open(self.cache_file, 'rb') as f:
100
+ encrypted = f.read()
101
+
102
+ decrypted = _decrypt_data(encrypted)
103
+ if decrypted:
104
+ data = json.loads(decrypted.decode('utf-8'))
105
+ self._cache = data.get('keys', {})
106
+ except Exception as e:
107
+ # 损坏或无法解密(格式变更、平台迁移),重置缓存
108
+ self._cache = {}
109
+
110
+ def _save(self):
111
+ """保存缓存到磁盘"""
112
+ try:
113
+ data = {'keys': self._cache}
114
+ plain = json.dumps(data, ensure_ascii=False).encode('utf-8')
115
+ encrypted = _encrypt_data(plain)
116
+
117
+ with open(self.cache_file, 'wb') as f:
118
+ f.write(encrypted)
119
+
120
+ # 非 Windows 平台设置严格权限
121
+ if not IS_WINDOWS:
122
+ try:
123
+ os.chmod(self.cache_file, 0o600)
124
+ except Exception as e:
125
+ # chmod 失败不影响功能
126
+ pass
127
+ except Exception as e:
128
+ # 保存失败(磁盘满、权限问题)不影响内存缓存
129
+ pass
130
+
131
+ def get(self, salt_hex):
132
+ """获取密钥(按 salt 索引)
133
+
134
+ Args:
135
+ salt_hex: 32 字符十六进制 salt
136
+
137
+ Returns:
138
+ 64 字符十六进制 key,未找到返回 None
139
+ """
140
+ return self._cache.get(salt_hex.lower())
141
+
142
+ def put(self, salt_hex, key_hex):
143
+ """存储密钥
144
+
145
+ Args:
146
+ salt_hex: 32 字符十六进制 salt
147
+ key_hex: 64 字符十六进制 key
148
+ """
149
+ self._cache[salt_hex.lower()] = key_hex.lower()
150
+ self._save()
151
+
152
+ def put_batch(self, keys):
153
+ """批量存储密钥
154
+
155
+ Args:
156
+ keys: list of {"salt": str, "key": str}
157
+ """
158
+ changed = False
159
+ for item in keys:
160
+ salt = item.get('salt', '').lower()
161
+ key = item.get('key', '').lower()
162
+ if salt and key and len(salt) == 32 and len(key) == 64:
163
+ if self._cache.get(salt) != key:
164
+ self._cache[salt] = key
165
+ changed = True
166
+
167
+ if changed:
168
+ self._save()
169
+
170
+ def clear(self):
171
+ """清空缓存"""
172
+ self._cache = {}
173
+ if os.path.exists(self.cache_file):
174
+ try:
175
+ os.remove(self.cache_file)
176
+ except Exception as e:
177
+ # 删除失败不影响内存清空
178
+ pass
179
+
180
+ def list_keys(self):
181
+ """列出所有缓存的密钥(脱敏)
182
+
183
+ Returns:
184
+ list of {"salt": str, "key_prefix": str}
185
+ """
186
+ result = []
187
+ for salt, key in self._cache.items():
188
+ result.append({
189
+ 'salt': salt,
190
+ 'key_prefix': key[:16] + '...' if len(key) > 16 else key
191
+ })
192
+ return result
193
+
194
+
195
+ # 全局单例
196
+ _global_cache = None
197
+
198
+ def get_key_cache():
199
+ """获取全局密钥缓存实例"""
200
+ global _global_cache
201
+ if _global_cache is None:
202
+ _global_cache = KeyCache()
203
+ return _global_cache
204
+
205
+
206
+ if __name__ == '__main__':
207
+ # 简单测试
208
+ import sys
209
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
210
+
211
+ cache = KeyCache()
212
+
213
+ # 测试存取
214
+ test_salt = 'a' * 32
215
+ test_key = 'b' * 64
216
+
217
+ cache.put(test_salt, test_key)
218
+ retrieved = cache.get(test_salt)
219
+
220
+ print(f"存储测试: {'✓' if retrieved == test_key else '✗'}")
221
+ print(f"Salt: {test_salt[:16]}...")
222
+ print(f"Key: {retrieved[:16] if retrieved else 'None'}...")
223
+
224
+ # 测试列表
225
+ keys = cache.list_keys()
226
+ print(f"\n缓存数量: {len(keys)}")
227
+ for item in keys[:3]:
228
+ print(f" {item['salt'][:16]}... → {item['key_prefix']}")
229
+
230
+ # 清理测试数据
231
+ cache.clear()
232
+ print(f"\n清空后数量: {len(cache.list_keys())}")