From 72a1d9537286a89774667db1a138a3a051a1563f Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:14:39 +0800 Subject: [PATCH 01/12] 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 | 269 ++---------------- .../settings/bot-onboarding-modal.tsx | 8 + .../core/src/__tests__/bot-onboarding.test.ts | 31 ++ packages/core/src/bot-onboarding.ts | 20 ++ 9 files changed, 288 insertions(+), 268 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 60603532c6..4f4298c7af 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -527,6 +527,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) { @@ -534,6 +540,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 () => { @@ -559,6 +645,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 516cc0bdbd..cd66a4c712 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -25,6 +25,7 @@ import type { BotOnboardingBrand, BotOnboardingErrorCode, BotOnboardingProvider, + BotOnboardingRetryFailureCategory, BotOnboardingSnapshot, BotOnboardingStartInput, BotOnboardingState, @@ -104,6 +105,7 @@ interface BotOnboardingSession { controller: AbortController; pollPromise?: Promise; pollFailures: number; + pollFailureCategory?: BotOnboardingRetryFailureCategory; identity?: { id?: string; displayName?: string }; error?: string; errorCode?: BotOnboardingErrorCode; @@ -214,6 +216,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); @@ -252,7 +255,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'; @@ -304,14 +307,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); session.errorCode = providerErrorCode(error); @@ -432,6 +438,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'; } @@ -449,6 +456,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 { @@ -459,6 +471,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 } : {}), @@ -509,19 +531,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 a94f960ba7..f6fee1bfbd 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -19,11 +19,9 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; -import type { BotStatusCode, BotTestErrorCode, WechatBridgeQrHintCode } from '@maka/runtime/bots'; -import type { BotOnboardingErrorCode } from '@maka/core/bot-onboarding'; -import type { GeneralizedErrorClass } from '@maka/core/redaction'; +import type { BotOnboardingRetryFailureCategory } from '@maka/core/bot-onboarding'; -import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string @@ -31,32 +29,6 @@ type WidenCopy = T extends string ? (...args: Args) => string : { [K in keyof T]: K extends 'tone' ? T[K] : WidenCopy }; -// Bot transport failures name the platform, not the model service that the -// shared generalized copy describes. -const BOT_TRANSPORT_ERRORS = { - 'zh-CN': { - timeout: '请求超时,请稍后重试', - rate_limited: '请求过于频繁,请稍后重试', - auth_failed: '鉴权失败,请检查凭据', - provider_error: '平台服务暂时不可用,请稍后重试', - network_error: '网络错误,请检查网络和代理设置', - }, - 'zh-TW': { - timeout: '請求逾時,請稍後重試', - rate_limited: '請求過於頻繁,請稍後重試', - auth_failed: '驗證失敗,請檢查憑證', - provider_error: '平台服務暫時無法使用,請稍後重試', - network_error: '網路錯誤,請檢查網路和代理設定', - }, - en: { - timeout: 'Request timed out. Try again later', - rate_limited: 'Too many requests. Try again later', - auth_failed: 'Authentication failed. Check the credentials', - provider_error: 'The platform is temporarily unavailable. Try again later', - network_error: 'Network error. Check the network and proxy settings', - }, -} satisfies UiCatalog>; - const zhCopy = { providers: { telegram: { label: 'Telegram', help: '通过 @BotFather 创建 Bot 并获取 Token' }, @@ -83,45 +55,6 @@ const zhCopy = { unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志', polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无', }, - testHints: { - wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。', - wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。', - } satisfies Record, - statusReasons: { - codes: { - 'slack-disconnected': 'Slack 连接已断开,正在等待重新连接', - disconnected: '连接已断开', - reconnecting: '正在重新连接', - 'stream-failed': '消息接收失败,请检查网络和运行日志', - ...BOT_TRANSPORT_ERRORS['zh-CN'], - 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次', - 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效', - 'send-failed': '消息发送失败,请检查运行日志后重试', - 'get-me-failed': '连接探测失败,请检查网络后重试', - }, - withCode: { - gatewayBot: (code: string) => `获取 Gateway 失败(HTTP ${code})`, - gatewayClosed: (code: string) => `Gateway 连接关闭(${code});正在重连`, - connectionsOpen: (code: string) => `Stream 订阅打开失败(HTTP ${code})`, - streamClosed: (code: string) => `Stream 连接关闭(${code});正在重连`, - sendFailed: (code: string) => `发送失败(HTTP ${code})`, - getAppAccessToken: (code: string) => `获取 access_token 失败(HTTP ${code})`, - }, - }, - testErrors: { - connection_failed: '请检查凭据和网络设置后重试。', - token_missing: '请填写 Bot Token 后再测试。', - token_invalid: 'Bot Token 无效,请检查后重试。', - slack_tokens_missing: '请填写 Slack Bot Token 和 App-Level Token 后再测试。', - feishu_credentials_missing: '请填写 App ID 和 App Secret 后再测试。', - wecom_credentials_missing: '请填写企业微信 Bot ID 和 Secret 后再测试。', - dingtalk_credentials_missing: '请填写钉钉 Client ID(AppKey)和 Client Secret 后再测试。', - dingtalk_no_access_token: '钉钉未返回 access_token,请检查凭据和网络后重试。', - qq_credentials_missing: '请填写 QQ App ID 和 AppSecret 后再测试。', - qq_no_access_token: 'QQ 未返回 access_token,请检查凭据和网络后重试。', - wechat_bridge_url_invalid: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。', - wechat_ilink_credentials_incomplete: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。', - } satisfies Record, overview: { loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序', empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置', @@ -155,15 +88,8 @@ const zhCopy = { dingtalkId: '钉钉应用密钥', dingtalkSecret: '钉钉 Client Secret', wecomBotPlaceholder: '企业微信 AI 应用 Bot ID', wecomBotAria: '企业微信 Bot ID', wecomSecretPlaceholder: 'AI 应用 Secret', wecomSecretAria: '企业微信 Secret', qqId: 'QQ 应用编号', allowedUsersLabel: (count: number, max: number) => `允许的用户 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一个用户 ID,留空表示不限\n例如:123456789', - allowedUsersHelp: (atCap: boolean) => atCap - ? 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。 (已达到上限)' - : 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', - invalidUsers: (entries: readonly string[]) => { - const preview = entries.slice(0, 3).join('、'); - return entries.length > 3 - ? `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${preview} 等 ${entries.length} 项` - : `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${preview}`; - }, + allowedUsersHelp: 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', + limitReached: '(已达到上限)', invalidUsers: (values: string) => `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 项`, }, onboarding: { providers: { @@ -177,14 +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: '准备扫码接入…', - savedNotConnected: '凭据已保存,但连接未建立,可稍后在设置中重试。', - savedNotConnectedDetail: (detail: string) => `凭据已保存,但连接未建立:${detail},可稍后在设置中重试。`, - errors: { - cancelled: '扫码接入已取消。', - ...BOT_TRANSPORT_ERRORS['zh-CN'], - unavailable: '扫码接入暂时不可用,请稍后重试。', - } satisfies Record, + 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 地址)', @@ -222,45 +141,6 @@ const zhTwCopy = { unavailable: '該平台目前不可作為遠端串接管道', stopped: '監聽已停止', detailsInLogs: '執行狀態詳情請見記錄', polling: '長輪詢', gateway: '事件通道', webhook: 'Webhook', none: '無', }, - testHints: { - wechat_bridge_remote_url: '微信掃碼登入只允許存取本機 wechat-bridge,不能指向遠端 URL。', - wechat_bridge_unreachable: '先啟動本機 wechat-bridge,並確認它暴露了 iLink 相容的 /api/weixin/qrcode 或 /qrcode 介面。', - } satisfies Record, - statusReasons: { - codes: { - 'slack-disconnected': 'Slack 連線已中斷,正在等待重新連線', - disconnected: '連線已中斷', - reconnecting: '正在重新連線', - 'stream-failed': '訊息接收失敗,請檢查網路和執行記錄', - ...BOT_TRANSPORT_ERRORS['zh-TW'], - 'rate-limited': '傳送被節流(429);上一則回覆可能截斷,可以請使用者再發一次', - 'polling-timeout': '事件輪詢逾時;可能是網路抖動或代理失效', - 'send-failed': '訊息傳送失敗,請檢查執行記錄後重試', - 'get-me-failed': '連線探測失敗,請檢查網路後重試', - }, - withCode: { - gatewayBot: (code: string) => `取得 Gateway 失敗(HTTP ${code})`, - gatewayClosed: (code: string) => `Gateway 連線關閉(${code});正在重連`, - connectionsOpen: (code: string) => `Stream 訂閱開啟失敗(HTTP ${code})`, - streamClosed: (code: string) => `Stream 連線關閉(${code});正在重連`, - sendFailed: (code: string) => `傳送失敗(HTTP ${code})`, - getAppAccessToken: (code: string) => `取得 access_token 失敗(HTTP ${code})`, - }, - }, - testErrors: { - connection_failed: '請檢查憑證和網路設定後重試。', - token_missing: '請填寫 Bot Token 後再測試。', - token_invalid: 'Bot Token 無效,請檢查後重試。', - slack_tokens_missing: '請填寫 Slack Bot Token 和 App-Level Token 後再測試。', - feishu_credentials_missing: '請填寫 App ID 和 App Secret 後再測試。', - wecom_credentials_missing: '請填寫企業微信 Bot ID 和 Secret 後再測試。', - dingtalk_credentials_missing: '請填寫釘釘 Client ID(AppKey)和 Client Secret 後再測試。', - dingtalk_no_access_token: '釘釘未回傳 access_token,請檢查憑證和網路後重試。', - qq_credentials_missing: '請填寫 QQ App ID 和 AppSecret 後再測試。', - qq_no_access_token: 'QQ 未回傳 access_token,請檢查憑證和網路後重試。', - wechat_bridge_url_invalid: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。', - wechat_ilink_credentials_incomplete: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。', - } satisfies Record, overview: { loadFailed: '遠端串接狀態載入失敗', reload: '重新載入', active: '正在使用', sortHint: '按需要處理、最近活動排序', empty: '還沒有正在使用的管道', emptyHelp: '從下方選擇一個訊息平台開始設定。', more: '串接更多管道', choose: '選擇平台開始設定', @@ -294,15 +174,8 @@ const zhTwCopy = { dingtalkId: '釘釘應用金鑰', dingtalkSecret: '釘釘 Client Secret', wecomBotPlaceholder: '企業微信 AI 應用 Bot ID', wecomBotAria: '企業微信 Bot ID', wecomSecretPlaceholder: 'AI 應用 Secret', wecomSecretAria: '企業微信 Secret', qqId: 'QQ 應用編號', allowedUsersLabel: (count: number, max: number) => `允許的使用者 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一個使用者 ID,留空表示不限\n例如:123456789', - allowedUsersHelp: (atCap: boolean) => atCap - ? 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。 (已達到上限)' - : 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', - invalidUsers: (entries: readonly string[]) => { - const preview = entries.slice(0, 3).join('、'); - return entries.length > 3 - ? `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${preview} 等 ${entries.length} 項` - : `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${preview}`; - }, + allowedUsersHelp: 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', + limitReached: '(已達到上限)', invalidUsers: (values: string) => `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 項`, }, onboarding: { providers: { @@ -317,13 +190,6 @@ const zhTwCopy = { generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟', done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…', connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', - savedNotConnected: '憑證已儲存,但連線未建立,可稍後在設定中重試。', - savedNotConnectedDetail: (detail: string) => `憑證已儲存,但連線未建立:${detail},可稍後在設定中重試。`, - errors: { - cancelled: '掃碼串接已取消。', - ...BOT_TRANSPORT_ERRORS['zh-TW'], - unavailable: '掃碼串接暫時無法使用,請稍後重試。', - } satisfies Record, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -352,69 +218,14 @@ const enCopy: BotSettingsCopy = { }, planned: { label: 'Unavailable', detail: 'This platform is not saved as a remote-access channel or scheduled-task delivery target.', tone: 'neutral' }, status: { disabled: 'Turned off', noToken: 'Waiting for Bot Token', missingFeishuCredentials: 'Waiting for Feishu App ID or App Secret', feishuDomainRequired: 'Feishu credentials are valid; add the event subscription domain', feishuEventsNotConnected: 'Feishu credentials are valid; connect the event callback', unavailable: 'This platform cannot currently be used for remote access', stopped: 'Listener stopped', detailsInLogs: 'See logs for runtime details', polling: 'Long polling', gateway: 'Event channel', webhook: 'Webhook', none: 'None' }, - testHints: { - wechat_bridge_remote_url: 'WeChat QR sign-in only accepts the local wechat-bridge, not a remote URL.', - wechat_bridge_unreachable: 'Start the local wechat-bridge first and make sure it exposes an iLink-compatible /api/weixin/qrcode or /qrcode endpoint.', - } satisfies Record, - statusReasons: { - codes: { - 'slack-disconnected': 'Slack disconnected; waiting to reconnect', - disconnected: 'Connection lost', - reconnecting: 'Reconnecting', - 'stream-failed': 'Failed to receive messages. Check the network and runtime logs', - ...BOT_TRANSPORT_ERRORS.en, - 'rate-limited': 'Sending was throttled (429); the last reply may be truncated, so ask the user to resend', - 'polling-timeout': 'Event polling timed out; the network or proxy may be unstable', - 'send-failed': 'Message send failed. Check the runtime logs and try again', - 'get-me-failed': 'Connection probe failed. Check the network and try again', - }, - withCode: { - gatewayBot: (code) => `Failed to fetch the Gateway (HTTP ${code})`, - gatewayClosed: (code) => `Gateway connection closed (${code}); reconnecting`, - connectionsOpen: (code) => `Failed to open the Stream subscription (HTTP ${code})`, - streamClosed: (code) => `Stream connection closed (${code}); reconnecting`, - sendFailed: (code) => `Send failed (HTTP ${code})`, - getAppAccessToken: (code) => `Failed to fetch access_token (HTTP ${code})`, - }, - }, - testErrors: { - connection_failed: 'Check the credentials and network settings, then try again.', - token_missing: 'Enter a Bot Token before testing the connection.', - token_invalid: 'The Bot Token is invalid. Check it and try again.', - slack_tokens_missing: 'Enter a Slack Bot Token and App-Level Token before testing the connection.', - feishu_credentials_missing: 'Enter an App ID and App Secret before testing the connection.', - wecom_credentials_missing: 'Enter a WeCom Bot ID and Secret before testing the connection.', - dingtalk_credentials_missing: 'Enter a DingTalk Client ID (AppKey) and Client Secret before testing the connection.', - dingtalk_no_access_token: 'DingTalk returned no access_token. Check the credentials and network, then try again.', - qq_credentials_missing: 'Enter a QQ App ID and AppSecret before testing the connection.', - qq_no_access_token: 'QQ returned no access_token. Check the credentials and network, then try again.', - wechat_bridge_url_invalid: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.', - wechat_ilink_credentials_incomplete: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.', - } satisfies Record, overview: { loadFailed: 'Failed to load remote-access status', reload: 'Reload', active: 'In use', sortHint: 'Sorted by attention needed and recent activity', empty: 'No channels are in use', emptyHelp: 'Choose a messaging platform below to begin setup.', more: 'Connect more channels', choose: 'Choose a platform to begin setup', listening: 'Listening', manageAria: (name, status) => `Manage ${name}, ${status}`, connectAria: (name) => `Connect ${name}` }, page: { saveFailed: (name) => `Failed to save ${name}`, loadFailed: 'Failed to load remote-access status', refreshFailed: 'Failed to refresh remote-access status', credentialVerified: (name) => `${name} credentials verified`, credentialVerifiedDetail: 'The credential check passed.', credentialTestFailed: (name) => `${name} credential test failed`, credentialTestFailedDetail: 'Check the credentials and network settings, then try again.', testError: (name) => `${name} test error`, listening: (name) => `${name} is listening`, notListening: (name) => `${name} did not start listening`, startFailed: (name) => `Failed to start ${name}`, disconnectTitle: 'Disconnect WeChat?', disconnectDescription: 'This clears the saved local QR sign-in credentials. You will need to scan again to keep using WeChat.', disconnect: 'Disconnect', cancel: 'Cancel', disconnected: 'WeChat disconnected', credentialsCleared: 'Local linked-session credentials cleared.' }, detail: { - unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', - allowedUsersHelp: (atCap) => atCap - ? 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored. (limit reached)' - : 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', - invalidUsers: (entries) => { - const preview = entries.slice(0, 3).join(', '); - return entries.length > 3 - ? `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${preview} and ${entries.length - 3} more` - : `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${preview}`; - }, + unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', allowedUsersHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', limitReached: '(limit reached)', invalidUsers: (values) => `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${values}`, moreInvalid: (count) => ` and ${count} more`, }, 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…', - savedNotConnected: 'Credentials were saved, but the connection did not start. Retry from settings later.', - savedNotConnectedDetail: (detail) => `Credentials were saved, but the connection did not start: ${detail}. Retry from settings later.`, - errors: { - cancelled: 'QR setup was cancelled.', - ...BOT_TRANSPORT_ERRORS.en, - unavailable: 'QR setup is temporarily unavailable. Try again later.', - } satisfies Record, + 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' }, }; @@ -429,56 +240,20 @@ export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy { return BOT_SETTINGS_COPY[locale]; } -const BOT_STATUS_REASON_PATTERNS: ReadonlyArray<{ - pattern: RegExp; - key: keyof BotSettingsCopy['statusReasons']['withCode']; -}> = [ - { pattern: /^gateway-bot-(\d+)$/, key: 'gatewayBot' }, - { pattern: /^gateway-closed-(\d+)$/, key: 'gatewayClosed' }, - { pattern: /^connections-open-(\d+)$/, key: 'connectionsOpen' }, - { pattern: /^stream-closed-(\d+)$/, key: 'streamClosed' }, - { pattern: /^send-failed-(\d+)$/, key: 'sendFailed' }, - { pattern: /^getAppAccessToken-(\d+)$/, key: 'getAppAccessToken' }, -]; - -/** Localize a machine-readable bridge status reason such as `gateway-closed-4004`. - * A non-empty reason always resolves (unknown codes degrade to `detailsInLogs`), - * so the string overload is definite; only an absent reason yields undefined. */ -export function botStatusReasonMessage(reason: string, locale: UiLocale): string; -export function botStatusReasonMessage( - reason: string | undefined, - locale: UiLocale, -): string | undefined; -export function botStatusReasonMessage( - reason: string | undefined, - locale: UiLocale, -): string | undefined { - if (!reason) return undefined; - return botStatusReasonCopy(reason, locale) ?? BOT_SETTINGS_COPY[locale].status.detailsInLogs; -} - -/** Copy for a bridge status reason the catalog knows; `undefined` for anything else. */ -export function botStatusReasonCopy(reason: string, locale: UiLocale): string | undefined { - const settings = BOT_SETTINGS_COPY[locale]; - const copy = settings.statusReasons; - const fixed = lookupCopy( - { - ...copy.codes, - ...settings.testErrors, - disabled: settings.status.disabled, - stopped: settings.status.stopped, - } satisfies Record, - reason, - ); - if (fixed) return fixed; - for (const { pattern, key } of BOT_STATUS_REASON_PATTERNS) { - const match = pattern.exec(reason); - if (match) return copy.withCode[key](match[1]); +function retryCategoryZh(category: BotOnboardingRetryFailureCategory): string { + switch (category) { + case 'timeout': return '请求超时'; + case 'network': return '网络暂时异常'; + case 'rate_limited': return '服务请求频率受限'; + case 'server': return '服务端暂时异常'; } - return undefined; } -export function botOnboardingErrorMessage(errorCode: string | undefined, locale: UiLocale): string { - const shared = BOT_SETTINGS_COPY[locale].onboarding; - return lookupCopy(shared.errors, errorCode) ?? shared.failed; +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 48c6a32348..332bb318bc 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 a6d8aa018d..27bb31b444 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 f89cf309005360167ec615cd632c1261c0315dcc Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:22:43 +0800 Subject: [PATCH 02/12] 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 3072759a592ec08e51bad7d7bfe290963eb74a5d Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 16:47:19 +0800 Subject: [PATCH 03/12] 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 7e2d7869cdd605c933d7c558077bfec1dd34ceba Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 10:00:17 +0800 Subject: [PATCH 04/12] 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 374f5ba46565ae69bb116b4bea4430b870844daf Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 10:28:47 +0800 Subject: [PATCH 05/12] 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 f4e82f9f4948450c53d43e9c7216566680506546 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 11:41:03 +0800 Subject: [PATCH 06/12] ci: retrigger desktop checks for bot onboarding PR From 7f761099470569713e941f306912f2871bba29c7 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 14:27:44 +0800 Subject: [PATCH 07/12] fix(desktop): keep post-confirmation failures terminal --- .../__tests__/bot-onboarding-main.test.ts | 24 +++++++++++++++++++ apps/desktop/src/main/bot-onboarding-main.ts | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) 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 4f4298c7af..b4b3e1a9ec 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -422,6 +422,30 @@ describe('BotOnboardingService', () => { assert.equal(connected.warningCode, undefined); }); + it('does not advertise poll retry health for post-confirmation failures', async () => { + const adapter: BotOnboardingProviderAdapter = { + async start() { return startResult(); }, + async poll() { + return { + status: 'confirmed', + credential: { + provider: 'dingtalk', + clientId: 'public-client-id', + clientSecret: 'private-client-secret', + }, + }; + }, + }; + const test = harness(adapter, async () => { + throw Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' }); + }); + const started = await test.service.start({ provider: 'dingtalk' }); + test.advance(5_000); + const result = await test.service.poll(started.sessionId); + assert.equal(result.state, 'error'); + assert.equal(result.retryHealth, undefined); + }); + it('invalidates an older session when the same provider starts again', async () => { const pending = deferred(); let polls = 0; diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts index cd66a4c712..cad332ee15 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -251,8 +251,10 @@ export class BotOnboardingService { } private async pollOnce(session: BotOnboardingSession): Promise { + let providerPollSettled = false; try { const result = await this.adapters[session.provider].poll(session, session.controller.signal); + providerPollSettled = true; this.assertCurrent(session); // A response of any kind clears the transient-failure streak. this.clearRetryHealth(session); @@ -307,7 +309,7 @@ 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. - const failureCategory = classifyTransientPollError(error); + const failureCategory = providerPollSettled ? undefined : classifyTransientPollError(error); if (failureCategory) { session.pollFailures += 1; if (session.pollFailures < MAX_CONSECUTIVE_POLL_FAILURES) { From 8f437139a467efa49db6cd1f4e7b0cd38b5fac10 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 14:56:50 +0800 Subject: [PATCH 08/12] test(desktop): budget bot onboarding Electron test --- apps/desktop/e2e-budget.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index bdc49d7bde..187339d505 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -5,6 +5,10 @@ "Every spec below records the Electron-owned mechanism it needs. If you cannot name one, the test does not belong here." ], "specs": { + "bot-onboarding-retry-health.spec.ts": { + "tests": 1, + "electron": "the deterministic bot adapter, IPC, persistence, and renderer polling must run through the real Electron main process without contacting an external provider" + }, "composer-directory-reference.spec.ts": { "tests": 1, "electron": "the folder reference has to survive a renderer reload and still agree with the Host's session record" From a558d9eed556b20f5b2083f1eab59025630f6e37 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 15:04:06 +0800 Subject: [PATCH 09/12] fix(desktop): retain locale catalog dependency --- apps/desktop/src/renderer/locales/settings-bot-copy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index 2cea86942d..8309b8310c 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -20,7 +20,7 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { lookupCopy, type UiCatalog, type UiLocale } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string From fd0b283f5651e27d2c8258f9edb7c50a542b7e71 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 15:13:29 +0800 Subject: [PATCH 10/12] fix(desktop): restore bot settings catalog compatibility --- .../src/renderer/locales/settings-bot-copy.ts | 256 +++++++++++++++++- 1 file changed, 250 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index 8309b8310c..e351625dab 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -19,8 +19,11 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; +import type { BotStatusCode, BotTestErrorCode, WechatBridgeQrHintCode } from '@maka/runtime/bots'; +import type { BotOnboardingErrorCode } from '@maka/core/bot-onboarding'; +import type { GeneralizedErrorClass } from '@maka/core/redaction'; -import { lookupCopy, type UiCatalog, type UiLocale } from '@maka/core/ui-locale'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string @@ -28,6 +31,32 @@ type WidenCopy = T extends string ? (...args: Args) => string : { [K in keyof T]: K extends 'tone' ? T[K] : WidenCopy }; +// Bot transport failures name the platform, not the model service that the +// shared generalized copy describes. +const BOT_TRANSPORT_ERRORS = { + 'zh-CN': { + timeout: '请求超时,请稍后重试', + rate_limited: '请求过于频繁,请稍后重试', + auth_failed: '鉴权失败,请检查凭据', + provider_error: '平台服务暂时不可用,请稍后重试', + network_error: '网络错误,请检查网络和代理设置', + }, + 'zh-TW': { + timeout: '請求逾時,請稍後重試', + rate_limited: '請求過於頻繁,請稍後重試', + auth_failed: '驗證失敗,請檢查憑證', + provider_error: '平台服務暫時無法使用,請稍後重試', + network_error: '網路錯誤,請檢查網路和代理設定', + }, + en: { + timeout: 'Request timed out. Try again later', + rate_limited: 'Too many requests. Try again later', + auth_failed: 'Authentication failed. Check the credentials', + provider_error: 'The platform is temporarily unavailable. Try again later', + network_error: 'Network error. Check the network and proxy settings', + }, +} satisfies UiCatalog>; + const zhCopy = { providers: { telegram: { label: 'Telegram', help: '通过 @BotFather 创建 Bot 并获取 Token' }, @@ -54,6 +83,45 @@ const zhCopy = { unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志', polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无', }, + testHints: { + wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。', + } satisfies Record, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack 连接已断开,正在等待重新连接', + disconnected: '连接已断开', + reconnecting: '正在重新连接', + 'stream-failed': '消息接收失败,请检查网络和运行日志', + ...BOT_TRANSPORT_ERRORS['zh-CN'], + 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次', + 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效', + 'send-failed': '消息发送失败,请检查运行日志后重试', + 'get-me-failed': '连接探测失败,请检查网络后重试', + }, + withCode: { + gatewayBot: (code: string) => `获取 Gateway 失败(HTTP ${code})`, + gatewayClosed: (code: string) => `Gateway 连接关闭(${code});正在重连`, + connectionsOpen: (code: string) => `Stream 订阅打开失败(HTTP ${code})`, + streamClosed: (code: string) => `Stream 连接关闭(${code});正在重连`, + sendFailed: (code: string) => `发送失败(HTTP ${code})`, + getAppAccessToken: (code: string) => `获取 access_token 失败(HTTP ${code})`, + }, + }, + testErrors: { + connection_failed: '请检查凭据和网络设置后重试。', + token_missing: '请填写 Bot Token 后再测试。', + token_invalid: 'Bot Token 无效,请检查后重试。', + slack_tokens_missing: '请填写 Slack Bot Token 和 App-Level Token 后再测试。', + feishu_credentials_missing: '请填写 App ID 和 App Secret 后再测试。', + wecom_credentials_missing: '请填写企业微信 Bot ID 和 Secret 后再测试。', + dingtalk_credentials_missing: '请填写钉钉 Client ID(AppKey)和 Client Secret 后再测试。', + dingtalk_no_access_token: '钉钉未返回 access_token,请检查凭据和网络后重试。', + qq_credentials_missing: '请填写 QQ App ID 和 AppSecret 后再测试。', + qq_no_access_token: 'QQ 未返回 access_token,请检查凭据和网络后重试。', + wechat_bridge_url_invalid: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_ilink_credentials_incomplete: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。', + } satisfies Record, overview: { loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序', empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置', @@ -87,8 +155,15 @@ const zhCopy = { dingtalkId: '钉钉应用密钥', dingtalkSecret: '钉钉 Client Secret', wecomBotPlaceholder: '企业微信 AI 应用 Bot ID', wecomBotAria: '企业微信 Bot ID', wecomSecretPlaceholder: 'AI 应用 Secret', wecomSecretAria: '企业微信 Secret', qqId: 'QQ 应用编号', allowedUsersLabel: (count: number, max: number) => `允许的用户 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一个用户 ID,留空表示不限\n例如:123456789', - allowedUsersHelp: 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', - limitReached: '(已达到上限)', invalidUsers: (values: string) => `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 项`, + allowedUsersHelp: (atCap: boolean) => atCap + ? 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。 (已达到上限)' + : 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', + invalidUsers: (entries: readonly string[]) => { + const preview = entries.slice(0, 3).join('、'); + return entries.length > 3 + ? `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${preview} 等 ${entries.length} 项` + : `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${preview}`; + }, }, onboarding: { providers: { @@ -103,6 +178,13 @@ const zhCopy = { generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开', done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…', connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', retrying: (category: string, count: number, seconds: number) => `${retryCategoryZh(category)};连续失败 ${count} 次,约 ${seconds} 秒后自动重试。`, expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', + savedNotConnected: '凭据已保存,但连接未建立,可稍后在设置中重试。', + savedNotConnectedDetail: (detail: string) => `凭据已保存,但连接未建立:${detail},可稍后在设置中重试。`, + errors: { + cancelled: '扫码接入已取消。', + ...BOT_TRANSPORT_ERRORS['zh-CN'], + unavailable: '扫码接入暂时不可用,请稍后重试。', + } satisfies Record, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -140,6 +222,45 @@ const zhTwCopy = { unavailable: '該平台目前不可作為遠端串接管道', stopped: '監聽已停止', detailsInLogs: '執行狀態詳情請見記錄', polling: '長輪詢', gateway: '事件通道', webhook: 'Webhook', none: '無', }, + testHints: { + wechat_bridge_remote_url: '微信掃碼登入只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_bridge_unreachable: '先啟動本機 wechat-bridge,並確認它暴露了 iLink 相容的 /api/weixin/qrcode 或 /qrcode 介面。', + } satisfies Record, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack 連線已中斷,正在等待重新連線', + disconnected: '連線已中斷', + reconnecting: '正在重新連線', + 'stream-failed': '訊息接收失敗,請檢查網路和執行記錄', + ...BOT_TRANSPORT_ERRORS['zh-TW'], + 'rate-limited': '傳送被節流(429);上一則回覆可能截斷,可以請使用者再發一次', + 'polling-timeout': '事件輪詢逾時;可能是網路抖動或代理失效', + 'send-failed': '訊息傳送失敗,請檢查執行記錄後重試', + 'get-me-failed': '連線探測失敗,請檢查網路後重試', + }, + withCode: { + gatewayBot: (code: string) => `取得 Gateway 失敗(HTTP ${code})`, + gatewayClosed: (code: string) => `Gateway 連線關閉(${code});正在重連`, + connectionsOpen: (code: string) => `Stream 訂閱開啟失敗(HTTP ${code})`, + streamClosed: (code: string) => `Stream 連線關閉(${code});正在重連`, + sendFailed: (code: string) => `傳送失敗(HTTP ${code})`, + getAppAccessToken: (code: string) => `取得 access_token 失敗(HTTP ${code})`, + }, + }, + testErrors: { + connection_failed: '請檢查憑證和網路設定後重試。', + token_missing: '請填寫 Bot Token 後再測試。', + token_invalid: 'Bot Token 無效,請檢查後重試。', + slack_tokens_missing: '請填寫 Slack Bot Token 和 App-Level Token 後再測試。', + feishu_credentials_missing: '請填寫 App ID 和 App Secret 後再測試。', + wecom_credentials_missing: '請填寫企業微信 Bot ID 和 Secret 後再測試。', + dingtalk_credentials_missing: '請填寫釘釘 Client ID(AppKey)和 Client Secret 後再測試。', + dingtalk_no_access_token: '釘釘未回傳 access_token,請檢查憑證和網路後重試。', + qq_credentials_missing: '請填寫 QQ App ID 和 AppSecret 後再測試。', + qq_no_access_token: 'QQ 未回傳 access_token,請檢查憑證和網路後重試。', + wechat_bridge_url_invalid: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_ilink_credentials_incomplete: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。', + } satisfies Record, overview: { loadFailed: '遠端串接狀態載入失敗', reload: '重新載入', active: '正在使用', sortHint: '按需要處理、最近活動排序', empty: '還沒有正在使用的管道', emptyHelp: '從下方選擇一個訊息平台開始設定。', more: '串接更多管道', choose: '選擇平台開始設定', @@ -173,8 +294,15 @@ const zhTwCopy = { dingtalkId: '釘釘應用金鑰', dingtalkSecret: '釘釘 Client Secret', wecomBotPlaceholder: '企業微信 AI 應用 Bot ID', wecomBotAria: '企業微信 Bot ID', wecomSecretPlaceholder: 'AI 應用 Secret', wecomSecretAria: '企業微信 Secret', qqId: 'QQ 應用編號', allowedUsersLabel: (count: number, max: number) => `允許的使用者 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一個使用者 ID,留空表示不限\n例如:123456789', - allowedUsersHelp: 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', - limitReached: '(已達到上限)', invalidUsers: (values: string) => `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 項`, + allowedUsersHelp: (atCap: boolean) => atCap + ? 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。 (已達到上限)' + : 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', + invalidUsers: (entries: readonly string[]) => { + const preview = entries.slice(0, 3).join('、'); + return entries.length > 3 + ? `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${preview} 等 ${entries.length} 項` + : `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${preview}`; + }, }, onboarding: { providers: { @@ -189,6 +317,13 @@ const zhTwCopy = { generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟', done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…', connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', retrying: (category: string, count: number, seconds: number) => `${retryCategoryZhTw(category)};連續失敗 ${count} 次,約 ${seconds} 秒後自動重試。`, expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', + savedNotConnected: '憑證已儲存,但連線未建立,可稍後在設定中重試。', + savedNotConnectedDetail: (detail: string) => `憑證已儲存,但連線未建立:${detail},可稍後在設定中重試。`, + errors: { + cancelled: '掃碼串接已取消。', + ...BOT_TRANSPORT_ERRORS['zh-TW'], + unavailable: '掃碼串接暫時無法使用,請稍後重試。', + } satisfies Record, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -217,14 +352,69 @@ const enCopy: BotSettingsCopy = { }, planned: { label: 'Unavailable', detail: 'This platform is not saved as a remote-access channel or scheduled-task delivery target.', tone: 'neutral' }, status: { disabled: 'Turned off', noToken: 'Waiting for Bot Token', missingFeishuCredentials: 'Waiting for Feishu App ID or App Secret', feishuDomainRequired: 'Feishu credentials are valid; add the event subscription domain', feishuEventsNotConnected: 'Feishu credentials are valid; connect the event callback', unavailable: 'This platform cannot currently be used for remote access', stopped: 'Listener stopped', detailsInLogs: 'See logs for runtime details', polling: 'Long polling', gateway: 'Event channel', webhook: 'Webhook', none: 'None' }, + testHints: { + wechat_bridge_remote_url: 'WeChat QR sign-in only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_unreachable: 'Start the local wechat-bridge first and make sure it exposes an iLink-compatible /api/weixin/qrcode or /qrcode endpoint.', + } satisfies Record, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack disconnected; waiting to reconnect', + disconnected: 'Connection lost', + reconnecting: 'Reconnecting', + 'stream-failed': 'Failed to receive messages. Check the network and runtime logs', + ...BOT_TRANSPORT_ERRORS.en, + 'rate-limited': 'Sending was throttled (429); the last reply may be truncated, so ask the user to resend', + 'polling-timeout': 'Event polling timed out; the network or proxy may be unstable', + 'send-failed': 'Message send failed. Check the runtime logs and try again', + 'get-me-failed': 'Connection probe failed. Check the network and try again', + }, + withCode: { + gatewayBot: (code) => `Failed to fetch the Gateway (HTTP ${code})`, + gatewayClosed: (code) => `Gateway connection closed (${code}); reconnecting`, + connectionsOpen: (code) => `Failed to open the Stream subscription (HTTP ${code})`, + streamClosed: (code) => `Stream connection closed (${code}); reconnecting`, + sendFailed: (code) => `Send failed (HTTP ${code})`, + getAppAccessToken: (code) => `Failed to fetch access_token (HTTP ${code})`, + }, + }, + testErrors: { + connection_failed: 'Check the credentials and network settings, then try again.', + token_missing: 'Enter a Bot Token before testing the connection.', + token_invalid: 'The Bot Token is invalid. Check it and try again.', + slack_tokens_missing: 'Enter a Slack Bot Token and App-Level Token before testing the connection.', + feishu_credentials_missing: 'Enter an App ID and App Secret before testing the connection.', + wecom_credentials_missing: 'Enter a WeCom Bot ID and Secret before testing the connection.', + dingtalk_credentials_missing: 'Enter a DingTalk Client ID (AppKey) and Client Secret before testing the connection.', + dingtalk_no_access_token: 'DingTalk returned no access_token. Check the credentials and network, then try again.', + qq_credentials_missing: 'Enter a QQ App ID and AppSecret before testing the connection.', + qq_no_access_token: 'QQ returned no access_token. Check the credentials and network, then try again.', + wechat_bridge_url_invalid: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.', + wechat_ilink_credentials_incomplete: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.', + } satisfies Record, overview: { loadFailed: 'Failed to load remote-access status', reload: 'Reload', active: 'In use', sortHint: 'Sorted by attention needed and recent activity', empty: 'No channels are in use', emptyHelp: 'Choose a messaging platform below to begin setup.', more: 'Connect more channels', choose: 'Choose a platform to begin setup', listening: 'Listening', manageAria: (name, status) => `Manage ${name}, ${status}`, connectAria: (name) => `Connect ${name}` }, page: { saveFailed: (name) => `Failed to save ${name}`, loadFailed: 'Failed to load remote-access status', refreshFailed: 'Failed to refresh remote-access status', credentialVerified: (name) => `${name} credentials verified`, credentialVerifiedDetail: 'The credential check passed.', credentialTestFailed: (name) => `${name} credential test failed`, credentialTestFailedDetail: 'Check the credentials and network settings, then try again.', testError: (name) => `${name} test error`, listening: (name) => `${name} is listening`, notListening: (name) => `${name} did not start listening`, startFailed: (name) => `Failed to start ${name}`, disconnectTitle: 'Disconnect WeChat?', disconnectDescription: 'This clears the saved local QR sign-in credentials. You will need to scan again to keep using WeChat.', disconnect: 'Disconnect', cancel: 'Cancel', disconnected: 'WeChat disconnected', credentialsCleared: 'Local linked-session credentials cleared.' }, detail: { - unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', allowedUsersHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', limitReached: '(limit reached)', invalidUsers: (values) => `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${values}`, moreInvalid: (count) => ` and ${count} more`, + unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', + allowedUsersHelp: (atCap) => atCap + ? 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored. (limit reached)' + : 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', + invalidUsers: (entries) => { + const preview = entries.slice(0, 3).join(', '); + return entries.length > 3 + ? `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${preview} and ${entries.length - 3} more` + : `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${preview}`; + }, }, 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.', 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…', + savedNotConnected: 'Credentials were saved, but the connection did not start. Retry from settings later.', + savedNotConnectedDetail: (detail) => `Credentials were saved, but the connection did not start: ${detail}. Retry from settings later.`, + errors: { + cancelled: 'QR setup was cancelled.', + ...BOT_TRANSPORT_ERRORS.en, + unavailable: 'QR setup is temporarily unavailable. Try again later.', + } satisfies Record, }, 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' }, }; @@ -268,3 +458,57 @@ function retryCategoryEn(category: string): string { default: return 'The service is temporarily unavailable'; } } + +const BOT_STATUS_REASON_PATTERNS: ReadonlyArray<{ + pattern: RegExp; + key: keyof BotSettingsCopy['statusReasons']['withCode']; +}> = [ + { pattern: /^gateway-bot-(\d+)$/, key: 'gatewayBot' }, + { pattern: /^gateway-closed-(\d+)$/, key: 'gatewayClosed' }, + { pattern: /^connections-open-(\d+)$/, key: 'connectionsOpen' }, + { pattern: /^stream-closed-(\d+)$/, key: 'streamClosed' }, + { pattern: /^send-failed-(\d+)$/, key: 'sendFailed' }, + { pattern: /^getAppAccessToken-(\d+)$/, key: 'getAppAccessToken' }, +]; + +/** Localize a machine-readable bridge status reason such as `gateway-closed-4004`. + * A non-empty reason always resolves (unknown codes degrade to `detailsInLogs`), + * so the string overload is definite; only an absent reason yields undefined. */ +export function botStatusReasonMessage(reason: string, locale: UiLocale): string; +export function botStatusReasonMessage( + reason: string | undefined, + locale: UiLocale, +): string | undefined; +export function botStatusReasonMessage( + reason: string | undefined, + locale: UiLocale, +): string | undefined { + if (!reason) return undefined; + return botStatusReasonCopy(reason, locale) ?? BOT_SETTINGS_COPY[locale].status.detailsInLogs; +} + +/** Copy for a bridge status reason the catalog knows; `undefined` for anything else. */ +export function botStatusReasonCopy(reason: string, locale: UiLocale): string | undefined { + const settings = BOT_SETTINGS_COPY[locale]; + const copy = settings.statusReasons; + const fixed = lookupCopy( + { + ...copy.codes, + ...settings.testErrors, + disabled: settings.status.disabled, + stopped: settings.status.stopped, + } satisfies Record, + reason, + ); + if (fixed) return fixed; + for (const { pattern, key } of BOT_STATUS_REASON_PATTERNS) { + const match = pattern.exec(reason); + if (match) return copy.withCode[key](match[1]); + } + return undefined; +} + +export function botOnboardingErrorMessage(errorCode: string | undefined, locale: UiLocale): string { + const shared = BOT_SETTINGS_COPY[locale].onboarding; + return lookupCopy(shared.errors, errorCode) ?? shared.failed; +} From 3b862f464075a82941a29eef7548d52de3735f23 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 15:24:35 +0800 Subject: [PATCH 11/12] test(desktop): use registered e2e window fixture --- 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 05c21bf0d7..a2a66fb506 100644 --- a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts +++ b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts @@ -21,7 +21,7 @@ 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, + window: page, }, testInfo) => { const status = page.locator('.settingsBotOnboardingStatus'); const expectedStatuses = (['zh-CN', 'en'] as const).map((locale) => From ebe9abe00dc8832d3ee35c6b46b3adfbc3e84ca5 Mon Sep 17 00:00:00 2001 From: faith_liu Date: Sun, 6 Sep 2026 15:46:04 +0800 Subject: [PATCH 12/12] test(desktop): exercise bot onboarding retry health --- apps/desktop/e2e/bot-onboarding-retry-health.spec.ts | 2 +- apps/desktop/e2e/fixtures.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 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 a2a66fb506..188d69049b 100644 --- a/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts +++ b/apps/desktop/e2e/bot-onboarding-retry-health.spec.ts @@ -21,7 +21,7 @@ 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 ({ - window: page, + botOnboardingWindow: page, }, testInfo) => { const status = page.locator('.settingsBotOnboardingStatus'); const expectedStatuses = (['zh-CN', 'en'] as const).map((locale) => diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index b3eac80252..7fa8c30459 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -517,6 +517,7 @@ async function withE2eWindow( type E2eTestFixtures = { window: Page; + botOnboardingWindow: Page; gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; projectSidebarWindow: Page; @@ -551,6 +552,14 @@ export const test = base.extend({ window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN' }, use); }, + botOnboardingWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '.settingsSurface', + e2eFixtureScenario: 'settings-bots-onboarding', + locale: 'zh-CN', + }, use); + }, gitReviewWindow: async ({}, use) => { await withE2eWindow( {