Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/common/emailNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export class EmailNotificationService extends Service {
this.onOffline(data.tipsDesc || data.tipsTitle)
})

this.ctx.on('qq/session-expired', (reason) => {
wasOffline = true
this.onOffline(reason)
})

this.checkLoginStatus = setInterval(() => {
if (wasOffline && selfInfo.online) {
this.notificationSent = false
Expand Down
2 changes: 2 additions & 0 deletions src/main/qqProtocol/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ declare module 'cordis' {
'qq/online': () => void
/** 协议层断开(WS / TCP 掉了)。上层一般不需要卸插件,等协议层重连后会再次 emit `qq/online` */
'protocol/disconnect': () => void
/** An online QQ session was rejected; notify consumers without applying KickNT recovery. */
'qq/session-expired': (reason: string) => void
'llbot/self-nick-changed': (info: { nick: string }) => void
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/main/qqProtocol/direct-lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ export class DirectProtocolClient extends EventEmitter {

async sendCommand(cmd: string, payload: Buffer, encryptType?: EncryptType, timeout = 15000): Promise<SsoPacket> {
const seq = this.nextSeq()
const session = this.session
const ctx = this.getPacketContext()
const enc = encryptType ?? (this.session ? EncryptType.EncryptD2Key : EncryptType.EncryptEmpty)

Expand Down Expand Up @@ -784,6 +785,10 @@ export class DirectProtocolClient extends EventEmitter {
}
}

// Signing can still be in flight when another request invalidates the session.
if (this.session !== session) {
throw new Error('QQ session changed before the command could be sent')
}
const packet = buildServicePacket(seq, cmd, ctx, payload, enc, signResult)

// 调试用: 出网前 dump SSO frame, 跟真机抓包对照定位 sign 不一致的字节差异.
Expand Down Expand Up @@ -825,6 +830,22 @@ export class DirectProtocolClient extends EventEmitter {
this.emit('error', new Error('Failed to parse incoming packet'))
return
}
// Authentication failures can have seq=0 and no command, so handle them before request matching.
if (parsed.retCode === -10001 && this.session) {
const uin = this.session.uin
const error = new Error(
`QQ session authentication failed: retCode=${parsed.retCode}, ${parsed.extraMsg || 'Please log in again'}`,
)
this.clearSession()
for (const pending of this.pendingPackets.values()) {
clearTimeout(pending.timeout)
pending.reject(error)
}
this.pendingPackets.clear()
this.frameArriveAt.clear()
this.emit('session-expired', uin, error)
return
}
if (isDebugEnabled()) this.frameArriveAt.set(parsed.seq, tArrive)
const pending = this.pendingPackets.get(parsed.seq)
if (pending) {
Expand Down
4 changes: 4 additions & 0 deletions src/main/qqProtocol/direct-lib/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ export async function pollQrCode(client: DirectProtocolClient, sig: Buffer): Pro
export async function loginWithQrResult(
client: DirectProtocolClient,
qrResult: QrPollResult,
// Checked after the network response, before installing session credentials.
isCurrent?: () => boolean,
): Promise<LoginResult> {
if (!qrResult.tempPassword || !qrResult.tgtgtKey || !qrResult.noPicSig || !qrResult.uin) {
throw new Error('QR poll result incomplete')
Expand Down Expand Up @@ -283,6 +285,8 @@ export async function loginWithQrResult(
15000,
)

// A superseded QR attempt must not install credentials into the shared client.
if (isCurrent && !isCurrent()) throw new Error('QR login attempt was superseded')
const result = parseLoginResponse(resp.payload, client.getEcdhShareKey(), qrResult.tgtgtKey)
if (result.success) {
client.setSession({
Expand Down
84 changes: 65 additions & 19 deletions src/main/qqProtocol/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class DirectQQProtocol extends QQProtocolBase {

public async logout(): Promise<void> {
this.manualLogout = true
this.qrPollToken++
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
this.directClient?.clearSession()
this.directClient?.disconnect()
Expand Down Expand Up @@ -253,13 +254,14 @@ export class DirectQQProtocol extends QQProtocolBase {
private async doInitDirectClient(authToken: string): Promise<void> {
this.manualLogout = false
// 换账号 / 重试时取消上一轮残留的 QR poll loop (qrPollToken 变 -> 旧 poll 循环下一 tick 自退)
this.qrPollToken++
const myToken = ++this.qrPollToken
// 换账号/重建 client: 清 base 展示缓存, 否则 TTL 内会复用上一账号的旧码
this.resetQrState()
this.directQrResult = null

// native sign 已 init 时热切换到最新 token; 未 init (首次) 时 no-op, token 由 new client 的 config 带入
await updateAuthToken(authToken).catch((e) => this.logger.warn('[Sign] updateAuthToken failed:', (e as Error).message))
if (this.qrPollToken !== myToken) return

// 先 loadSession 拿 (uin, guid). 运行时 override 优先于 argv -q.
const specifiedUin = this.runtimeUinOverride || getSpecifiedUin()
Expand Down Expand Up @@ -308,11 +310,13 @@ export class DirectQQProtocol extends QQProtocolBase {
// uin 授权/绑定由服务端判 (登录时按配额自动绑, 满了才 403); 本地不预检 allowed_uins.
this.logger.info('Found saved session for UIN %s (file: %s), attempting restore...', persisted.uin, getSessionFilePathForUin(persisted.uin))
if (!this.directClient.isConnected) await this.directClient.connect()
if (this.qrPollToken !== myToken) return
const session = persistedToSessionInfo(persisted)
this.directClient.setSession(session)

try {
await registerOnline(this.directClient)
if (this.qrPollToken !== myToken || this.directClient.getSession() !== session) return
this.logger.info('[QQ Server] Online registered!')
selfInfo.uin = persisted.uin
selfInfo.uid = persisted.uid
Expand All @@ -323,9 +327,12 @@ export class DirectQQProtocol extends QQProtocolBase {
this.directStopHeartbeat = startHeartbeat(this.directClient)
this.maybeEmitOnline()
// 直连 session 恢复后 nick 可能为空; 异步补查
if (!selfInfo.nick) this.scheduleFetchSelfNick()
if (!selfInfo.nick) {
this.scheduleFetchSelfNick(() => this.qrPollToken === myToken && this.directClient?.getSession() === session)
}
return
} catch (e) {
if (this.qrPollToken !== myToken || this.directClient.getSession() !== session) return
// 恢复失败 (session 过期): 清 session, 但保留 TCP 连接复用给扫码 -- 不 disconnect, 否则会触发
// close 事件且 native sign relay 目标断链, 下面 fresh 分支直接用现连接拉码.
this.logger.info('Saved session expired, will need QR login: %s', (e as Error).message)
Expand All @@ -339,6 +346,34 @@ export class DirectQQProtocol extends QQProtocolBase {

/** 给 client 挂事件 (error/connected/close/push). 只在首次建立 client 时调一次 -- 复用 client 不重挂. */
private bindDirectClientEvents(client: DirectProtocolClient): void {
client.on('session-expired', (uin: string, error: Error) => {
const wasOnline = this.onlineEmitted
selfInfo.online = false
this.onlineEmitted = false
authTokenStatus.loginError = error.message
this.logger.warn('QQ session expired:', error.message)
if (this.directStopHeartbeat) {
this.directStopHeartbeat()
this.directStopHeartbeat = null
}
if (this.reconnectTimer) {
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.ctx.parallel('qq/session-expired', error.message)
}
this.ensureQrLoop()
})
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
client.on('error', (err: Error) => {
this.logger.warn('Direct client error:', err.message)
})
Expand All @@ -363,8 +398,8 @@ export class DirectQQProtocol extends QQProtocolBase {
}
})
client.on('push', (packet: { cmd: string; payload: Buffer }) => {
// 收到包 = 连着; 顺带刷新 lastConnectedTime
this.lastConnectedTime = Date.now()
// Unauthenticated traffic must not postpone the disconnect notification.
if (client.isLoggedIn) this.lastConnectedTime = Date.now()
this.ctx.parallel('qq/raw', { cmd: packet.cmd, payload: packet.payload })
})
}
Expand All @@ -386,7 +421,7 @@ export class DirectQQProtocol extends QQProtocolBase {
this.directPollResult = result

if (result.state === QrCodeState.Confirmed) {
await this.completeDirectLogin()
await this.completeDirectLogin(myToken)
return
}

Expand All @@ -402,6 +437,7 @@ export class DirectQQProtocol extends QQProtocolBase {
return
}
} catch (e) {
if (this.qrPollToken !== myToken) return
this.logger.warn('QR poll error:', (e as Error).message)
}

Expand All @@ -412,48 +448,56 @@ export class DirectQQProtocol extends QQProtocolBase {
setTimeout(poll, 2000)
}

private async completeDirectLogin() {
if (!this.directClient || !this.directPollResult || !this.directQrResult) return
private async completeDirectLogin(myToken: number) {
const client = this.directClient
const pollResult = this.directPollResult
const qrResult = this.directQrResult
if (this.qrPollToken !== myToken || !client || !pollResult || !qrResult) return
this.manualLogout = false

// Get UIN
const urlParams = new URL(this.directQrResult.url).searchParams
const urlParams = new URL(qrResult.url).searchParams
const qrSig = urlParams.get('k') || ''
const uin = await getCorrectUin(AppInfo.appId, qrSig)
this.directPollResult.uin = String(uin)
if (this.qrPollToken !== myToken) return
pollResult.uin = String(uin)

// uin 授权/绑定由服务端判 (登录时按配额自动绑, 满了才 403); 本地不预检 allowed_uins.

// wtlogin.login
const loginResult = await loginWithQrResult(this.directClient, this.directPollResult)
const loginResult = await loginWithQrResult(client, pollResult, () => this.qrPollToken === myToken)
if (this.qrPollToken !== myToken) return
if (!loginResult.success) {
this.logger.error(`Login failed: state=${loginResult.state} ${loginResult.tag} ${loginResult.message}`)
// 登录失败原因回传 WebUI (如 auth_token 可用 QQ 数量已达上限)
authTokenStatus.loginError = `登录失败: ${[loginResult.tag, loginResult.message].filter(Boolean).join(' ')}`.trim()
return
}

this.logger.info(`Login successful! UID: ${loginResult.uid}, nick: "${loginResult.nick}"`)

// Save session
const session = this.directClient.getSession()!
saveSession(session, this.directPollResult.tgtgtKey!, this.directClient.getGuid(), loginResult.tempPassword, loginResult.nick)
const session = client.getSession()
if (!session) return

// Register online: 失败视为登录未完成, 不标记在线, 报错回 WebUI. 必须清掉半成品 session
// (loginWithQrResult 已 setSession -> isLoggedIn=true), 否则扫码 loop 认为已登录会停, 不出新码,
// 变成收不到 MsgPush 的"假在线". 连接保留复用, 下一轮 loop 直接拉新码.
try {
await registerOnline(this.directClient)
await registerOnline(client)
} catch (e) {
if (this.qrPollToken !== myToken || client.getSession() !== session) return
const msg = (e as Error).message
this.logger.error('Register online failed:', msg)
authTokenStatus.loginError = `上线注册失败: ${msg}`
this.directClient.clearSession()
client.clearSession()
return
}
// A completed request may resume after invalidation or a newer login attempt.
if (this.qrPollToken !== myToken || client.getSession() !== session) return

this.logger.info(`Login successful! UID: ${loginResult.uid}, nick: "${loginResult.nick}"`)
saveSession(session, pollResult.tgtgtKey!, client.getGuid(), loginResult.tempPassword, loginResult.nick)

// Start heartbeat
this.directStopHeartbeat = startHeartbeat(this.directClient)
this.directStopHeartbeat = startHeartbeat(client)

// Update global state
selfInfo.uin = String(uin)
Expand All @@ -463,6 +507,8 @@ export class DirectQQProtocol extends QQProtocolBase {
// 记住已登录 uin: 断线重连走 initDirectClient() 时用它 loadSession 快速登录, 不退回扫码.
this.runtimeUinOverride = String(uin)
this.maybeEmitOnline()
if (!selfInfo.nick) this.scheduleFetchSelfNick()
if (!selfInfo.nick) {
this.scheduleFetchSelfNick(() => this.qrPollToken === myToken && client.getSession() === session)
}
}
}
4 changes: 4 additions & 0 deletions src/milky/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ export class MilkyAdapter extends Service {
this.emitEvent('bot_offline', eventData)
}
})

this.ctx.on('qq/session-expired', (reason) => {
this.emitEvent('bot_offline', { reason })
})
}
}

Expand Down
Loading
Loading