CherryHQ/cherry-studio · error · Error
QR login confirmed, but the API did not return bot credentia
Error message
QR login confirmed, but the API did not return bot credentials
What it means
Thrown by loginFlow() when pollQrStatus returns status:'confirmed' but the response is missing bot_token, ilink_bot_id, or ilink_user_id. The user successfully scanned and confirmed the QR, but the WeChat iLink backend did not return the credentials needed to construct a Credentials object. This is a protocol/server defect, not a user action — the login reached the final step but the payload was incomplete.
Source
Thrown at src/main/ai/channels/adapters/wechat/WeChatProtocol.ts:632
throw new Error('Login cancelled')
}
const status = await pollQrStatus(options.baseUrl, qr.qrcode)
if (status.status !== lastStatus) {
if (status.status === 'scaned') {
logger.info('QR code scanned, waiting for confirmation')
} else if (status.status === 'confirmed') {
logger.info('Login confirmed')
} else if (status.status === 'expired') {
logger.info('QR code expired', { attempt: qrRetries + 1, maxAttempts: MAX_QR_RETRIES })
}
lastStatus = status.status
}
if (status.status === 'confirmed') {
if (!status.bot_token || !status.ilink_bot_id || !status.ilink_user_id) {
throw new Error('QR login confirmed, but the API did not return bot credentials')
}
const credentials: Credentials = {
token: status.bot_token,
baseUrl: status.baseurl ?? options.baseUrl,
accountId: status.ilink_bot_id,
userId: status.ilink_user_id
}
await saveCredentials(credentials, options.tokenPath)
return credentials
}
if (status.status === 'expired') break
await delay(QR_POLL_INTERVAL_MS)
}
qrRetries++View on GitHub (pinned to 726446b54c)
Solutions
- Retry the login after clearing credentials — if it was a transient server issue, a fresh QR flow may return complete credentials.
- Poll the status once more after a short delay before giving up — the server may provision credentials a beat after the 'confirmed' status.
- Delete the token file and run WeixinBot.login({force:true}) to start completely fresh.
- If persistent, the protocol may have changed — verify the QrStatusResponseSchema fields against current iLink responses.
Example fix
// before — single poll, throws on first confirmed-without-credentials
if (status.status === 'confirmed') {
if (!status.bot_token || !status.ilink_bot_id || !status.ilink_user_id) {
throw new Error('QR login confirmed, but the API did not return bot credentials')
}
// ...
}
// after — one bounded re-poll to absorb a server-side provisioning race
if (status.status === 'confirmed') {
let s = status
for (let i = 0; i < 3 && (!s.bot_token || !s.ilink_bot_id || !s.ilink_user_id); i++) {
await delay(1_000)
s = await pollQrStatus(options.baseUrl, qr.qrcode)
}
if (!s.bot_token || !s.ilink_bot_id || !s.ilink_user_id) {
throw new Error('QR login confirmed, but the API did not return bot credentials')
}
// proceed with s
} Defensive patterns
Strategy: retry
Validate before calling
// You cannot prevent a server-side omission, but you can poll once more before failing.
// Wrap the confirmed-status check in a bounded re-poll:
async function awaitConfirmedCredentials(
baseUrl: string,
qrCode: string,
maxRepolls = 3
): Promise<{ bot_token: string; ilink_bot_id: string; ilink_user_id: string }> {
for (let i = 0; i < maxRepolls; i++) {
const s = await pollQrStatus(baseUrl, qrCode)
if (s.status === 'confirmed' && s.bot_token && s.ilink_bot_id && s.ilink_user_id) {
return { bot_token: s.bot_token, ilink_bot_id: s.ilink_bot_id, ilink_user_id: s.ilink_user_id }
}
if (s.status !== 'confirmed') throw new Error(`QR status regressed to ${s.status}`)
await delay(1_000)
}
throw new Error('QR login confirmed, but the API did not return bot credentials')
} Type guard
function hasFullCredentials(s: QrStatusResponse): boolean {
return Boolean(s.bot_token && s.ilink_bot_id && s.ilink_user_id)
} Try / catch
try {
const credentials = await bot.login({ signal })
} catch (e) {
if (e instanceof Error && /did not return bot credentials/.test(e.message)) {
// Retry the whole login once — server-side provisioning race
logger.warn('WeChat confirmed without credentials, retrying login')
await rm(tokenPath, { force: true })
return await bot.login({ force: true, signal })
}
throw e
} Prevention
- Poll once or twice more after a 'confirmed' status before failing — the server may provision credentials a beat later.
- If the issue persists, the reverse-engineered QrStatusResponseSchema fields may have moved — verify against current iLink responses.
- Clear the credentials file before retrying a fresh QR flow to avoid loadCredentials short-circuiting.
- Log the raw confirmed-status payload when this throws to capture which field was missing.
When it happens
Trigger: pollQrStatus returns {status:'confirmed'} and one or more of bot_token, ilink_bot_id, ilink_user_id is undefined/empty. The QrStatusResponseSchema (WeChatProtocol.ts:127) marks these optional precisely because the server may omit them. This would indicate a server-side issue, a protocol version mismatch, or an edge case where confirmation arrived but credential issuance had not completed server-side.
Common situations: Rare. Could occur if the WeChat iLink backend had a partial failure during credential issuance; if the reverse-engineered protocol expects a follow-up call to fetch credentials that is not implemented; if a protocol change moved the credentials to a different response field; race condition where the status was polled in the narrow window between user confirmation and server-side credential provisioning.
Related errors
- QR login failed after ${MAX_QR_RETRIES} expired QR codes. Us
- Bot is not connected
- ${label} returned non-JSON (HTTP ${response.status})
- ${label} failed with HTTP ${response.status}
- ${label} failed
AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12).
Data as JSON: /api/errors/1fddb64ba99ded4b.
Report an issue: GitHub.