18 KiB
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()。
- 队列条目新增 5 字段:
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.py18 项、test_session_name.py67 项回归通过。- 旧 pending_replies.json 条目加载兼容(新字段自动补默认值)。
Phase 2:企微本地库解密(密钥提取攻坚)
关键发现(本轮)
- 真正的 bug 根因:
wxwork_key.py::_prep_verify的验证基准构造错误——读了文件 offset 16..32(含明文头),正确应为8..16 密文A + 24..32 密文B(与wxwork_crypto.decrypt_page1_block一致)。修复后探针测试从"0 命中"变为"命中"。 - 算法确认:
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 派生算法判断正确。 - 扫描器性能优化:
regions(rw_only=True)只扫可写区 +len(set(w))>=8字节多样性预过滤 +_fast_verify单变体优先验证(AES 3→1 次)。探针扫描 87.7s → 3.4s。 - 扫描覆盖全部:16/32 字节窗口 × 16/8 字节对齐 × 全部企微进程(含 WeChatAppEx.exe 8 实例、WXDrive_x64.exe、WXWorkXNet.exe),3 轮全 0 命中 → 结论:企微 5.0.10 数据库密钥不在常驻可读内存(用完即焚/受保护页/混淆存储)。
- 当前进程非管理员权限(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_key0.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.py31 项、test_runtime_settings.py18 项、test_qt_console_bridge.py14 项、test_qt_capsule.py19 项全过 - 测试适配:
test_runtime_settings.py的 mockpoll_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)
_session_pages_overlap_at_shift:放宽shift边界检查从<=0到<0,允许shift=0检测(同页内容完全匹配)- 新增
_any_scroll_overlap(before, after):在0 ~ 2*item_h范围内遍历合理 shift,检测是否存在 >=90% 的内容重叠 _scroll_session_list_page:签名比较后增加_any_scroll_overlap兜底——如果签名不同但无有效滚动重叠,返回None(认为到底),阻止无效翻页
验证
- 语法 py_compile 通过
test_dual_engine.py31 项、test_engine_b_modes.py14 项全过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)
- 新增
_STAGED_REPLY_EXPIRE_FAILURES = 3:有 staged_reply_text 的任务失败 3 次即放弃(回复未发出、不落档,客户新消息会重新建单) _expire_unreachable_pending按是否有 staged_reply_text 选阈值:有回复→3,纯定位→8(保留原语义,防企微暂未开误删)- 放弃时打印区分原因(有过时回复 / 找不到会话)
验证
- 语法 py_compile 通过
tmp/test_pending_expire.py7 项全过(staged 3 次放弃/2 次保留、纯定位 8/7 边界、放弃不落档、非法值与空白回复边界)- 回归:
test_dual_engine.py31 项、test_engine_b_modes.py14 项、test_runtime_settings.py18 项全过
用户队列现状(修复后重启监听即生效)
- 一个小迷糊@微信(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 不同 → 不撞防抖 _recently300s 防抖只挡同一条消息(同 dedup_key),新消息不受影响has_active_pending对终态(sent/cancelled/expired/cleared)返回 False → 任务移除后引擎 B 可重新投递- 队列已有活跃任务时引擎 B 只补 detected_by 标记不重复投递
验证
tmp/test_queue_loop.py10 项全过:投递→防抖→发送移除→新消息重新投递→二次防抖→活跃不重复- 注意:测试数据字段用
ts(不是send_time),_msg_ts读msg["ts"] - 回归:
test_dual_engine.py31、test_engine_b_modes.py14、test_pending_expire.py7 全过