From f89bbe49c09dd1584537cd04481c48d173230fb5 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:14:39 +0800 Subject: [PATCH 1/6] feat(desktop): expose bot onboarding retry health Generated-by: OpenAI Codex --- .../e2e/bot-onboarding-retry-health.spec.ts | 31 +++++++ .../__tests__/bot-onboarding-main.test.ts | 87 +++++++++++++++++++ .../bot-onboarding-status-copy.test.ts | 46 ++++++++++ .../src/main/bot-onboarding-e2e-fixture.ts | 20 ++--- apps/desktop/src/main/bot-onboarding-main.ts | 44 +++++++--- .../src/renderer/locales/settings-bot-copy.ts | 23 ++++- .../settings/bot-onboarding-modal.tsx | 8 ++ .../core/src/__tests__/bot-onboarding.test.ts | 31 +++++++ packages/core/src/bot-onboarding.ts | 20 +++++ 9 files changed, 287 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/e2e/bot-onboarding-retry-health.spec.ts create mode 100644 apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts create mode 100644 packages/core/src/__tests__/bot-onboarding.test.ts diff --git a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts new file mode 100644 index 0000000000..fe618a7b89 --- /dev/null +++ b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test } from './fixtures'; + +test('bot onboarding shows bounded retry health while preserving the QR', async ({ + linkColorWindow: page, +}, testInfo) => { + const status = page.locator('.settingsBotOnboardingStatus'); + await expect(status).toContainText('服务端暂时异常'); + await expect(status).toContainText('自动重试'); + await expect(status).not.toContainText('provider detail'); + await expect(page.locator('.settingsBotOnboardingQrFrame img')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('bot-onboarding-retry-health.png') }); +}); diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts index 83d5744dc2..58718808c6 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -484,6 +484,12 @@ describe('BotOnboardingService', () => { const afterFirst = await test.service.poll(started.sessionId); assert.equal(afterFirst.state, 'waiting', 'a single transient blip must not kill the session'); assert.equal(attempts, 1); + assert.deepEqual(afterFirst.retryHealth, { + category: 'timeout', + consecutiveFailures: 1, + nextRetryAt: 13_000, + nextRetryAfterMs: 7_000, + }); let last = afterFirst; for (let i = 0; i < 12 && last.state !== 'error'; i += 1) { @@ -491,6 +497,86 @@ describe('BotOnboardingService', () => { last = await test.service.poll(started.sessionId); } assert.equal(last.state, 'error', 'repeated consecutive transient failures must go terminal'); + assert.equal(last.retryHealth, undefined, 'terminal sessions must not advertise another retry'); + }); + + it('projects only a finite redacted category and clears retry health after recovery', async () => { + let attempts = 0; + const adapter: BotOnboardingProviderAdapter = { + async start() { return startResult(); }, + async poll() { + attempts += 1; + if (attempts === 1) { + throw new Error('HTTP 503 https://provider.example/poll?token=super-secret credential=hidden'); + } + return { status: 'pending' }; + }, + }; + const test = harness(adapter); + const started = await test.service.start({ provider: 'dingtalk' }); + test.advance(5_000); + const backingOff = await test.service.poll(started.sessionId); + assert.deepEqual(backingOff.retryHealth, { + category: 'server', + consecutiveFailures: 1, + nextRetryAt: 13_000, + nextRetryAfterMs: 7_000, + }); + assert.equal(JSON.stringify(backingOff).includes('super-secret'), false); + assert.equal(JSON.stringify(backingOff).includes('provider.example'), false); + + test.advance(7_000); + const recovered = await test.service.poll(started.sessionId); + assert.equal(recovered.state, 'waiting'); + assert.equal(recovered.retryHealth, undefined); + }); + + it('clears retry health on provider terminal responses and cancellation', async () => { + let attempts = 0; + const adapter: BotOnboardingProviderAdapter = { + async start() { return startResult(); }, + async poll() { + attempts += 1; + if (attempts === 1) throw new Error('HTTP 429 rate limited'); + return { status: 'denied', error: 'Provider denied authorization' }; + }, + }; + const test = harness(adapter); + const first = await test.service.start({ provider: 'dingtalk' }); + test.advance(5_000); + const backingOff = await test.service.poll(first.sessionId); + assert.equal(backingOff.retryHealth?.category, 'rate_limited'); + assert.equal(test.service.cancel(first.sessionId).retryHealth, undefined); + + attempts = 0; + const second = await test.service.start({ provider: 'dingtalk' }); + test.advance(5_000); + assert.equal((await test.service.poll(second.sessionId)).retryHealth?.category, 'rate_limited'); + test.advance(7_000); + const denied = await test.service.poll(second.sessionId); + assert.equal(denied.state, 'denied'); + assert.equal(denied.retryHealth, undefined); + assert.equal((await test.service.poll(second.sessionId)).retryHealth, undefined); + }); + + it('does not project a late transient failure after session supersession', async () => { + const pending = deferred(); + let starts = 0; + const adapter: BotOnboardingProviderAdapter = { + async start() { starts += 1; return startResult(); }, + async poll() { return pending.promise; }, + }; + const test = harness(adapter); + const first = await test.service.start({ provider: 'dingtalk' }); + test.advance(5_000); + const stalePoll = test.service.poll(first.sessionId); + await test.service.start({ provider: 'dingtalk' }); + assert.equal(starts, 2); + pending.reject(new Error('network token=late-super-secret')); + const superseded = await stalePoll; + assert.equal(superseded.state, 'cancelled'); + assert.equal(superseded.retryHealth, undefined); + assert.equal(JSON.stringify(superseded).includes('late-super-secret'), false); }); it('fails immediately on a fatal (non-transient) poll error', async () => { @@ -516,6 +602,7 @@ describe('BotOnboardingService', () => { test.advance(1_001); const expired = await test.service.poll(started.sessionId); assert.equal(expired.state, 'expired'); + assert.equal(expired.retryHealth, undefined); assert.equal(polls, 0); }); diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts new file mode 100644 index 0000000000..f3a1fd7bd4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; + +test('provides concise localized retry health without provider error text', () => { + const zh = getBotSettingsCopy('zh'); + const en = getBotSettingsCopy('en'); + assert.equal( + zh.onboarding.retrying('network', 2, 7), + '网络暂时异常;连续失败 2 次,约 7 秒后自动重试。', + ); + assert.equal( + en.onboarding.retrying('network', 2, 7), + 'The network is temporarily unavailable; 2 consecutive failures. Retrying automatically in about 7s.', + ); +}); + +test('the existing onboarding status surface prefers retry health while present', async () => { + const source = await readFile( + new URL('../../../src/renderer/settings/bot-onboarding-modal.tsx', import.meta.url), + 'utf8', + ); + assert.match(source, /if \(snapshot\?\.retryHealth\)/); + assert.match(source, /shared\.retrying\(/); + assert.match(source, /case 'waiting': return copy\.waiting/); +}); diff --git a/apps/desktop/src/main/bot-onboarding-e2e-fixture.ts b/apps/desktop/src/main/bot-onboarding-e2e-fixture.ts index 0ce8a03b6e..ae5716f665 100644 --- a/apps/desktop/src/main/bot-onboarding-e2e-fixture.ts +++ b/apps/desktop/src/main/bot-onboarding-e2e-fixture.ts @@ -34,11 +34,10 @@ const WAITING_HOLD_TTL_SECONDS = 60 * 60; * They exercise the real main-owned session, IPC, persistence, runtime-effect, * and renderer polling paths without contacting an external IM platform. * - * Scenario-aware: the `settings-bots-onboarding` fixture needs the modal frozen - * in its 'waiting' state so the QR-onboarding capture is stable, so every - * provider holds a fixed QR + long TTL + never-confirming poll. All other - * scenarios keep the scanned → confirmed happy-path adapters the E2E - * onboarding specs rely on. + * Scenario-aware: the `settings-bots-onboarding` fixture holds a fixed QR and + * long TTL while deterministic HTTP 503 poll failures exercise the retry-health + * presentation. All other scenarios keep the scanned → confirmed happy-path + * adapters the E2E onboarding specs rely on. */ export function createE2eFixtureBotOnboardingAdapters(): AdapterMap { if (process.env.MAKA_E2E_FIXTURE === 'settings-bots-onboarding') { @@ -158,11 +157,10 @@ export function createE2eFixtureBotOnboardingAdapters(): AdapterMap { /** * #1233 deferral (settings-bots-onboarding): adapters that hold the modal in - * its 'waiting' state. Every value is FIXED (no Date.now / random) so the - * rendered QR image is byte-identical across runs, the TTL is long - * enough to outlast the fixture settle window, and `poll` never leaves - * 'pending' — so the main service keeps the session 'waiting' and the modal's - * waiting layout stays put for a deterministic fixture state. + * its waiting/backoff state. Every value is FIXED (no Date.now / random) so the + * rendered QR image is byte-identical across runs, the TTL outlasts the fixture + * settle window, and a finite HTTP 503 category exercises the renderer without + * leaking provider text. */ function createWaitingHoldBotOnboardingAdapters(): AdapterMap { function waitingHold(provider: BotOnboardingProvider): BotOnboardingProviderAdapter { @@ -177,7 +175,7 @@ function createWaitingHoldBotOnboardingAdapters(): AdapterMap { }; }, async poll() { - return { status: 'pending' }; + throw new Error('HTTP 503 e2e fixture provider detail must stay in main'); }, }; } diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts index 6ed3ff7535..cf09c04c62 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -24,6 +24,7 @@ import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import type { BotOnboardingBrand, BotOnboardingProvider, + BotOnboardingRetryFailureCategory, BotOnboardingSnapshot, BotOnboardingStartInput, BotOnboardingState, @@ -99,6 +100,7 @@ interface BotOnboardingSession { controller: AbortController; pollPromise?: Promise; pollFailures: number; + pollFailureCategory?: BotOnboardingRetryFailureCategory; identity?: { id?: string; displayName?: string }; error?: string; warning?: string; @@ -206,6 +208,7 @@ export class BotOnboardingService { } if (session.expiresAt !== undefined && session.expiresAt <= this.now()) { session.state = 'expired'; + this.clearRetryHealth(session); return this.snapshot(session); } if (session.nextPollAt > this.now()) return this.snapshot(session); @@ -244,7 +247,7 @@ export class BotOnboardingService { const result = await this.adapters[session.provider].poll(session, session.controller.signal); this.assertCurrent(session); // A response of any kind clears the transient-failure streak. - session.pollFailures = 0; + this.clearRetryHealth(session); switch (result.status) { case 'pending': session.state = 'waiting'; @@ -294,14 +297,17 @@ export class BotOnboardingService { // retry with backoff until enough CONSECUTIVE failures accumulate; only // then surface a terminal error. A definite provider/protocol error is // fatal immediately. - if (isTransientPollError(error)) { + const failureCategory = classifyTransientPollError(error); + if (failureCategory) { session.pollFailures += 1; if (session.pollFailures < MAX_CONSECUTIVE_POLL_FAILURES) { session.pollIntervalMs = Math.min(session.pollIntervalMs + 2_000, MAX_POLL_INTERVAL_MS); session.nextPollAt = this.now() + session.pollIntervalMs; + session.pollFailureCategory = failureCategory; return this.snapshot(session); } } + this.clearRetryHealth(session); session.state = 'error'; session.error = safeProviderError(error); return this.snapshot(session); @@ -421,6 +427,7 @@ export class BotOnboardingService { private cancelSession(session: BotOnboardingSession): void { if (!session.controller.signal.aborted) session.controller.abort(); + this.clearRetryHealth(session); if (session.state !== 'connected' && session.state !== 'expired' && session.state !== 'denied') { session.state = 'cancelled'; } @@ -438,6 +445,11 @@ export class BotOnboardingService { if (!this.isCurrent(session)) throw new Error('Bot onboarding session is no longer active'); } + private clearRetryHealth(session: BotOnboardingSession): void { + session.pollFailures = 0; + session.pollFailureCategory = undefined; + } + private snapshot(session: BotOnboardingSession, includeQrCode = false): BotOnboardingSnapshot { const state = session.state === 'starting' ? 'waiting' : session.state; return { @@ -448,6 +460,16 @@ export class BotOnboardingService { ...(includeQrCode && session.qrCodeDataUrl ? { qrCodeDataUrl: session.qrCodeDataUrl } : {}), ...(session.expiresAt !== undefined ? { expiresAt: session.expiresAt } : {}), nextPollAfterMs: Math.max(0, session.nextPollAt - this.now()), + ...(session.pollFailureCategory && session.pollFailures > 0 + ? { + retryHealth: { + category: session.pollFailureCategory, + consecutiveFailures: session.pollFailures, + nextRetryAt: session.nextPollAt, + nextRetryAfterMs: Math.max(0, session.nextPollAt - this.now()), + }, + } + : {}), canOpenInBrowser: Boolean(session.verificationUrl), ...(session.identity ? { identity: { ...session.identity } } : {}), ...(session.error ? { error: session.error } : {}), @@ -491,19 +513,21 @@ function safeProviderError(error: unknown): string { * fault, server 5xx, or 429 rate limit) versus a fatal provider/protocol error. * User-initiated aborts are filtered out before this runs. */ -function isTransientPollError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; +function classifyTransientPollError(error: unknown): BotOnboardingRetryFailureCategory | undefined { + if (!(error instanceof Error)) return undefined; + if (error.name === 'TimeoutError' || error.name === 'AbortError') return 'timeout'; const message = error.message.toLowerCase(); - if (/fetch failed|network|socket|econn|enotfound|eai_again|und_err|timeout|timed out/.test(message)) { - return true; - } + if (/timeout|timed out/.test(message)) return 'timeout'; const httpMatch = message.match(/http (\d{3})/); if (httpMatch) { const status = Number(httpMatch[1]); - return status === 429 || status >= 500; + if (status === 429) return 'rate_limited'; + if (status >= 500) return 'server'; + } + if (/fetch failed|network|socket|econn|enotfound|eai_again|und_err/.test(message)) { + return 'network'; } - return false; + return undefined; } function channelPatchFromCredential(credential: OnboardingCredential): Partial { diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index b34d2fbca1..f6fee1bfbd 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -19,6 +19,7 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; +import type { BotOnboardingRetryFailureCategory } from '@maka/core/bot-onboarding'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -102,7 +103,7 @@ const zhCopy = { connectedRefreshFailed: (message: string) => `连接已完成,但状态刷新失败:${message}`, close: (title: string) => `关闭${title}`, generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开', done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…', - connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', + connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', retrying: (category: BotOnboardingRetryFailureCategory, count: number, seconds: number) => `${retryCategoryZh(category)};连续失败 ${count} 次,约 ${seconds} 秒后自动重试。`, expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -224,7 +225,7 @@ const enCopy: BotSettingsCopy = { }, onboarding: { providers: { dingtalk: { title: 'Set up DingTalk', ariaLabel: 'Set up DingTalk with a QR code', qrAlt: 'DingTalk setup QR code', subtitle: 'Scan in DingTalk to register the app', waiting: 'Scan with DingTalk and confirm authorization', scanned: 'Scanned. Complete confirmation in DingTalk.' }, feishu: { title: 'Set up Feishu', ariaLabel: 'Set up Feishu with a QR code', qrAlt: 'Feishu setup QR code', subtitle: 'Scan with Feishu to create and configure the bot', waiting: 'Scan with Feishu and confirm creation', scanned: 'Scanned. Complete confirmation in Feishu.' }, wecom: { title: 'Set up WeCom', ariaLabel: 'Set up WeCom with a QR code', qrAlt: 'WeCom setup QR code', subtitle: 'Quick setup creates and connects a WeCom bot', waiting: 'Open WeCom and scan to create the bot', scanned: 'Scanned. Complete confirmation in WeCom.' }, wechat: { title: 'Scan to sign in', ariaLabel: 'WeChat QR sign-in', qrAlt: 'WeChat sign-in QR code', subtitle: 'Scan with WeChat to connect', waiting: 'Scan with WeChat and confirm on your phone', scanned: 'Scanned. Complete confirmation in WeChat.' }, qq: { title: 'Set up QQ', ariaLabel: 'Set up QQ with a QR code', qrAlt: 'QQ setup QR code', subtitle: 'Scan with mobile QQ to create and bind a bot', waiting: 'Scan with mobile QQ and confirm binding', scanned: 'Scanned. Complete confirmation in QQ.' } }, - lark: { title: 'Set up Lark', ariaLabel: 'Set up Lark with a QR code', qrAlt: 'Lark setup QR code', subtitle: 'Scan with Lark to create and configure the bot', waiting: 'Scan with Lark and confirm creation', scanned: 'Scanned. Complete confirmation in Lark.' }, connectedRefreshFailed: (message) => `Connected, but status refresh failed: ${message}`, close: (title) => `Close ${title}`, generatingAria: 'Generating QR code', privacy: 'Credentials stay on this device and are never sent to the renderer or Maka cloud.', openBrowser: 'Cannot scan? Open in browser', done: 'Done', regenerate: 'Generate again', refreshQr: 'Refresh QR code', cancel: 'Cancel', generating: 'Generating a secure QR code…', connecting: 'Authorization complete. Saving credentials and starting connection…', connected: (name) => `${name} connected`, connectedWarning: 'Credentials were saved, but the connection did not start.', expired: 'QR code expired. Generate a new one.', denied: 'Authorization cancelled. Generate a new QR code.', cancelled: 'QR setup cancelled', failed: 'QR setup failed. Try again.', preparing: 'Preparing QR setup…', + lark: { title: 'Set up Lark', ariaLabel: 'Set up Lark with a QR code', qrAlt: 'Lark setup QR code', subtitle: 'Scan with Lark to create and configure the bot', waiting: 'Scan with Lark and confirm creation', scanned: 'Scanned. Complete confirmation in Lark.' }, connectedRefreshFailed: (message) => `Connected, but status refresh failed: ${message}`, close: (title) => `Close ${title}`, generatingAria: 'Generating QR code', privacy: 'Credentials stay on this device and are never sent to the renderer or Maka cloud.', openBrowser: 'Cannot scan? Open in browser', done: 'Done', regenerate: 'Generate again', refreshQr: 'Refresh QR code', cancel: 'Cancel', generating: 'Generating a secure QR code…', connecting: 'Authorization complete. Saving credentials and starting connection…', connected: (name) => `${name} connected`, connectedWarning: 'Credentials were saved, but the connection did not start.', retrying: (category, count, seconds) => `${retryCategoryEn(category)}; ${count} consecutive ${count === 1 ? 'failure' : 'failures'}. Retrying automatically in about ${seconds}s.`, expired: 'QR code expired. Generate a new one.', denied: 'Authorization cancelled. Generate a new QR code.', cancelled: 'QR setup cancelled', failed: 'QR setup failed. Try again.', preparing: 'Preparing QR setup…', }, wechat: { token: 'WeChat Bot Token', tokenPlaceholder: 'Local wechat-bridge Bearer Token', collapseAdvanced: 'Hide advanced settings', expandAdvanced: 'Advanced settings (Official Account / local bridge URL)', bridgeAddress: 'Local bridge URL', appId: 'Official Account App ID', appIdPlaceholder: 'WeChat Official Account App ID', appSecret: 'Official Account App Secret', appSecretPlaceholder: 'WeChat Official Account App Secret', advancedNotice: 'The local bridge defaults to http://127.0.0.1:18400. Official Account App ID and App Secret are used only for Official Account messaging; personal WeChat QR sign-in uses the local bridge.', readQrFailed: 'Could not read a QR code from the local wechat-bridge. Make sure the bridge is running.', title: 'WeChat QR sign-in', subtitle: 'Scan the QR code with WeChat and confirm signing in to the local wechat-bridge on your phone.', close: 'Close WeChat QR sign-in', generating: 'Generating QR code…', loggedIn: 'WeChat is signed in. Return to test the connection or restart the listener.', expired: 'QR code expired', expiredHint: 'Refresh the QR code and scan again to continue signing in.', refreshing: 'Refreshing…', refresh: 'Refresh QR code', qrAlt: 'WeChat sign-in QR code', waiting: 'Waiting for confirmation… Sign-in status refreshes every 3 seconds.', retrying: 'Retrying…', retry: 'Retry', bridgeGenerating: 'The bridge is generating a QR code', bridgeGeneratingHint: 'The QR code appears automatically once ready; you can also fetch it again.', fetching: 'Fetching…', fetchAgain: 'Fetch again' }, }; @@ -238,3 +239,21 @@ const BOT_SETTINGS_COPY = { export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy { return BOT_SETTINGS_COPY[locale]; } + +function retryCategoryZh(category: BotOnboardingRetryFailureCategory): string { + switch (category) { + case 'timeout': return '请求超时'; + case 'network': return '网络暂时异常'; + case 'rate_limited': return '服务请求频率受限'; + case 'server': return '服务端暂时异常'; + } +} + +function retryCategoryEn(category: BotOnboardingRetryFailureCategory): string { + switch (category) { + case 'timeout': return 'The request timed out'; + case 'network': return 'The network is temporarily unavailable'; + case 'rate_limited': return 'The service is rate limiting requests'; + case 'server': return 'The service is temporarily unavailable'; + } +} diff --git a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx index 90496c3070..b6bc8d9024 100644 --- a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx +++ b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx @@ -256,6 +256,14 @@ function statusCopy( const shared = getBotSettingsCopy(locale).onboarding; if (starting) return shared.generating; if (error) return error; + if (snapshot?.retryHealth) { + const seconds = Math.max(1, Math.ceil(snapshot.retryHealth.nextRetryAfterMs / 1_000)); + return shared.retrying( + snapshot.retryHealth.category, + snapshot.retryHealth.consecutiveFailures, + seconds, + ); + } switch (snapshot?.state) { case 'waiting': return copy.waiting; case 'scanned': return copy.scanned; diff --git a/packages/core/src/__tests__/bot-onboarding.test.ts b/packages/core/src/__tests__/bot-onboarding.test.ts new file mode 100644 index 0000000000..6434e480c5 --- /dev/null +++ b/packages/core/src/__tests__/bot-onboarding.test.ts @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { BOT_ONBOARDING_RETRY_FAILURE_CATEGORIES } from '../bot-onboarding.js'; + +test('pins the finite renderer-safe bot onboarding retry categories', () => { + assert.deepEqual(BOT_ONBOARDING_RETRY_FAILURE_CATEGORIES, [ + 'timeout', + 'network', + 'rate_limited', + 'server', + ]); +}); diff --git a/packages/core/src/bot-onboarding.ts b/packages/core/src/bot-onboarding.ts index 9bfbcbdeb4..d2b13d4f6c 100644 --- a/packages/core/src/bot-onboarding.ts +++ b/packages/core/src/bot-onboarding.ts @@ -49,6 +49,24 @@ export interface BotOnboardingStartInput { brand?: BotOnboardingBrand; } +export const BOT_ONBOARDING_RETRY_FAILURE_CATEGORIES = [ + 'timeout', + 'network', + 'rate_limited', + 'server', +] as const; + +export type BotOnboardingRetryFailureCategory = + (typeof BOT_ONBOARDING_RETRY_FAILURE_CATEGORIES)[number]; + +export interface BotOnboardingRetryHealth { + /** Finite, renderer-safe classification. Raw provider failures never cross IPC. */ + category: BotOnboardingRetryFailureCategory; + consecutiveFailures: number; + nextRetryAt: number; + nextRetryAfterMs: number; +} + /** * Renderer-safe projection of a main-process-owned onboarding session. * Provider device codes and final credentials never cross the preload boundary. @@ -61,6 +79,8 @@ export interface BotOnboardingSnapshot { qrCodeDataUrl?: string; expiresAt?: number; nextPollAfterMs: number; + /** Present only while the main-process owner is backing off after a transient failure. */ + retryHealth?: BotOnboardingRetryHealth; canOpenInBrowser: boolean; identity?: { id?: string; From 1d6bc844d16f08900ad91f9469198944617e7e78 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:22:43 +0800 Subject: [PATCH 2/6] fix(desktop): preserve bot copy dependency boundary Generated-by: OpenAI Codex --- apps/desktop/src/renderer/locales/settings-bot-copy.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index f6fee1bfbd..398d828d32 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -19,7 +19,6 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; -import type { BotOnboardingRetryFailureCategory } from '@maka/core/bot-onboarding'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -103,7 +102,7 @@ const zhCopy = { connectedRefreshFailed: (message: string) => `连接已完成,但状态刷新失败:${message}`, close: (title: string) => `关闭${title}`, generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开', done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…', - connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', retrying: (category: BotOnboardingRetryFailureCategory, count: number, seconds: number) => `${retryCategoryZh(category)};连续失败 ${count} 次,约 ${seconds} 秒后自动重试。`, expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', + connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', retrying: (category: string, count: number, seconds: number) => `${retryCategoryZh(category)};连续失败 ${count} 次,约 ${seconds} 秒后自动重试。`, expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -240,20 +239,22 @@ export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy { return BOT_SETTINGS_COPY[locale]; } -function retryCategoryZh(category: BotOnboardingRetryFailureCategory): string { +function retryCategoryZh(category: string): string { switch (category) { case 'timeout': return '请求超时'; case 'network': return '网络暂时异常'; case 'rate_limited': return '服务请求频率受限'; case 'server': return '服务端暂时异常'; + default: return '服务暂时异常'; } } -function retryCategoryEn(category: BotOnboardingRetryFailureCategory): string { +function retryCategoryEn(category: string): string { switch (category) { case 'timeout': return 'The request timed out'; case 'network': return 'The network is temporarily unavailable'; case 'rate_limited': return 'The service is rate limiting requests'; case 'server': return 'The service is temporarily unavailable'; + default: return 'The service is temporarily unavailable'; } } From 111f19b362e5beddf65b836dc289bd8d6a72c888 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:47:19 +0800 Subject: [PATCH 3/6] test(desktop): make onboarding health E2E locale-safe Generated-by: OpenAI Codex --- apps/desktop/e2e/bot-onboarding-retry-health.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts index fe618a7b89..ced28898d5 100644 --- a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts +++ b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts @@ -18,13 +18,18 @@ */ import { expect, test } from './fixtures'; +import { getBotSettingsCopy } from '../src/renderer/locales/settings-bot-copy'; test('bot onboarding shows bounded retry health while preserving the QR', async ({ linkColorWindow: page, }, testInfo) => { const status = page.locator('.settingsBotOnboardingStatus'); - await expect(status).toContainText('服务端暂时异常'); - await expect(status).toContainText('自动重试'); + const expectedStatuses = (['zh', 'en'] as const).map((locale) => + getBotSettingsCopy(locale).onboarding.retrying('server', 1, 3), + ); + await expect(status).toHaveAttribute('data-state', 'waiting'); + await expect.poll(async () => expectedStatuses.includes(await status.innerText())).toBe(true); + await expect(status).not.toContainText('HTTP 503'); await expect(status).not.toContainText('provider detail'); await expect(page.locator('.settingsBotOnboardingQrFrame img')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('bot-onboarding-retry-health.png') }); From 20a24426f85e7106c7a57b00c120c388007262da Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 10:00:17 +0800 Subject: [PATCH 4/6] fix(desktop): align bot onboarding copy with locale catalog --- .../__tests__/bot-onboarding-status-copy.test.ts | 2 +- .../src/renderer/locales/settings-bot-copy.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts index f3a1fd7bd4..5223717889 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-status-copy.test.ts @@ -23,7 +23,7 @@ import { test } from 'node:test'; import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; test('provides concise localized retry health without provider error text', () => { - const zh = getBotSettingsCopy('zh'); + const zh = getBotSettingsCopy('zh-CN'); const en = getBotSettingsCopy('en'); assert.equal( zh.onboarding.retrying('network', 2, 7), diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index 398d828d32..2cea86942d 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -188,7 +188,7 @@ const zhTwCopy = { connectedRefreshFailed: (message: string) => `連線已完成,但狀態重新整理失敗:${message}`, close: (title: string) => `關閉${title}`, generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟', done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…', - connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', + connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', retrying: (category: string, count: number, seconds: number) => `${retryCategoryZhTw(category)};連續失敗 ${count} 次,約 ${seconds} 秒後自動重試。`, expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -249,6 +249,16 @@ function retryCategoryZh(category: string): string { } } +function retryCategoryZhTw(category: string): string { + switch (category) { + case 'timeout': return '請求逾時'; + case 'network': return '網路暫時異常'; + case 'rate_limited': return '服務請求頻率受限'; + case 'server': return '服務端暫時異常'; + default: return '服務暫時異常'; + } +} + function retryCategoryEn(category: string): string { switch (category) { case 'timeout': return 'The request timed out'; From 2af0860e096cbac92ef55648b037af9437889f2c Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 10:28:47 +0800 Subject: [PATCH 5/6] fix(desktop): use resolved locale in bot onboarding e2e --- apps/desktop/e2e/bot-onboarding-retry-health.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts index ced28898d5..05c21bf0d7 100644 --- a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts +++ b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts @@ -24,7 +24,7 @@ test('bot onboarding shows bounded retry health while preserving the QR', async linkColorWindow: page, }, testInfo) => { const status = page.locator('.settingsBotOnboardingStatus'); - const expectedStatuses = (['zh', 'en'] as const).map((locale) => + const expectedStatuses = (['zh-CN', 'en'] as const).map((locale) => getBotSettingsCopy(locale).onboarding.retrying('server', 1, 3), ); await expect(status).toHaveAttribute('data-state', 'waiting'); From 0c2008256f8dcf94a26435e951610f0c0898d481 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 11:41:03 +0800 Subject: [PATCH 6/6] ci: retrigger desktop checks for bot onboarding PR