Files
2026-08-27 14:04:28 +08:00

222 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-08-21 工作日志
## 企业微信自动回复第二套方案(引擎 B 数据直读)落地
### 背景
用户确认 4 个拍板点:发送层 GUI 复用、数据来源两条线并行、机器人对接复用 ai_chat、互斥共存。
### 新建文件
- `send_lock.py`:发送互斥锁。Windows 命名互斥量(`Global\WeChatBotSendLock`)+ 线程持有者跟踪(防同线程重入);非 Windows 回退 threading.Lock。`try_acquire(owner, timeout)` / `release()` / `lock()` 上下文管理器。
- `engine_b.py`:引擎 B(数据直读)。`DataEngine` 类独立线程轮询 `conversations.json`,按 history 增量发现新客户消息,经 `bot.enqueue_detected()` 投递共享队列。启动时 120s 内新消息算新鲜可投,防抖窗口 300s。纯检测纯投递,不碰窗口。
- `test_dual_engine.py`:双引擎集成测试(31 项断言),覆盖互斥锁/投递/字段透传/去重/合并/DataEngine 集成。回归命令:系统 Python 3.11 跑(wechat_bot 依赖 win32gui,managed python 无此包)。
### 修改文件
- `wechat_bot.py`:
- 队列条目新增 5 字段:`dedup_key`/`detected_by`/`detection_ts`/`sending_owner`/`send_lock_ts`,在 `_load_pending_replies` 与 `_persist_pending_replies_unlocked` 两处白名单透传(旧条目自动补默认值,44 字段兼容验证通过)。
- `send_reply()` 改为互斥包装:`try_acquire` 拿不到锁 → `_deny_send("另一引擎正在发送")`;原主体改名 `_send_reply_inner`。
- 新增 `enqueue_detected()`(B 引擎投递,复用 `_mark_reply_pending` + 补新字段 + display_name 兜底)、`has_active_pending()`(终态集合判断去重)、`merge_detected_by()`、`_merge_engine_tags()`。
- `gui_runtime.py`:`BotThread.__init__` 加 `enable_engine_b`/`engine_b_poll_interval`;`run()` 中 bot connect 成功后启动 DataEngine 线程,finally/stop 中停止。
- `wechat_gui_qt.py` / `wechat_gui.py`:runtime 默认值加 `enable_engine_b=True`、`engine_b_poll_interval=2.0`,start_monitoring 传参。
- `app_settings.json`:加 `enable_engine_b: true`、`engine_b_poll_interval: 2.0`。
### 关键架构决策
- **B 引擎不自己发送**:队列条目由引擎 A 的 `_resume_orphaned_pending_reply` 统一捡起(重新定位→视觉提取→`_generate_ai_reply`→`send_reply`)。发送天然收敛到唯一通道 + 互斥锁,B 全程不碰窗口。这是"检测解耦、发送收敛"的最稳落地。
- 检测到 A 已处理的会话时 B 只补 detected_by 标记,不重复投递。
- 队列字段是白名单读写,新增字段必须在 load/persist 两处透传,否则 B 引擎的标记会被 A 持久化时抹掉。
### 验证
- `test_dual_engine.py`:31 项全部通过。
- `test_runtime_settings.py` 18 项、`test_session_name.py` 67 项回归通过。
- 旧 pending_replies.json 条目加载兼容(新字段自动补默认值)。
---
## Phase 2:企微本地库解密(密钥提取攻坚)
### 关键发现(本轮)
1. **真正的 bug 根因**:`wxwork_key.py::_prep_verify` 的验证基准构造错误——读了文件 offset 16..32(含明文头),正确应为 `8..16 密文A + 24..32 密文B`(与 `wxwork_crypto.decrypt_page1_block` 一致)。修复后探针测试从"0 命中"变为"命中"。
2. **算法确认**:`WXWork.exe`(269MB,`D:\soft\WXWork\`,版本 5.0.10.6015)代码段含 `sAlT` 常量 + MD5 初始常量 `0x67452301` 的机器码(`mov [ebp-0xC],0x546C4173` / `mov [ebp-0x7C],0x67452301`)→ 证实 wxSQLite3 aes128cbc MD5+sAlT 派生算法判断正确。
3. **扫描器性能优化**:`regions(rw_only=True)` 只扫可写区 + `len(set(w))>=8` 字节多样性预过滤 + `_fast_verify` 单变体优先验证(AES 3→1 次)。探针扫描 87.7s → 3.4s。
4. **扫描覆盖全部**:16/32 字节窗口 × 16/8 字节对齐 × 全部企微进程(含 WeChatAppEx.exe 8 实例、WXDrive_x64.exe、WXWorkXNet.exe),3 轮全 0 命中 → **结论:企微 5.0.10 数据库密钥不在常驻可读内存**(用完即焚/受保护页/混淆存储)。
5. 当前进程非管理员权限(IsUserAnAdmin=False)。
### 新建/修改文件
- `wxwork_key.py`:修复 `_prep_verify`;`regions()` 加 `rw_only` 参数;`scan_binary_single` 加 `key_len`/`step` 参数;`scan_binary_all` 支持 step;`find_wxwork_pids` 加 `include_extra`(WeChatAppEx/WXDrive);CLI 加 `--binary8`(8 字节对齐)。
- `test_scan_probe.py`:修复子进程 code 路径 `\U` 转义(用 `!r`),加子进程存活检查;改为 aligned 优先。
- `test_scan_probe_mis.py`(新):非对齐密钥探针,验证全滑窗兜底。
- `scan_golden_window.py`(新):重启企微黄金窗口多轮扫描脚本(`--kill` 需用户确认)。
- `probe_wxwork_db.py`:已就绪(等密钥到手探查 message.db 表结构)。
### 下一步(待用户决策)
- A. 黄金窗口扫描:重启企微后立即多轮扫(`scan_golden_window.py`,需用户同意重启)
- B. 管理员权限重跑全部扫描
- C. hook sqlite3_key(看雪方案,注入拦截,开发量大)
- D. 换会话存档 SDK 路线(`Downloads\wxwork-finance-sdk-main`,企业后台 API)
---
## Phase 2 落地:参考目录代码 → DB 直读(密钥已验证,主线打通)
### 背景
用户提供参考目录 `C:\Users\pc\WorkBuddy\2026-08-19-18-27-34\`(企业微信导出助手,密钥已验证),要求以它为基准实现 DB 直读,保留密钥逻辑、复用可靠模块。
### 参考目录关键资产(已逐文件分析)
- `wxwork_keys.json`:2 个账号验证过密钥(`1688856770803435`/`db7288a302bfbe66563d81f025bde977`、`1688855555743233`/`d18e7f9a222665389989066f929ece19`)→ **密钥来源问题解决,内存提取不再必要**
- `wxwork_crypto.py`:解密算法与本项目 `wxwork_crypto.py` 完全一致(互相印证)
- `wxwork_find_key.py`:7 种内存密钥提取模式(参考项目曾在运行中企微成功提取,但本项目 5.0.10 提取不到)
- `wxwork_export_final.py`:`parse_content`(UTF-8→protobuf 递归→JSON→hex)、`MSG_TYPE_MAP`、`is_personal_chat`(M:/S: 白名单)、`clean_text`、`format_timestamp`、`load_keys`(支持 `*` 通配)、`decrypt_with_keys`(WAL 感知增量缓存)、`detect_wxwork_dir`、会话名解析
- `wxwork_export_db.py`:`collect_metadata`(user_table/conversation_table → user_cache/conv_cache)
- `crm_import/decrypted/`:9 账号目录全空 → 印证仅 2 账号有密钥
### 新建/修改文件
- `wxwork_keys.json`(新):复制参考密钥,含 note 说明"每账号独立密钥,未列出账号未登录"
- `wxwork_db.py`(重写):
- 移植参考全套:`load_keys`/`decrypt_with_keys`/`parse_content`/`extract_protobuf_texts`/`clean_text`/`MSG_TYPE_MAP`/`get_msg_type_name`/`is_personal_chat`/`is_blocked_conv_name`/`format_timestamp`/`detect_wxwork_dir`/`connect_sqlite`/会话名解析(user_cache/conv_cache/S: 对端推导)
- **WAL 帧合并增强**(参考实现没有):企微运行时新消息在 message.db-wal,参考只靠 wal_mtime 触发重解密主库(仍缺 WAL 内消息);本项目直接解密 WAL 帧(同算法、帧头明文、salt 匹配过滤、后帧覆盖)按页号合并进主库副本,更新 SQLite 头页数字段,`PRAGMA quick_check` 验证(`test_wal_merge.py`)
- **会话指纹增强**:`session_fp_from_name` 用与 `wechat_bot._fp_from_name` 完全一致的算法(md5+blake2b,40 字节)→ DB 直读 fp_hex 与引擎 A identity_by_name 模式同指纹空间
- `normalize_name` 仅标准库实现(NFKC+去空白),避免 import session_name(其顶层依赖 numpy)
- `WXWorkDB` 类:构造时解密缓存+元数据,`get_new_messages(since_ts)` 秒级 send_time 增量查询,`_refresh_if_needed` 按 mtime 自动重解密
- `engine_b.py`(改造):`DataEngine` 加 `db_source` 参数(WXWorkDB 实例);`_run` 有 DB 则 `_poll_db_once`;DB 直读失败自动回退 `poll_once`(conversations.json),下轮自动重试恢复;DB 路径**按 fp_hex 聚合**只投递每会话最后一条(防历史逐条误投);初始游标 = now-120s(与启动新鲜窗口一致);`_process_entry` 提取为两条路径共用
### 验证结果
- `wxwork_db.py` 冒烟:2 账号可读(`1688856770803435`+`1688855555743233`)、1041 条单聊消息、health_check 通过
- 增量查询命中真实消息:今天 11:06:05「一个小迷糊: 在不」
- 指纹一致性:`' 高瑞 @微信 '` 的 fp 前 16 字节与 conversations.json 档案 key 完全一致 → 引擎 A 正运行在 identity_by_name 模式
- WAL 合并:quick_check ok(当前 WAL 无更新帧,机制已验证)
- 引擎 B 真实集成:FakeDB 聚合单测通过(多历史只投最新一条/self 过滤/去重/降级回退)
- `test_dual_engine.py`:31 项全部通过(系统 Python 3.11 跑)
- 密钥匹配验证:仅 2 账号可解密,其余 9 账号无密钥(与参考项目空目录印证)
### 关键结论
- 密钥确认在主库页 1 可验证(`verify_key` 0.03s),企微运行时 WAL 帧同算法加密可合并 → 实时性方案成立
- DB 直读 fp_hex 与引擎 A 档案同指纹空间 → 投递后可被引擎 A 无缝定位窗口回复
- 回归命令:managed venv(`C:\Users\pc\.workbuddy\binaries\python\envs\default\Scripts\python.exe`)跑 wxwork_db/engine_b;系统 Python 3.11 跑 test_dual_engine(wechat_bot 依赖 win32gui)
---
## 引擎 B 数据源改为「两路并行双跑」(用户拍板)
### 背景
用户质疑"是否有两套方案兜底,而不是合到一起了"。原实现 DB 与 JSON 是**主备二选一**(`_run` 有 DB 只跑 `_poll_db_once`,DB 异常才 `return self.poll_once()` 降级)——DB 正常时 JSON 路径完全不执行,存在单点盲区。用户选择改为**并行双跑**。
### 修改(engine_b.py)
- `_run`:每轮无条件调用 `_poll_db_once()` + `poll_once()`(两路独立检测,互为兜底)
- 游标分离:新增 `_seen_db`(DB 路径会话游标),`_seen` 只归 JSON 路径;`_process_entry` 加 `seen` 参数(DB 传 `_seen_db`,JSON 用默认),两路独立建档/判定、互不污染
- `_poll_db_once`:去掉降级 return,DB 失败仅 `db_active=False` 并打印,JSON 路径不受影响
- 去重统一走 `_maybe_enqueue` 的 dedup_key(fp_hex:时间戳,300s 防抖 + 队列级去重)——同一条消息两路都发现也只投一次
### 验证
- 引擎 B 冒烟通过;并行单测全部断言通过:同消息只投一次 / 两路各自增量 / 游标独立 / DB 崩溃不影响 JSON
- 真实 DB 并行集成:db_active=True、0 重复投递
- `test_dual_engine.py`:31 项全过
### 架构语义(最终形态)
引擎 A(截图 RPA)与引擎 B(数据直读)两套独立检测通道;引擎 B 内部 DB 直读 + conversations.json **并行双跑**互为兜底;发送统一收敛引擎 A send_reply + send_lock 互斥。
---
## GUI 引擎切换卡片 + 全量回归(交付)
### GUI 改动(wechat_gui_qt.py)
- `DashboardPage` 加「检测引擎」卡片:引擎 A 开关(`engine_a_check`)、引擎 B 开关(`engine_b_check`)、引擎 B 数据源三选一(`QRadioButton` 并行双跑/仅DB/仅JSON);引擎 B 关闭时数据源单选组置灰(`_sync_engine_b_source_enabled`)
- 信号:复用已有 `settingsChanged` 信号 → `MainWindow._on_dashboard_engine_settings`(合并进 runtime_settings + 持久化)
- 键名统一为 **`engine_a_enabled`**(注意不是 `enable_engine_a`!BotThread 参数、_runtime_defaults、_load_runtime_settings 校验、start_monitoring 传参、DashboardPage 读写全部用 `engine_a_enabled`);`enable_engine_b`、`engine_b_data_source` 不变
- `_load_runtime_settings` 校验:`engine_a_enabled`/`enable_engine_b` 转 bool、`engine_b_data_source` 白名单(非法回落 parallel)
### 运行时改动(gui_runtime.py)
- `BotThread.__init__` 加 `engine_a_enabled=True`、`engine_b_data_source="parallel"`(含合法性回落)
- 引擎 B 启动段:按数据源模式决定是否接 DB(parallel/db 才初始化 WXWorkDB,失败仅记日志 db_source=None)
- 主循环:`bot._poll_once(scan_unread=self.engine_a_enabled)`(引擎 A 关闭→跳过红点扫描,只留队列恢复,B 投递仍能发送)
### 修复的真实 bug
- `engine_b.py::_poll_db_once`:DB 故障恢复后若首轮查询为空,`db_active` 永不回 True(`if not msgs: return` 跳过置位)→ 查询成功即置 `db_active=True`(空返回前)
### 验证
- 语法:5 核心模块 py_compile 通过
- GUI 卡片冒烟(`tmp/qt_engine_card_smoke.py`,offscreen):18 项全过(初始态/信号键集合/radio 置灰联动/设置加载校验/合并保存持久化)
- 引擎 B 三模式单测(`tmp/test_engine_b_modes.py`):14 项全过(json 只跑 JSON、db 只跑 DB、db 无源退化 JSON、parallel 双跑、DB 异常隔离+恢复、非法模式回落)
- 回归:`test_dual_engine.py` 31 项、`test_runtime_settings.py` 18 项、`test_qt_console_bridge.py` 14 项、`test_qt_capsule.py` 19 项全过
- **测试适配**:`test_runtime_settings.py` 的 mock `poll_once()` 改为 `poll_once(scan_unread=True)`(gui_runtime 新调用契约)
### 运行语义提示
- GUI 改引擎开关:运行中修改→保存文件,**本轮监听结束后重启才完全生效**(BotThread 启动时读取)
- 引擎 A 关闭 ≠ 不回复:B 投递的消息由 A 的队列恢复通道照常定位发送,只是不再截图扫红点
---
## 修复:视觉定位"卡死"(bot 线程假死)
### 现象
用户截图显示任务卡在"正在视觉定位"(opening),日志在 `_find_next_unread_session` 检测到总未读后完全中断,bot 线程约 4-5 分钟无响应。
### 根因
`_scroll_session_list_page` 滚动到底后,由于时间戳/动画等微小像素差异,`_session_page_signature` 认为页面不同,返回 `after` 而非 `None`。导致:
- `_find_pending_session` / `_find_next_unread_session` 持续翻页扫描 240 页(`SESSION_SCAN_MAX_PAGES`)或 45 秒超时
- 4 个 pending 恢复任务 × 45 秒 = 3 分钟+/轮
- 每轮结束后冷却期短(`SESSION_SCAN_COOLDOWN_SECONDS=15s`,失败按 `2^failures` 退避),任务反复被扫描
- 日志长时间无输出,GUI 状态停滞,用户感知为"卡死"
### 修复(wechat_bot.py)
1. `_session_pages_overlap_at_shift`:放宽 `shift` 边界检查从 `<=0` 到 `<0`,允许 `shift=0` 检测(同页内容完全匹配)
2. 新增 `_any_scroll_overlap(before, after)`:在 `0 ~ 2*item_h` 范围内遍历合理 shift,检测是否存在 >=90% 的内容重叠
3. `_scroll_session_list_page`:签名比较后增加 `_any_scroll_overlap` 兜底——如果签名不同但无有效滚动重叠,返回 `None`(认为到底),阻止无效翻页
### 验证
- 语法 py_compile 通过
- `test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项全过
- `tmp/test_scroll_deadlock_fix.py`:
- 同页 shift=0 返回 True ✓
- 时间戳微差(签名不同但无有效滚动)→ `_any_scroll_overlap` 返回 True → `_scroll_session_list_page` 会返回 None ✓
- 真实滚动 72px → `_any_scroll_overlap` 返回 True,滚动仍被识别 ✓
### 影响
- 修复后:滚动到底立即返回 None,不再做 45 秒无效扫描
- 极少量场景(动画/加载导致大面积像素变化)可能提前结束扫描,遗漏极底部未读——但相比卡死 3 分钟+,可接受;下一轮轮询会重新扫描
---
## 修复:队列任务永远"处理中"(快速放弃机制)
### 现象
用户截图:3 个任务(一个小迷糊@微信/在线问诊/高瑞)全部 stage=opening、resume_failures=7/3/2,监听停止后 GUI 仍显示"处理中"。用户诉求:AI 回复完成就应移除任务,除非有新消息。
### 根因
- 发送成功(sent)任务本就会 `_clear_reply_pending` 移除——但**卡在 opening 的任务没有快速放弃机制**
- `_UNREACHABLE_RESUME_FAILURES=8`:纯定位任务要失败 8 次才丢弃,加上 `2^failures` 指数退避(最长 600s),最坏情况任务挂几小时
- 已生成 AI 回复(staged_reply_text 非空)却发不出去的任务,回复有时效性,继续挂着毫无意义
### 修复(wechat_bot.py)
1. 新增 `_STAGED_REPLY_EXPIRE_FAILURES = 3`:有 staged_reply_text 的任务失败 3 次即放弃(回复未发出、不落档,客户新消息会重新建单)
2. `_expire_unreachable_pending` 按是否有 staged_reply_text 选阈值:有回复→3,纯定位→8(保留原语义,防企微暂未开误删)
3. 放弃时打印区分原因(有过时回复 / 找不到会话)
### 验证
- 语法 py_compile 通过
- `tmp/test_pending_expire.py` 7 项全过(staged 3 次放弃/2 次保留、纯定位 8/7 边界、放弃不落档、非法值与空白回复边界)
- 回归:`test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项、`test_runtime_settings.py` 18 项全过
### 用户队列现状(修复后重启监听即生效)
- 一个小迷糊@微信(failures=7,有回复)→ 下次失败即达 3 → 放弃
- 在线问诊(failures=3)→ 立即达阈值 → 放弃
- 高瑞(failures=2)→ 再失败 1 次 → 放弃
---
## 任务池闭环验证(发送成功移除 + 新消息重新投递)
用户确认诉求:AI 回复成功→任务移除;对方新消息→算新任务重新入队,任务池顺畅。
### 闭环语义(全部已确认成立)
- **发送成功(sent)→ `_clear_reply_pending` 移除任务**(`_resume_orphaned_pending_reply` 发送对账路径)
- **新消息 = 新 dedup_key**:`dedup_key = fp_hex:消息ts`,DB 消息在 `_poll_db_once` 归一化为 `{"content", "ts"}`(ts=send_time),新消息 ts 不同 → 不撞防抖
- **`_recently` 300s 防抖只挡同一条消息**(同 dedup_key),新消息不受影响
- **`has_active_pending` 对终态(sent/cancelled/expired/cleared)返回 False** → 任务移除后引擎 B 可重新投递
- **队列已有活跃任务时引擎 B 只补 detected_by 标记不重复投递**
### 验证
- `tmp/test_queue_loop.py` 10 项全过:投递→防抖→发送移除→新消息重新投递→二次防抖→活跃不重复
- 注意:测试数据字段用 `ts`(不是 `send_time`),`_msg_ts` 读 `msg["ts"]`
- 回归:`test_dual_engine.py` 31、`test_engine_b_modes.py` 14、`test_pending_expire.py` 7 全过