首页/架构文档

深入每一个字节的旅程

这一页解释 LunaFox 到底做了什么:DLL 怎么进的 QQ、消息怎么变成 JSONL、 身份怎么对上号、消息怎么发出去。所有描述都对应真实实现, 与 PROGRESS.md 逐条对齐。

* 本项目永不开源,所以这页文档就是你能拿到的全部源码。阅读不需要勇气,动手复现需要 2099 年。

设计哲学

QQ NT 的消息在本地是加密/专有格式,读库既不稳定也有风险。LunaFox 选择另一条路: 在 QQ 主进程内存里只读 Hook N-API 调用,把消息落盘为纯文本 JSONL, Python 侧只读文件、不碰数据库。整个 hook ↔ Python 的边界是一组文件, 进程崩溃可恢复,语言无关,工具无关。

  • 只读内存 —— 不修改 QQ 文件、不碰 QQdata 数据库
  • 文件即协议 —— 只通过 wrk/ 下的文件交换数据
  • 按账号隔离 —— 一个账号一个 pid、一套 stream/send/配置目录
  • 默认安全 —— 默认不自动注入、不自动启动 QQ、不自动杀 QQ
  • 可迁移 —— 路径由环境变量/自身位置推导,不写死盘符和账号

1 · 注入与隐身

注入走标准的 VirtualAllocEx + CreateRemoteThread(LoadLibraryW), 由常驻提权代理(一次 UAC)执行。有几个不那么标准的细节:

  • 每次注入前把 payload/hook_cap*.dll 复制成唯一副本 wrk/inject/hook_cap11_<pid>_<ts>.dll—— 同一路径二次 LoadLibrary 不会重跑 DllMain,这个坑我们踩过
  • 配置按 pid 隔离:wrk/hook_config_<pid>.ini, hook v11 优先读 DLL 同目录配置
  • DllMain 里先缓存自身路径,再从 PEB 三条模块链表摘除自己并抹掉模块名—— 顺序反了就再也找不到自己
  • x86 进程在注入前被 IsWow64Process2 明确拒绝,绝不硬来
Note

hook 没有 FreeLibrary / 卸载接口。写 wrk/hook_disabled 可以让工作线程立即停止采集, 但彻底移除内存里的 DLL 只能重启 QQ。框架停止 ≠ hook 停止。

这段话我们每年都要向不同的人解释很多遍,所以干脆写进了官网。

2 · IAT 定位与自愈

早期版本硬编码 QQNT.dll 的模块基址和槽位号,QQ 一升级就失灵。 v9 起改为运行时自发现:等工作线程确认 wrapper.node 加载后, 遍历它的导入表定位 napi_call_function,打 IAT 补丁进入 my_call_function。不依赖版本号,不依赖偏移。

QQ 自己也会还原被补丁的槽位。工作线程每 2s 写一次心跳 (wrk/hook_alive_<pid>.log),每 5 次心跳校验 IAT 槽是否被还原, 是则重新补上——所谓自愈,就是对方拆一次我们贴一次,直到一方放弃。

3 · 捕获与落盘

回调里只序列化 fire(QQ 的 JS 事件推送入口)。 N-API 值递归转 JSON,深度、键数、数组长度、单条大小均设上限, 防止一条超大消息把整个进程拖垮。通过键名 msgId,elements 判定是否消息。

# wrk/stream/pid-<pid>/ 下的三类分段文件
msg-*.jsonl    # 消息(文本/图片/表情/@/回复/ark/合并转发/撤回…)
event-*.jsonl  # 通讯录 / 群事件(加群、退群、改名…)
trace-*.jsonl  # 全回调摘要,排查「msgs 归零」时的第一现场

# 规则
单段 8MB 自动轮转 · 只 append 不 rename · 崩溃不丢已写入部分

Python 侧 qqbot.stream 是唯一数据入口:轮转感知、增量 tail, 上层永远不直接 glob 文件。

4 · 身份库与规范化

消息里到处是 uid(内部 ID)和 uin(QQ 号),昵称和群名又只在事件里出现。 qqbot.identity 用 SQLite(wrk/qqbot.db,WAL)从消息 + 事件双来源学习, 维护 uid ↔ uin ↔ 昵称/群名/群成员 的映射;查不到就是查不到,绝不瞎猜。

qqbot.normalize 把所有 element 折叠成统一的 23 字段消息结构: 谁、在哪个会话、什么时候、发了什么(文本化后的)、原始元素列表、消息 ID 等。 机器人、WebUI、OneBot 全部只认这一个结构。

5 · 发送泵

发送不能靠模拟按键——太脆。LunaFox 让 QQ 自己发:Python 把请求写成 wrk/send/pid-<pid>/req-<id>.json,QQ 主进程里的发送泵 (send_pump.js,每 ~250ms 扫一次)在主进程执行真正的发送调用:

# 发送路径
文本 / @   → msgService.sendMsgElementsToMultiUsers
图片 / 文件 → msgService.sendMsg({items:[…]})
群禁言      → op=kernel_call → ntApi IPC(真机验证通过)

# 结果确认(双保险)
res-<id>.json 回执  +  hook 流回读到这条消息

因为泵跑在主进程,不受 renderer 刷新、窗口切换影响;文件协议保证请求可重试、 结果可审计(req / res / claim 三件套,迟到的 res 10 分钟后自动清理)。

Risk

发送有风控风险。默认限流:每个目标 1 秒 1 条、全局 30 条/分钟。请不要刷屏, 也请不要把「测试」两个字发进别人的群。

「0 反检测」不是让你主动吸引检测的火力,虽然效果上确实没有区别。

6 · OneBot v11

协议端只做 WebSocket:正向 WS 服务端 + 反向 WS 客户端,数组消息段,不解析 CQ 码。 多账号 = 一个账号一个实例([account-<uin>]),端口与 path 可配。 已实现 action:send_msg 系列、群信息 / 群成员查询、 set_group_ban / set_group_whole_ban 等。

WS 实现是自研的最小 RFC6455(stdlib socket),没有 aiohttp / websockets 依赖—— Python 运行时仅标准库这一条原则,从 P0 坚持到现在。

7 · 多账号隔离

  • 一个账号一个 pid,hook / stream / send / 配置目录全部按 pid 分开
  • WebUI 发送强制按账号路由:多账号时未选「发送账号」直接返回 account-required,想发错都难
  • 消息流里有重复 msgId?多半是同一个进程被注入了两次—— 新护栏已阻止,老问题见 changelog 的黑历史章节
  • 账号注册表(wrk/accounts/)在启动时自动清理已退出的 pid 记录

8 · 稳定性与诊断

这个项目最花时间的不是功能,是不静默地失败。两个诊断工具是核心资产:

  • tools/health.py —— 一页体检:代理 / QQ 主进程 / hook / 心跳 / 分段流 / 身份库 / 看门狗 / 发送泵,exit 0 = 健康
  • tools/compat_check.py —— 升级适配诊断:能定位「注入成功但其实没捕获」的 静默假成功、re-patch 风暴、运行版本漂移、泵积压,FAIL 附修复建议

另外 tools/win_structures.py 统一了所有结构/架构断言; 看门狗默认只诊断(auto_inject=0),不会自作主张 kill/launch QQ; 单实例互斥量探活保证同一账号不会被注入两份。

Hard Constraints

① 只支持 x64 QQ;② N-API 回调 fire 名称目前固定,QQ 若改名, msgs 会归零——先看 trace;③ 9.9.36 未验证;④ 旧 DLL 无法卸载, hook 升级必须重启 QQ。以上每一条都对应一次真实事故。

「文档里的 Hard Constraints 越多,说明过去越柔软。」—— 一位不愿透露姓名的开发者