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 9f86548727..8dc5c859f5 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-CN"), "请填写 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 b34d2fbca1..a37ff56803 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 地址)', @@ -140,6 +176,36 @@ const zhTwCopy = { 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: '選擇平台開始設定', @@ -189,6 +255,12 @@ 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: '掃碼串接已取消。', timeout: '請求逾時', rate_limited: '已達模型速率限制', auth_failed: '驗證失敗', + provider_error: '模型服務傳回錯誤', network_error: '網路錯誤', unavailable: '掃碼串接暫時無法使用,請稍後重試。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -217,6 +289,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: { @@ -225,6 +327,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' }, }; @@ -238,3 +346,33 @@ const BOT_SETTINGS_COPY = { 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`. */ +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 ae9ee1aeaf..394098453d 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-CN' ? 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 b2e248a006..249c17de72 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-CN' ? 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 90496c3070..036e3e50d1 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-CN' ? 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-CN' ? (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={