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

18 KiB
Raw Permalink Blame History

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 全过