fix: handle unsequenced QQ authentication failures - #856
Open
HLfromZ wants to merge 2 commits into
Open
Conversation
Contributor
审查者指南该 PR 在序列匹配之前检测 QQ 的 retCode=-10001 身份验证失败,即使对于 seq=0/空命令响应,也会一致地使会话失效,拒绝正在进行的工作,并通过现有的二维码登录流程让账号重新登录。同时,它还防止异步签名使用过期会话,并添加了全面的回归测试,同时保持普通错误、推送和传输关闭行为不变。 QQ 会话身份验证失败恢复的时序图sequenceDiagram
participant QQ as QQ Server
participant Client as DirectProtocolClient
participant Protocol as DirectQQProtocol
participant Login as QR Login Flow
participant OneBot as OneBot Client
QQ-->>Client: SSO response retCode=-10001, seq=0, cmd=""
Client->>Client: clearSession()
Client->>Client: reject pending requests and clear timers
Client->>Protocol: session-expired(uin, error)
Protocol->>Protocol: mark selfInfo.online=false
Protocol->>Protocol: stop heartbeat and pending reconnect
Protocol->>Protocol: deleteSession(uin)
Protocol->>Protocol: resetQrState()
Protocol->>Login: ensureQrLoop()
Protocol-->>OneBot: protocol/disconnect
Direct QQ 身份验证失败的状态图stateDiagram-v2
[*] --> Online
Online --> SessionExpired: retCode=-10001
SessionExpired --> Offline: clearSession and reject pending requests
Offline --> NeedQRCode: deleteSession and resetQrState
NeedQRCode --> QRLogin: ensureQrLoop
QRLogin --> Online: successful login
防止过期会话命令的流程图flowchart LR
A[sendCommand] --> B[Capture current session]
B --> C[Async signing]
C --> D{Session changed?}
D -- Yes --> E[Throw stale-session error]
D -- No --> F[buildServicePacket]
F --> G[Send command]
文件级变更
提示和命令与 Sourcery 交互
自定义你的使用体验访问你的控制面板以:
获取帮助Original review guide in EnglishReviewer's GuideThe PR detects QQ’s retCode=-10001 authentication failures before sequence matching, invalidates the session consistently even for seq=0/empty-command responses, rejects in-flight work, and drives the account back through the existing QR login flow. It also guards asynchronous signing against stale sessions and adds comprehensive regression tests while preserving ordinary error, push, and transport-close behavior. Sequence diagram for QQ session authentication failure recoverysequenceDiagram
participant QQ as QQ Server
participant Client as DirectProtocolClient
participant Protocol as DirectQQProtocol
participant Login as QR Login Flow
participant OneBot as OneBot Client
QQ-->>Client: SSO response retCode=-10001, seq=0, cmd=""
Client->>Client: clearSession()
Client->>Client: reject pending requests and clear timers
Client->>Protocol: session-expired(uin, error)
Protocol->>Protocol: mark selfInfo.online=false
Protocol->>Protocol: stop heartbeat and pending reconnect
Protocol->>Protocol: deleteSession(uin)
Protocol->>Protocol: resetQrState()
Protocol->>Login: ensureQrLoop()
Protocol-->>OneBot: protocol/disconnect
State diagram for Direct QQ authentication failurestateDiagram-v2
[*] --> Online
Online --> SessionExpired: retCode=-10001
SessionExpired --> Offline: clearSession and reject pending requests
Offline --> NeedQRCode: deleteSession and resetQrState
NeedQRCode --> QRLogin: ensureQrLoop
QRLogin --> Online: successful login
Flow diagram for stale-session command preventionflowchart LR
A[sendCommand] --> B[Capture current session]
B --> C[Async signing]
C --> D{Session changed?}
D -- Yes --> E[Throw stale-session error]
D -- No --> F[buildServicePacket]
F --> G[Send command]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
HLfromZ
marked this pull request as ready for review
September 6, 2026 12:24
Contributor
There was a problem hiding this comment.
你好——我发现了 1 个问题
面向 AI Agent 的提示
请处理本次代码审查中的评论:
## 单条评论
### 评论 1
<location path="src/main/qqProtocol/direct.ts" line_range="356-366" />
<code_context>
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ // Remove only the rejected account's credentials; retain the device identity and native client.
+ deleteSession(uin)
+ this.runtimeUinOverride = null
+ this.qrPollToken++
+ this.directQrResult = null
+ this.directPollResult = null
+ this.resetQrState()
+ setLoginState({ state: 'need_qrcode', qrcode_png_base64: undefined })
+ if (wasOnline) this.ctx.parallel('protocol/disconnect')
+ this.ensureQrLoop()
+ })
client.on('error', (err: Error) => {
this.logger.warn('Direct client error:', err.message)
</code_context>
<issue_to_address>
**问题 (bug_risk):**进行中的 `completeDirectLogin()` 在 `qrPollToken++` 之后没有被取消或重新验证。如果会话在二维码登录或 `registerOnline()` 等待期间过期,旧的登录流程仍会继续、启动心跳、设置 `selfInfo.online = true`,并在过期处理程序已将账号标记为离线且重置二维码状态之后调用 `maybeEmitOnline()`。
**触发条件:**二维码登录完成过程与会话身份验证失败重叠时。
**建议修复:**将二维码/会话代数传入 `completeDirectLogin()`,并在每个等待的登录步骤之后,在保存会话、启动心跳或将账号标记为在线之前,连同 `directClient.isLoggedIn` 一起对其进行检查。
</issue_to_address>Original comment in English
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src/main/qqProtocol/direct.ts" line_range="356-366" />
<code_context>
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ // Remove only the rejected account's credentials; retain the device identity and native client.
+ deleteSession(uin)
+ this.runtimeUinOverride = null
+ this.qrPollToken++
+ this.directQrResult = null
+ this.directPollResult = null
+ this.resetQrState()
+ setLoginState({ state: 'need_qrcode', qrcode_png_base64: undefined })
+ if (wasOnline) this.ctx.parallel('protocol/disconnect')
+ this.ensureQrLoop()
+ })
client.on('error', (err: Error) => {
this.logger.warn('Direct client error:', err.message)
</code_context>
<issue_to_address>
**issue (bug_risk):** An in-flight `completeDirectLogin()` is not cancelled or revalidated after `qrPollToken++`. If session expiration occurs while QR login or `registerOnline()` is awaiting, the old login flow continues, starts a heartbeat, sets `selfInfo.online = true`, and calls `maybeEmitOnline()` after the expiration handler has already marked the account offline and reset the QR state.
**Triggers:** When a QR login completion overlaps a session-authentication failure.
**Suggested fix:** Pass the QR/session generation into `completeDirectLogin()` and check it, along with `directClient.isLoggedIn`, after every awaited login step before saving the session, starting the heartbeat, or marking the account online.
</issue_to_address>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题与影响
LLBot Direct 模式下,QQ 服务端拒绝当前会话时,可以返回
retCode=-10001、seq=0、cmd=""的 SSO 响应。原实现先按请求序号查找等待中的请求,因此这类无法匹配请求的鉴权错误会进入 push 分支,随后因命令为空而未被处理。结果是:实际业务请求持续超时,QQ 消息停止上报,但 TCP 连接、连接级心跳和 OneBot WebSocket 仍可保持工作,
get_status也仍返回online=true。使用者看到的是机器人长时间不响应,却没有明确的重新登录提示。本 PR 在请求匹配前处理会话鉴权失败,立即使失效会话退出在线状态,并进入已有的二维码登录流程。
现场环境与脱敏说明
8.1.10,Docker 部署,Direct 模式(原生签名 + TCP)。<...>。未附带消息正文、Authorization、会话凭据、密钥、二维码、配置文件或原始抓包文件。seq=0、错误码、命令名和超时时长属于定位问题所需的协议字段,予以保留。抓包解析结果与 LLBot 原生日志分开展示。故障时间线与日志证据
SsoHeartBeat超时。get_status仍显示在线;绕过缓存查询群信息却在约 15 秒后失败。Heartbeat.Alive仍成功。1. LLBot 原生日志节选
以下日志只替换了部署地址与身份字段;中间无关行已省略:
单凭这些超时日志无法区分网络故障和会话鉴权失败,也不能证明早期每一次超时都由相同原因引起。鉴权失败的直接证据来自下面的响应解析。
2. 故障期间的 OneBot 只读探测
get_status耗时约 0.013 秒,以下只保留状态字段:{ "status": "ok", "retcode": 0, "data": { "online": true, "good": true } }随后对脱敏目标
<GROUP_ID>调用get_group_info,设置no_cache=true,耗时约 15.307 秒,返回:{ "status": "failed", "retcode": 200, "data": null, "message": "Command OidbSvcTrpcTcp.0xfe5_2 timed out after 15000ms", "wording": "Command OidbSvcTrpcTcp.0xfe5_2 timed out after 15000ms" }这里的 OneBot
retcode=200是业务 API 的失败结果,与下文 QQ SSO 层的retCode=-10001属于不同层级。3. TCP 传输与 SSO 响应解析
诊断期间被动观察 LLBot 网络命名空间中的现有连接,并配合只读 API 请求;没有为抓包重启 LLBot。下面是去除地址和 TCP 序号后的传输节选:
该片段证明 TCP 连接仍有双向数据交换,不能单独证明业务请求成功,也不能仅凭时序认定它对应哪一个业务命令。
下面是抓包后按协议解析得到的字段,不是 LLBot 原有日志。保留了长度、协议版本、加密类型、错误码及命令;正常心跳的非零序号替换为占位符:
{ "at": "2026-09-06T19:41:44.710+08:00", "len": 136, "version": 12, "encryption": 2, "head_len": 89, "seq": 0, "retCode": -10001, "extraMsg": "身份验证失败,请你重新登录。(s20)", "cmd": "" }{ "at": "2026-09-06T19:41:57.502+08:00", "len": 89, "version": 13, "encryption": 0, "head_len": 57, "seq": "<NONZERO_SEQ>", "retCode": 0, "extraMsg": "", "cmd": "Heartbeat.Alive" }这说明服务端已经拒绝当前会话,但连接级存活检测仍然成功。
Heartbeat.Alive与需要会话鉴权的SsoHeartBeat不能互相替代。能够确认的是当前凭据被服务端判定为无效,以及 LLBot 漏处理了该响应。仅凭现有证据无法确定服务端为何使凭据失效,例如自然到期或主动撤销;本修复不依赖任何假定的固定有效期。
源码中的漏处理路径
DirectProtocolClient.handlePacket()原先先执行pendingPackets.get(parsed.seq),仅在匹配到请求后检查错误码。seq=0无法匹配等待中的请求,因此被作为 push 发出;真正的请求仍等待到 5 秒或 15 秒超时。DirectQQProtocol转交 push 时只保留命令和载荷,没有将retCode/extraMsg转为会话失效事件;空命令也没有对应的 dispatcher 处理分支。get_status继续读取旧的selfInfo.online,不能反映这次鉴权失败。修复后的行为
retCode=-10001,同时覆盖可匹配和无法匹配请求的鉴权失败。qrPollToken,在查询 UIN、登录请求和上线注册等异步步骤后重新校验;底层登录响应在安装凭据前也校验该标记。手动登出同样取消旧流程。二维码登录仍需要使用者扫码确认。此次改动处理的是明确的鉴权失败;普通业务错误和普通传输断开继续沿用现有逻辑。
原有掉线处理与本次通知衔接
此前的
protocol/disconnect监听器只记录断开日志,并不直接推送掉线通知。已核对的其他处理如下:nt/kicked-offline时立即通知;协议持续异常达到 10 秒时,5 秒巡检触发断线回调。qq/session-expired,复用邮件服务的onOffline(reason),附带实际鉴权失败原因,遵守已有启用开关、通知去重及重新登录后复位逻辑。nt/kicked-offline转换成bot_offline,通过既有 HTTP/WebSocket 与 webhook 广播路径分发。qq/session-expired并发送相同的bot_offline事件结构,data.reason为鉴权失败原因。1001还会清除设备身份。只有此前已上报在线的账号才发送即时会话失效通知;首次登录注册失败不会被误报为已在线机器人掉线。会话清除后,仍收到的未鉴权 push 不再刷新
lastConnectedTime,避免反复错误包推迟原有断线超时回调。自动评审问题的处理
已处理 Sourcery 指出的登录收尾竞态,对应提交
bedb57f8。复现过程是:让二维码登录的
registerOnline()暂停,注入retCode=-10001使当前会话失效,再让旧注册请求完成。原 PR 提交7b6eec55会把selfInfo.online重新写为true。另一个场景是旧注册请求稍后失败,原实现会清除已经替换的新会话。现已对成功和异常路径同时增加登录轮次与会话校验,并覆盖保存会话恢复路径中的同类竞态。验证结果与边界
npm test:17 个测试文件、175 项测试通过;本 PR 共包含 24 项相关回归测试,其中本次评审后补充 15 项。seq=0与匹配序号的鉴权失败、等待请求清理、普通错误/推送/断线、签名中失效、二维码登录各异步阶段失效、保存会话恢复的迟到成功/失败、保护替换会话、主动登出取消、正常扫码登录,以及未鉴权数据持续到达时的超时回调。7b6eec55,选取上述竞态、超时回调和通知场景,得到 7 项预期失败;恢复本次补丁后全部通过。npm run build:通过。git diff --check:通过。npm run check:遇到已有的类型错误,已在基线提交1cadea38上复现相同结果:本地验证使用 Node.js
26.8.1。回归测试使用合成协议帧,并模拟网络、签名、会话存储、邮件发送和 Milky 广播端点,没有把真实账号、凭据或抓包载荷写入测试,也没有向真实收件人或 webhook 发送测试通知。补丁尚未部署到在线 QQ 实例进行端到端验证;上面的重新登录成功日志仅证明现场手动恢复,不能作为补丁线上验证结果。Sourcery 摘要
通过使过期会话失效,并在未匹配的响应被误判为推送消息之前发起二维码重新登录,恢复 QQ 认证失败后的可靠恢复能力。
错误修复:
增强功能:
测试:
Original summary in English
Sourcery 总结
通过使过期会话失效并提示通过二维码重新认证,恢复从 QQ 身份验证失败中可靠恢复的能力。
错误修复:
改进:
测试:
Original summary in English
Summary by Sourcery
Restore reliable recovery from QQ authentication failures by invalidating stale sessions and prompting QR-code reauthentication.
Bug Fixes:
Enhancements:
Tests: