这一页解释 LunaFox 到底做了什么:DLL 怎么进的 QQ、消息怎么变成 JSONL、
身份怎么对上号、消息怎么发出去。所有描述都对应真实实现,
与 PROGRESS.md 逐条对齐。
* 本项目永不开源,所以这页文档就是你能拿到的全部源码。阅读不需要勇气,动手复现需要 2099 年。
QQ NT 的消息在本地是加密/专有格式,读库既不稳定也有风险。LunaFox 选择另一条路: 在 QQ 主进程内存里只读 Hook N-API 调用,把消息落盘为纯文本 JSONL, Python 侧只读文件、不碰数据库。整个 hook ↔ Python 的边界是一组文件, 进程崩溃可恢复,语言无关,工具无关。
wrk/ 下的文件交换数据
注入走标准的 VirtualAllocEx + CreateRemoteThread(LoadLibraryW),
由常驻提权代理(一次 UAC)执行。有几个不那么标准的细节:
payload/hook_cap*.dll 复制成唯一副本
wrk/inject/hook_cap11_<pid>_<ts>.dll——
同一路径二次 LoadLibrary 不会重跑 DllMain,这个坑我们踩过wrk/hook_config_<pid>.ini,
hook v11 优先读 DLL 同目录配置IsWow64Process2 明确拒绝,绝不硬来hook 没有 FreeLibrary / 卸载接口。写 wrk/hook_disabled 可以让工作线程立即停止采集,
但彻底移除内存里的 DLL 只能重启 QQ。框架停止 ≠ hook 停止。
早期版本硬编码 QQNT.dll 的模块基址和槽位号,QQ 一升级就失灵。
v9 起改为运行时自发现:等工作线程确认 wrapper.node 加载后,
遍历它的导入表定位 napi_call_function,打 IAT 补丁进入
my_call_function。不依赖版本号,不依赖偏移。
QQ 自己也会还原被补丁的槽位。工作线程每 2s 写一次心跳
(wrk/hook_alive_<pid>.log),每 5 次心跳校验 IAT 槽是否被还原,
是则重新补上——所谓自愈,就是对方拆一次我们贴一次,直到一方放弃。
回调里只序列化 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 文件。
消息里到处是 uid(内部 ID)和 uin(QQ 号),昵称和群名又只在事件里出现。
qqbot.identity 用 SQLite(wrk/qqbot.db,WAL)从消息 + 事件双来源学习,
维护 uid ↔ uin ↔ 昵称/群名/群成员 的映射;查不到就是查不到,绝不瞎猜。
qqbot.normalize 把所有 element 折叠成统一的 23 字段消息结构:
谁、在哪个会话、什么时候、发了什么(文本化后的)、原始元素列表、消息 ID 等。
机器人、WebUI、OneBot 全部只认这一个结构。
发送不能靠模拟按键——太脆。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 分钟后自动清理)。
发送有风控风险。默认限流:每个目标 1 秒 1 条、全局 30 条/分钟。请不要刷屏, 也请不要把「测试」两个字发进别人的群。
「0 反检测」不是让你主动吸引检测的火力,虽然效果上确实没有区别。
协议端只做 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 坚持到现在。
account-required,想发错都难msgId?多半是同一个进程被注入了两次——
新护栏已阻止,老问题见 changelog 的黑历史章节wrk/accounts/)在启动时自动清理已退出的 pid 记录这个项目最花时间的不是功能,是不静默地失败。两个诊断工具是核心资产:
tools/health.py —— 一页体检:代理 / QQ 主进程 / hook / 心跳 /
分段流 / 身份库 / 看门狗 / 发送泵,exit 0 = 健康tools/compat_check.py —— 升级适配诊断:能定位「注入成功但其实没捕获」的
静默假成功、re-patch 风暴、运行版本漂移、泵积压,FAIL 附修复建议
另外 tools/win_structures.py 统一了所有结构/架构断言;
看门狗默认只诊断(auto_inject=0),不会自作主张 kill/launch QQ;
单实例互斥量探活保证同一账号不会被注入两份。
① 只支持 x64 QQ;② N-API 回调 fire 名称目前固定,QQ 若改名,
msgs 会归零——先看 trace;③ 9.9.36 未验证;④ 旧 DLL 无法卸载,
hook 升级必须重启 QQ。以上每一条都对应一次真实事故。