From 45394c8ffa478443fc1a93a97799728411251347 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Thu, 3 Sep 2026 01:34:09 -0700 Subject: [PATCH] fix(i18n): emit bot expected-result codes from producers Bot test, onboarding, and bridge producers emitted zh prose that reached en users verbatim, and the renderer sniffed CJK to decide whether to show it. Producers now return stable codes; the settings-bot catalog maps each code per locale with an explicit unknown fallback. Generated-by: Claude Code --- .../__tests__/bot-onboarding-main.test.ts | 6 +- .../__tests__/settings-ipc-helpers.test.ts | 4 +- .../settings-test-result-copy.test.ts | 15 +++ apps/desktop/src/main/bot-incoming-main.ts | 2 + apps/desktop/src/main/bot-onboarding-main.ts | 36 +++++-- .../src/main/settings-bots-ipc-main.ts | 4 +- apps/desktop/src/main/settings-ipc-helpers.ts | 42 ++++---- .../src/renderer/locales/settings-bot-copy.ts | 102 ++++++++++++++++++ .../src/renderer/settings/bot-chat-detail.tsx | 7 +- .../renderer/settings/bot-chat-overview.tsx | 4 +- .../settings/bot-onboarding-modal.tsx | 10 +- .../settings/bot-settings-view-model.ts | 4 +- .../renderer/settings/bot-wechat-login.tsx | 6 +- packages/core/src/__tests__/redaction.test.ts | 13 +++ packages/core/src/bot-events.ts | 84 ++++----------- packages/core/src/bot-onboarding.ts | 21 +++- packages/core/src/redaction.ts | 90 +++++++++------- packages/runtime/src/bots/bot-test.ts | 39 ++++--- packages/runtime/src/bots/index.ts | 4 +- packages/runtime/src/bots/types.ts | 31 +++++- packages/runtime/src/bots/wechat-bridge.ts | 21 ++-- 21 files changed, 363 insertions(+), 182 deletions(-) diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts index 83d5744dc2..97c3035534 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -351,8 +351,8 @@ describe('BotOnboardingService', () => { test.advance(5_000); const connected = await test.service.poll(started.sessionId); assert.equal(connected.state, 'connected'); - assert.match(connected.warning ?? '', /凭据已保存,但连接未建立/); - assert.match(connected.warning ?? '', /鉴权失败/); + assert.equal(connected.warningCode, 'saved_not_connected'); + assert.match(connected.warningDetail ?? '', /鉴权失败/); assert.equal(JSON.stringify(connected).includes('private-client-secret'), false); }); @@ -376,7 +376,7 @@ describe('BotOnboardingService', () => { test.advance(5_000); const connected = await test.service.poll(started.sessionId); assert.equal(connected.state, 'connected'); - assert.equal(connected.warning, undefined); + assert.equal(connected.warningCode, undefined); }); it('invalidates an older session when the same provider starts again', async () => { diff --git a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts index f905cba983..390c5d8001 100644 --- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts @@ -101,7 +101,7 @@ describe("settings IPC helpers", () => { const result = toSettingsTestResult("telegram", { ok: true, identity: { id: "42", username: "maka_bot", displayName: "Maka" }, - hint: "ready", + hintCode: "telegram_send_start", }); assert.equal(result.ok, true); @@ -115,7 +115,7 @@ describe("settings IPC helpers", () => { username: "maka_bot", displayName: "Maka", }); - assert.equal(result.details?.hint, "ready"); + assert.equal(result.details?.hintCode, "telegram_send_start"); }); test("redacts and generalizes bot test errors before returning SettingsTestResult", () => { diff --git a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts index 757ced374c..9ee4a7a236 100644 --- a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts +++ b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts @@ -20,6 +20,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { settingsTestResultMessage } from "../../renderer/locales/settings-test-result-copy.js"; +import { toSettingsTestResult } from "../settings-ipc-helpers.js"; test("missing proxy credentials have actionable bilingual copy", () => { const result = { @@ -37,3 +38,17 @@ test("missing proxy credentials have actionable bilingual copy", () => { "Proxy authentication is enabled. Enter a proxy password before testing.", ); }); + + +test("renders a bot-test error code per locale without content sniffing", () => { + const result = toSettingsTestResult("dingtalk", { + ok: false, + errorCode: "dingtalk_credentials_missing", + }); + assert.equal(result.code, "bot_app_credentials_missing"); + assert.equal(settingsTestResultMessage(result, "zh"), "请填写 App ID 和 App Secret 后再测试。"); + assert.equal( + settingsTestResultMessage(result, "en"), + "Enter an App ID and App Secret before testing the connection.", + ); +}); diff --git a/apps/desktop/src/main/bot-incoming-main.ts b/apps/desktop/src/main/bot-incoming-main.ts index 545518a885..2b1a521f73 100644 --- a/apps/desktop/src/main/bot-incoming-main.ts +++ b/apps/desktop/src/main/bot-incoming-main.ts @@ -183,6 +183,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): } } + // bot-channel notices follow the bot audience language; localization tracked under #2672 async function sendTransientBotNotice(message: BotIncomingMessage, text: string, ttlMs: number): Promise { if (closed) return; await deps.botRegistry.sendMessage( @@ -424,6 +425,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): return { handleBotIncomingMessage, invalidateSessionBindings, close }; } +// bot-channel notices follow the bot audience language; localization tracked under #2672 function botReply(result: BotSessionTurnResult): string { if (result.kind === 'suspended') { return '这条请求需要在 Maka 桌面端审批后才能继续。'; diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts index 6ed3ff7535..2b875a6694 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -23,12 +23,17 @@ import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import type { BotOnboardingBrand, + BotOnboardingErrorCode, BotOnboardingProvider, BotOnboardingSnapshot, BotOnboardingStartInput, BotOnboardingState, } from '@maka/core/bot-onboarding'; -import { generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redaction'; +import { + classifyGeneralizedError, + generalizedErrorMessageChinese, + redactSecrets, +} from '@maka/core/redaction'; import { isBotOnboardingBrand, isBotOnboardingProvider } from '@maka/core/bot-onboarding'; import type { BotRegistry } from '@maka/runtime/bots'; import { proxiedFetch } from '@maka/runtime/bots'; @@ -101,7 +106,9 @@ interface BotOnboardingSession { pollFailures: number; identity?: { id?: string; displayName?: string }; error?: string; - warning?: string; + errorCode?: BotOnboardingErrorCode; + warningCode?: 'saved_not_connected'; + warningDetail?: string; } export interface BotOnboardingServiceDeps { @@ -195,6 +202,7 @@ export class BotOnboardingService { } session.state = 'error'; session.error = safeProviderError(error); + session.errorCode = providerErrorCode(error); throw new Error(session.error); } } @@ -279,7 +287,9 @@ export class BotOnboardingService { // bridge is not running, surface an honest warning instead of lying // about a healthy connection. Onboarding still succeeds — the user can // retry the connection later from settings. - session.warning = this.connectionWarning(session.provider); + const warning = this.connectionWarning(session.provider); + session.warningCode = warning?.code; + session.warningDetail = warning?.detail; break; } return this.snapshot(session); @@ -304,6 +314,7 @@ export class BotOnboardingService { } session.state = 'error'; session.error = safeProviderError(error); + session.errorCode = providerErrorCode(error); return this.snapshot(session); } } @@ -386,13 +397,13 @@ export class BotOnboardingService { * honest, secret-free notice when the credentials were saved but the bridge * is not actually running, or `undefined` when the connection is healthy. */ - private connectionWarning(provider: BotOnboardingProvider): string | undefined { + private connectionWarning( + provider: BotOnboardingProvider, + ): { code: 'saved_not_connected'; detail?: string } | undefined { const status = this.readChannelStatus(provider); if (status.running) return undefined; - const reason = connectionFailureReason(status.reason); - return reason - ? `凭据已保存,但连接未建立:${reason},可稍后在设置中重试。` - : '凭据已保存,但连接未建立,可稍后在设置中重试。'; + const detail = connectionFailureReason(status.reason); + return { code: 'saved_not_connected', ...(detail ? { detail } : {}) }; } private getSession(rawSessionId: unknown): BotOnboardingSession { @@ -451,7 +462,9 @@ export class BotOnboardingService { canOpenInBrowser: Boolean(session.verificationUrl), ...(session.identity ? { identity: { ...session.identity } } : {}), ...(session.error ? { error: session.error } : {}), - ...(session.warning ? { warning: session.warning } : {}), + ...(session.errorCode ? { errorCode: session.errorCode } : {}), + ...(session.warningCode ? { warningCode: session.warningCode } : {}), + ...(session.warningDetail ? { warningDetail: session.warningDetail } : {}), }; } } @@ -478,6 +491,11 @@ function clampPollInterval(value: number): number { return Math.min(Math.max(Math.round(value), 1_000), MAX_POLL_INTERVAL_MS); } +function providerErrorCode(error: unknown): BotOnboardingErrorCode { + if (error instanceof Error && error.name === 'AbortError') return 'cancelled'; + return classifyGeneralizedError(error) ?? 'unavailable'; +} + function safeProviderError(error: unknown): string { if (error instanceof Error && error.name === 'AbortError') return '扫码接入已取消。'; // PR1197 review (P2-11): route through the shared categorizer so 超时 / 鉴权失败 / diff --git a/apps/desktop/src/main/settings-bots-ipc-main.ts b/apps/desktop/src/main/settings-bots-ipc-main.ts index 5910c3b117..acc1cc6768 100644 --- a/apps/desktop/src/main/settings-bots-ipc-main.ts +++ b/apps/desktop/src/main/settings-bots-ipc-main.ts @@ -90,12 +90,12 @@ export function registerSettingsBotsIpc( : 'configured') as BotReadinessState, readinessReason: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : botTestErrorMessage(provider, result), readinessUpdatedAt: Date.now(), lastTestAt: Date.now(), lastError: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : botTestErrorMessage(provider, result), }; await deps.settingsStore.update({ botChat: { channels: { [provider]: channelPatch } }, diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index df8952d38b..a2b27d05da 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -33,7 +33,7 @@ import { maskSensitive, type TestProxyResult, } from "@maka/core/settings/network-settings"; -import type { BotTestResult } from '@maka/runtime/bots'; +import type { BotTestErrorCode, BotTestResult } from '@maka/runtime/bots'; import { collectPersonalizationWarnings } from '@maka/runtime/system-prompt/personalization-prompt'; import { getTavilyCredentialSource } from "./web-search/credentials.js"; @@ -195,9 +195,7 @@ export function toSettingsTestResult( provider: BotProvider, result: BotTestResult, ): SettingsTestResult { - const failure = result.ok - ? undefined - : botTestFailure(provider, result.error); + const failure = result.ok ? undefined : botTestFailure(provider, result); return { ok: result.ok, code: result.ok ? "bot_credentials_valid" : failure?.code, @@ -208,50 +206,54 @@ export function toSettingsTestResult( details: { ...(result.identity ? { identity: result.identity } : {}), ...(result.capabilities ? { capabilities: result.capabilities } : {}), - ...(result.hint ? { hint: result.hint } : {}), + ...(result.hintCode ? { hintCode: result.hintCode } : {}), }, }; } export function botTestErrorMessage( provider: BotProvider, - error: unknown, + result: Pick, ): string { - return botTestFailure(provider, error).message; + return botTestFailure(provider, result).message; } +const MISSING_CREDENTIAL_CODES: ReadonlySet = new Set([ + "slack_tokens_missing", + "feishu_credentials_missing", + "wecom_credentials_missing", + "dingtalk_credentials_missing", + "qq_credentials_missing", + "wechat_ilink_credentials_incomplete", +]); + function botTestFailure( provider: BotProvider, - error: unknown, + result: Pick, ): { code: SettingsTestResultCode; message: string } { const label = botDisplayLabel(provider); - const raw = redactSecrets( - error instanceof Error ? error.message : String(error ?? ""), - ).trim(); - const lower = raw.toLowerCase(); - - if (lower.includes("bot token is required")) { + if (result.errorCode === "token_missing") { return { code: "bot_token_missing", message: `${label} requires a Bot Token.`, }; } - if (lower.includes("invalid bot token")) { + if (result.errorCode === "token_invalid") { return { code: "bot_token_invalid", message: `${label} rejected the Bot Token.`, }; } - if ( - provider === "feishu" && - /appid|app_id|appsecret|app_secret|required/.test(lower) - ) { + if (result.errorCode && MISSING_CREDENTIAL_CODES.has(result.errorCode)) { return { code: "bot_app_credentials_missing", - message: "Feishu requires an App ID and App Secret.", + message: `${label} requires an App ID and App Secret.`, }; } + const raw = redactSecrets( + typeof result.error === "string" ? result.error : "", + ).trim(); const classified = generalizedErrorMessage(raw, ""); return { code: "bot_connection_failed", diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index 698aa1f4d9..2ed68f22c7 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -54,6 +54,36 @@ const zhCopy = { unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志', polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无', }, + testHints: { + slack_socket_ready: '凭据有效,Socket Mode 连接可用。', + wechat_mp_callback_required: '凭据有效;消息收发还需要公众号服务器配置和回调验证。', + telegram_send_start: '发送 /start 给机器人后可在运行态接收消息。', + wecom_runtime_authority: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。', + dingtalk_stream_required: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。', + qq_gateway_required: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。', + wechat_bridge_local_only: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_start_required: '先在本机启动 wechat-bridge,并确认 WeChat 已登录;发送能力需要 wxp_act_ 激活码。', + wechat_ilink_login_required: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。', + wechat_ilink_polling: '扫码登录凭据已保存;运行态会通过 iLink 长轮询接收消息。', + wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。', + }, + statusReasons: { + codes: { + 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次', + 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效', + 'send-failed': '上一次发送失败,详细原因 Telegram 没有返回', + 'get-me-failed': '凭据探测失败;请检查 Bot Token', + }, + 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})`, + }, + }, overview: { loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序', empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置', @@ -103,6 +133,12 @@ const zhCopy = { 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: '扫码接入已取消。', timeout: '请求超时', rate_limited: '触发模型速率限制', auth_failed: '鉴权失败', + provider_error: '模型服务返回错误', network_error: '网络错误', unavailable: '扫码接入暂时不可用,请稍后重试。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -131,6 +167,36 @@ 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: { + slack_socket_ready: 'Credentials are valid and the Socket Mode connection is available.', + wechat_mp_callback_required: 'Credentials are valid; messaging still needs the Official Account server configuration and callback verification.', + telegram_send_start: 'Send /start to the bot to receive messages at runtime.', + wecom_runtime_authority: 'Credentials saved; the WeCom AI bot connection state follows the live persistent connection.', + dingtalk_stream_required: 'Credentials are valid; receiving messages needs an outgoing bot or Stream mode configuration.', + qq_gateway_required: 'Credentials are valid; receiving messages needs the QQ Gateway WebSocket.', + wechat_bridge_local_only: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_start_required: 'Start the local wechat-bridge and make sure WeChat is signed in; sending needs a wxp_act_ activation code.', + wechat_ilink_login_required: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.', + wechat_ilink_polling: 'QR sign-in credentials saved; the runtime receives messages over iLink long polling.', + 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.', + }, + statusReasons: { + codes: { + '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': 'The last send failed; Telegram returned no details', + 'get-me-failed': 'Credential probe failed; check the Bot Token', + }, + 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})`, + }, + }, 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: { @@ -139,6 +205,12 @@ const enCopy: BotSettingsCopy = { onboarding: { providers: { dingtalk: { title: 'Set up DingTalk', ariaLabel: 'Set up DingTalk with a QR code', qrAlt: 'DingTalk setup QR code', subtitle: 'Scan in DingTalk to register the app', waiting: 'Scan with DingTalk and confirm authorization', scanned: 'Scanned. Complete confirmation in DingTalk.' }, feishu: { title: 'Set up Feishu', ariaLabel: 'Set up Feishu with a QR code', qrAlt: 'Feishu setup QR code', subtitle: 'Scan with Feishu to create and configure the bot', waiting: 'Scan with Feishu and confirm creation', scanned: 'Scanned. Complete confirmation in Feishu.' }, wecom: { title: 'Set up WeCom', ariaLabel: 'Set up WeCom with a QR code', qrAlt: 'WeCom setup QR code', subtitle: 'Quick setup creates and connects a WeCom bot', waiting: 'Open WeCom and scan to create the bot', scanned: 'Scanned. Complete confirmation in WeCom.' }, wechat: { title: 'Scan to sign in', ariaLabel: 'WeChat QR sign-in', qrAlt: 'WeChat sign-in QR code', subtitle: 'Scan with WeChat to connect', waiting: 'Scan with WeChat and confirm on your phone', scanned: 'Scanned. Complete confirmation in WeChat.' }, qq: { title: 'Set up QQ', ariaLabel: 'Set up QQ with a QR code', qrAlt: 'QQ setup QR code', subtitle: 'Scan with mobile QQ to create and bind a bot', waiting: 'Scan with mobile QQ and confirm binding', scanned: 'Scanned. Complete confirmation in QQ.' } }, lark: { title: 'Set up Lark', ariaLabel: 'Set up Lark with a QR code', qrAlt: 'Lark setup QR code', subtitle: 'Scan with Lark to create and configure the bot', waiting: 'Scan with Lark and confirm creation', scanned: 'Scanned. Complete confirmation in Lark.' }, connectedRefreshFailed: (message) => `Connected, but status refresh failed: ${message}`, close: (title) => `Close ${title}`, generatingAria: 'Generating QR code', privacy: 'Credentials stay on this device and are never sent to the renderer or Maka cloud.', openBrowser: 'Cannot scan? Open in browser', done: 'Done', regenerate: 'Generate again', refreshQr: 'Refresh QR code', cancel: 'Cancel', generating: 'Generating a secure QR code…', connecting: 'Authorization complete. Saving credentials and starting connection…', connected: (name) => `${name} connected`, connectedWarning: 'Credentials were saved, but the connection did not start.', expired: 'QR code expired. Generate a new one.', denied: 'Authorization cancelled. Generate a new QR code.', cancelled: 'QR setup cancelled', failed: 'QR setup failed. Try again.', preparing: 'Preparing QR setup…', + 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.', timeout: 'Request timed out', rate_limited: 'Rate limit exceeded', auth_failed: 'Authentication failed', + provider_error: 'Provider returned an error', network_error: 'Network error', unavailable: 'QR setup is temporarily unavailable. Try again later.', + }, }, 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' }, }; @@ -148,3 +220,33 @@ const BOT_SETTINGS_COPY = { zh: zhCopy, en: enCopy } satisfies UiCatalog = [ + { 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`. */ +export function botStatusReasonMessage( + reason: string | undefined, + locale: UiLocale, +): string | undefined { + if (!reason) return undefined; + const copy = BOT_SETTINGS_COPY[locale].statusReasons; + const fixed = (copy.codes as Readonly>)[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]); + } + // Platform-supplied description ("Bad Request: chat not found", …): external + // error text passes through verbatim rather than being classified by content. + return reason; +} diff --git a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx index 2e443a042e..70dcc8d7d1 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx @@ -412,10 +412,13 @@ export function BotChatChannelDetail(props: { // PR1197 review (P0-3): the bridge may have failed to start even // though credentials saved. Reflect that honestly instead of a // success toast that overstates the connection. - if (snapshot.warning) { + if (snapshot.warningCode) { + const onboardingCopy = getBotSettingsCopy(locale).onboarding; toast.warning( detailCopy.credentialsSaved(providerPresentation.label), - locale === 'zh' ? snapshot.warning : detailCopy.savedButNotConnected, + snapshot.warningDetail + ? onboardingCopy.savedNotConnectedDetail(snapshot.warningDetail) + : onboardingCopy.savedNotConnected, ); return; } diff --git a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx index f3f096fe99..569da08bb6 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx @@ -26,7 +26,7 @@ import { EmptyState, Item, StatusDot } from '@astryxdesign/core'; import { Button, RelativeTime, useUiLocale, Banner } from '@maka/ui'; import { deriveBotChannelViewState } from './bot-settings-view-model'; import { BOT_LABELS, BotBrandLogo, botReadinessCopyForSupport, botStatusDetail } from './bot-chat-shared'; -import { getBotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../locales/settings-bot-copy'; import { SettingsPage, SettingsSection } from './settings-section'; import { dotForStatus } from '@maka/ui'; @@ -157,7 +157,7 @@ function botOverviewDetail( ); } - if (currentError) return locale === 'zh' ? currentError : fallback; + if (currentError) return botStatusReasonMessage(currentError, locale) ?? fallback; if (status?.reason) return botStatusDetail(status, locale); return fallback; } diff --git a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx index 05d99c09af..93676e5472 100644 --- a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx +++ b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx @@ -190,7 +190,7 @@ export function BotOnboardingModal(props: { ) : starting || snapshot?.state === 'connecting' ? ( ) : snapshot?.state === 'connected' ? ( - snapshot.warning ? ( + snapshot.warningCode ? ( @@ -262,13 +262,15 @@ function statusCopy( case 'connecting': return shared.connecting; // PR1197 review (P0-3): honour the honest "saved but not connected" notice // instead of claiming a healthy connection. - case 'connected': return snapshot.warning - ? (locale === 'zh' ? snapshot.warning : shared.connectedWarning) + case 'connected': return snapshot.warningCode + ? (snapshot.warningDetail + ? shared.savedNotConnectedDetail(snapshot.warningDetail) + : shared.savedNotConnected) : shared.connected(getBotSettingsCopy(locale).providers[snapshot.provider].label); case 'expired': return shared.expired; case 'denied': return shared.denied; case 'cancelled': return shared.cancelled; - case 'error': return locale === 'zh' ? (snapshot.error ?? shared.failed) : shared.failed; + case 'error': return snapshot.errorCode ? shared.errors[snapshot.errorCode] : shared.failed; default: return shared.preparing; } } diff --git a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts index f75c27860a..f3aea677f6 100644 --- a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts +++ b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts @@ -17,7 +17,7 @@ * under the License. */ -import { humanizeBotStatusReason } from '@maka/core/bot-events'; +import { botStatusErrorReason } from '@maka/core/bot-events'; import { type BotChannelSettings, type BotReadinessState } from '@maka/core/bot-chat-settings'; import type { BotStatus } from '@maka/runtime/bots'; @@ -43,7 +43,7 @@ export function deriveBotChannelViewState(input: { || isConfiguredReadiness(readiness); const liveOperational = status?.running === true && readiness === 'operational'; const liveError = readiness === 'degraded' - ? humanizeBotStatusReason(status?.reason) + ? botStatusErrorReason(status?.reason) : undefined; const currentError = liveOperational ? undefined : liveError ?? channel.lastError; const needsAttention = configured && ( diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx index 796b6b20ac..fc372fb7ce 100644 --- a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx +++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx @@ -99,7 +99,8 @@ export function WechatQrLoginModal(props: { onRefreshStatuses(): void | Promise; }) { const locale = useUiLocale(); - const copy = getBotSettingsCopy(locale).wechat; + const botCopy = getBotSettingsCopy(locale); + const copy = botCopy.wechat; const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [reloadNonce, setReloadNonce] = useState(0); @@ -131,7 +132,6 @@ export function WechatQrLoginModal(props: { setResult({ ok: false, error: settingsActionErrorMessage(error, locale), - hint: copy.readQrFailed, }); }) .finally(() => { @@ -222,7 +222,7 @@ export function WechatQrLoginModal(props: { headingLevel={4} icon={} title={error.error} - description={error.hint} + description={error.hintCode ? botCopy.testHints[error.hintCode] : copy.readQrFailed} actions={