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

  1. Retry the login after clearing credentials — if it was a transient server issue, a fresh QR flow may return complete credentials.
  2. Poll the status once more after a short delay before giving up — the server may provision credentials a beat after the 'confirmed' status.
  3. Delete the token file and run WeixinBot.login({force:true}) to start completely fresh.
  4. 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

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


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/1fddb64ba99ded4b. Report an issue: GitHub.