From 137dd482b2dc5b6e4271c45a6f5b259ce819558b Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 5 Sep 2026 16:07:59 -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, and each locale owns its complete allowlist warning and help messages. The Permission Center and Health center resolve bot reasons through the same catalog, so no surface sniffs the text. Generated-by: Claude Code Generated-by: OpenCode --- apps/desktop/renderer-architecture.json | 11 +- .../main/__tests__/bot-chat-detail.test.ts | 292 ++++++++++++++++++ .../__tests__/bot-onboarding-main.test.ts | 39 ++- .../permission-center-bot-reason.test.ts | 117 +++++++ .../src/main/__tests__/runtime-undici.ts | 42 +++ .../__tests__/settings-ipc-helpers.test.ts | 9 +- .../settings-test-result-copy.test.ts | 167 +++++++++- apps/desktop/src/main/bot-incoming-main.ts | 2 + apps/desktop/src/main/bot-onboarding-main.ts | 38 ++- .../src/main/settings-bots-ipc-main.ts | 9 +- apps/desktop/src/main/settings-ipc-helpers.ts | 78 ++--- .../src/renderer/locales/settings-bot-copy.ts | 230 +++++++++++++- .../renderer/locales/settings-health-copy.ts | 18 +- .../locales/settings-test-result-copy.ts | 50 ++- .../src/renderer/settings/bot-chat-detail.tsx | 15 +- .../renderer/settings/bot-chat-overview.tsx | 4 +- .../src/renderer/settings/bot-chat-shared.tsx | 12 +- .../settings/bot-onboarding-modal.tsx | 14 +- .../settings/bot-settings-view-model.ts | 4 +- .../renderer/settings/bot-wechat-login.tsx | 12 +- .../renderer/settings/health-center-page.tsx | 22 +- .../settings/permission-center-page.tsx | 25 +- packages/core/src/__tests__/redaction.test.ts | 31 +- packages/core/src/bot-events.ts | 81 ++--- packages/core/src/bot-onboarding.ts | 21 +- packages/core/src/redaction.ts | 116 +++---- packages/core/src/settings.ts | 8 + .../src/bots/__tests__/bot-registry.test.ts | 4 +- .../__tests__/gateway-bridge-base.test.ts | 25 +- packages/runtime/src/bots/base-adapter.ts | 28 +- packages/runtime/src/bots/bot-test.ts | 66 ++-- packages/runtime/src/bots/dingtalk-bridge.ts | 17 +- packages/runtime/src/bots/discord-bridge.ts | 13 +- packages/runtime/src/bots/feishu-bridge.ts | 9 +- packages/runtime/src/bots/index.ts | 5 +- packages/runtime/src/bots/qq-bridge.ts | 15 +- packages/runtime/src/bots/slack-bridge.ts | 7 +- packages/runtime/src/bots/telegram-bridge.ts | 15 +- packages/runtime/src/bots/types.ts | 42 ++- packages/runtime/src/bots/wechat-bridge.ts | 41 ++- packages/runtime/src/bots/wecom-bridge.ts | 11 +- packages/runtime/src/bots/ws-bridge-base.ts | 13 +- 42 files changed, 1396 insertions(+), 382 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/bot-chat-detail.test.ts create mode 100644 apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-undici.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..8b199af243 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1620,7 +1620,10 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/redaction": 1, + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/settings-daily-review-copy.ts": { "bridgePaths": {}, @@ -1719,7 +1722,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/settings-usage-copy.ts": { "bridgePaths": {}, @@ -2669,6 +2674,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../locales/settings-bot-copy": 1, "../locales/settings-health-copy": 1, "./runtime-host-settings-target.js": 1, "./settings-error-copy": 1, @@ -2877,6 +2883,7 @@ "actionFactories": [], "dependencyPaths": { "../locales/permission-center-copy": 1, + "../locales/settings-bot-copy": 1, "./runtime-host-settings-target.js": 1, "./settings-error-copy": 1, "./settings-section": 1, diff --git a/apps/desktop/src/main/__tests__/bot-chat-detail.test.ts b/apps/desktop/src/main/__tests__/bot-chat-detail.test.ts new file mode 100644 index 0000000000..5c870fd50a --- /dev/null +++ b/apps/desktop/src/main/__tests__/bot-chat-detail.test.ts @@ -0,0 +1,292 @@ +/* + * 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 assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { after, before, test, mock } from 'node:test'; +import { createRequire } from 'node:module'; +import type { EventEmitter } from 'node:events'; +import { pathToFileURL } from 'node:url'; +import { createDefaultBotChannel } from '@maka/core/bot-chat-settings'; +import { MAX_ALLOWED_USER_IDS, createDefaultSettings } from '@maka/core/settings'; +import { BotRegistry, SlackBotBridge, WechatBridge, type BotStatus } from '@maka/runtime/bots'; +import { UI_LOCALES, type UiCatalog, type UiLocale } from '@maka/core/ui-locale'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { build } from 'esbuild'; +import { parseHTML } from 'linkedom'; +import { act, createElement, type ComponentProps, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type * as BotChatDetailModule from '../../renderer/settings/bot-chat-detail.js'; +import type * as BotChatOverviewModule from '../../renderer/settings/bot-chat-overview.js'; +import type * as BotOnboardingModule from '../../renderer/settings/bot-onboarding-modal.js'; +import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; +import { GENERALIZED_ERROR_COPY } from '@maka/core/redaction'; + +const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); +let outdir: string; +let BotChatChannelDetail: typeof BotChatDetailModule.BotChatChannelDetail; +let BotChatOverview: typeof BotChatOverviewModule.BotChatOverview; +let BotOnboardingModal: typeof BotOnboardingModule.BotOnboardingModal; + +before(async () => { + outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/bot-chat-detail-')); + await build({ + entryPoints: ['bot-chat-detail', 'bot-chat-overview', 'bot-onboarding-modal'].map((name) => resolve(REPO_ROOT, `apps/desktop/src/renderer/settings/${name}.tsx`)), + outdir, + outExtension: { '.js': '.mjs' }, + bundle: true, + packages: 'external', + platform: 'node', + format: 'esm', + jsx: 'automatic', + target: 'node20', + logLevel: 'silent', + }); + ({ BotChatChannelDetail } = await import(pathToFileURL(resolve(outdir, 'bot-chat-detail.mjs')).href)); + ({ BotChatOverview } = await import(pathToFileURL(resolve(outdir, 'bot-chat-overview.mjs')).href)); + ({ BotOnboardingModal } = await import(pathToFileURL(resolve(outdir, 'bot-onboarding-modal.mjs')).href)); +}); + +after(async () => { + if (outdir) await rm(outdir, { recursive: true, force: true }); +}); + +const invalidUsers = ['@alice', '@bob', '@carol', '@dave', '@eve']; +const expectedCopy = { + 'zh-CN': { + help: 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', + cappedHelp: 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。 (已达到上限)', + warnings: [ + [1, '下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:@alice'], + [3, '下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:@alice、@bob、@carol'], + [4, '下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:@alice、@bob、@carol 等 4 项'], + [5, '下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:@alice、@bob、@carol 等 5 项'], + ], + }, + 'zh-TW': { + help: 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', + cappedHelp: 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。 (已達到上限)', + warnings: [ + [1, '下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:@alice'], + [3, '下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:@alice、@bob、@carol'], + [4, '下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:@alice、@bob、@carol 等 4 項'], + [5, '下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:@alice、@bob、@carol 等 5 項'], + ], + }, + en: { + help: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', + cappedHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored. (limit reached)', + warnings: [ + [1, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice'], + [3, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol'], + [4, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol and 1 more'], + [5, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol and 2 more'], + ], + }, +} satisfies UiCatalog<{ + help: string; + cappedHelp: string; + warnings: [number, string][]; +}>; + +for (const locale of UI_LOCALES) { + const expected = expectedCopy[locale]; + for (const [count, warning] of expected.warnings) { + test(`${locale}: BotChatChannelDetail renders the full warning for ${count} invalid IDs`, () => { + assert.deepEqual(renderAllowedUsersDescriptions(locale, invalidUsers.slice(0, count)), [ + expected.help, + warning, + ]); + }); + } + + test(`${locale}: BotChatChannelDetail omits the warning for an empty allowlist`, () => { + assert.deepEqual(renderAllowedUsersDescriptions(locale, []), [expected.help]); + }); + + for (const count of [MAX_ALLOWED_USER_IDS - 1, MAX_ALLOWED_USER_IDS]) { + test(`${locale}: BotChatChannelDetail renders the full help for ${count} numeric IDs without a warning`, () => { + const users = Array.from({ length: count }, (_, index) => String(123456789 + index)); + assert.deepEqual(renderAllowedUsersDescriptions(locale, users), [ + count === MAX_ALLOWED_USER_IDS ? expected.cappedHelp : expected.help, + ]); + }); + } +} + +function withLocale(locale: UiLocale, children: ReactNode) { + return createElement(LocaleProvider, { + locale, + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { children }), + }), + }); +} + +function detailProps(overrides: Partial> = {}): ComponentProps { + return { + provider: 'telegram', + channel: createDefaultBotChannel('telegram'), + status: undefined, + statusLoadError: null, + actionBusy: false, + pendingAction: null, + restarting: false, + onBack() {}, + async onUpdateChannel() { return true; }, + onTest() {}, + onTestAndConnect() {}, + onRestart() {}, + onDisconnectSession() {}, + async onReload() {}, + async onRefreshStatuses() { return true; }, + ...overrides, + }; +} + +function renderAllowedUsersDescriptions(locale: UiLocale, allowedUserIds: readonly string[]) { + const markup = renderToStaticMarkup(withLocale(locale, createElement(BotChatChannelDetail, detailProps({ channel: { ...createDefaultBotChannel('telegram'), allowedUserIds } })))); + const { document } = parseHTML(markup); + const textarea = document.querySelector('textarea'); + assert.ok(textarea, 'the public detail must render the Telegram allowlist'); + assert.equal(textarea.textContent, allowedUserIds.join('\n')); + const describedBy = textarea.getAttribute('aria-describedby'); + assert.ok(describedBy, 'the allowlist must reference its help and warning'); + return describedBy.split(/\s+/).map((id) => { + const description = document.getElementById(id); + assert.ok(description, `missing allowlist description ${id}`); + return description.textContent; + }); +} + +test('real bridge failures render localized detail and overview output in all locales', async () => { + const load = createRequire(import.meta.resolve('@maka/runtime/bots')); + const { WebClient } = load('@slack/web-api') as { + WebClient: { prototype: { apiCall(method: string, options?: unknown): Promise } }; + }; + type SlackSocket = EventEmitter & { start(): Promise; disconnect(): Promise }; + const { SocketModeClient } = load('@slack/socket-mode') as { SocketModeClient: new () => SlackSocket }; + const slack = new SlackBotBridge({ ...createDefaultBotChannel('slack'), enabled: true, token: 'bot-secret', appSecret: 'app-secret' }); + let socket: InstanceType | undefined; + const auth = mock.method(WebClient.prototype, 'apiCall', async () => ({ ok: true, user_id: 'bot' })); + const start = mock.method(SocketModeClient.prototype, 'start', async function (this: InstanceType) { socket = this; return {}; }); + const stop = mock.method(SocketModeClient.prototype, 'disconnect', async () => {}); + const log = mock.method(console, 'warn', () => {}); + const statuses: Array<[BotStatus, string[]]> = []; + try { + await slack.start(); + assert.ok(socket); + socket.emit('disconnected'); + statuses.push([slack.getStatus(), ['Slack 连接已断开,正在等待重新连接', 'Slack 連線已中斷,正在等待重新連線', 'Slack disconnected; waiting to reconnect']]); + await slack.stop(); + auth.mock.mockImplementation(async () => { throw new Error('Network error bot-secret app-secret'); }); + await assert.rejects(slack.start()); + assert.equal(slack.getStatus().reason, 'network_error'); + statuses.push([slack.getStatus(), UI_LOCALES.map((locale) => GENERALIZED_ERROR_COPY[locale].network_error)]); + const diagnostic = log.mock.calls.map((call) => call.arguments.join(' ')).join('\n'); + assert.match(diagnostic, /Network error \[redacted\] \[redacted\]/); + for (const [url, code] of [['https://remote.invalid', 'wechat_bridge_url_invalid'], ['https://ilinkai.weixin.qq.com', 'wechat_ilink_credentials_incomplete']] as const) { + const bridge = new WechatBridge({ ...createDefaultBotChannel('wechat'), enabled: true, webhookUrl: url }); + await bridge.start(); + assert.equal(bridge.getStatus().reason, code); + statuses.push([bridge.getStatus(), UI_LOCALES.map((locale) => getBotSettingsCopy(locale).testErrors[code])]); + } + for (const [provider, code] of [['slack', 'slack_tokens_missing'], ['wecom', 'wecom_credentials_missing'], ['dingtalk', 'dingtalk_credentials_missing'], ['qq', 'qq_credentials_missing']] as const) { + const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} }); + const settings = createDefaultSettings().botChat; + settings.channels[provider].enabled = true; + await registry.applySettings(settings); + statuses.push([registry.getStatus(provider), UI_LOCALES.map((locale) => getBotSettingsCopy(locale).testErrors[code])]); + await registry.stopAll(); + } + for (const reason of ['Network error', '外部错误 token=secret', 'constructor', '__proto__', 'toString', 'future-code']) { + statuses.push([{ ...slack.getStatus(), reason }, UI_LOCALES.map((locale) => getBotSettingsCopy(locale).status.detailsInLogs)]); + } + for (const [status, expected] of statuses) { + for (const [index, locale] of UI_LOCALES.entries()) { + const channel = { ...createDefaultBotChannel(status.platform), enabled: true }; + const detail = renderToStaticMarkup(withLocale(locale, createElement(BotChatChannelDetail, detailProps({ provider: status.platform, channel, status })))); + assert.ok(parseHTML(`${detail}`).document.body.textContent.includes(expected[index]), `${locale}: detail must render ${expected[index]}`); + const channels = createDefaultSettings().botChat.channels; + channels[status.platform] = channel; + const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} }); + const overview = renderToStaticMarkup(withLocale(locale, createElement(BotChatOverview, { channels, statuses: { ...registry.allStatuses(), [status.platform]: status }, statusLoadError: null, onOpenChannel() {}, async onRefreshStatuses() { return true; } }))); + const summary = parseHTML(overview).document.getElementById(`settings-remote-access-${status.platform}-summary`); + assert.equal(summary?.textContent, expected[index]); + } + } + } finally { + await slack.stop(); + auth.mock.restore(); start.mock.restore(); stop.mock.restore(); log.mock.restore(); + } +}); + +for (const locale of UI_LOCALES) { + test(`${locale}: onboarding modal and completion toast localize warning details`, async () => { + const { document, window } = parseHTML('
'); + const globals = ['document', 'window', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', 'matchMedia', 'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT'] as const; + const previous = Object.fromEntries(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const matchMedia = (media: string) => ({ matches: false, media, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }); + Object.assign(window, { matchMedia, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { showModal(this: HTMLElement) { this.setAttribute('open', ''); }, close(this: HTMLElement) { this.removeAttribute('open'); } }); + Object.assign(globalThis, { document, window, matchMedia, HTMLElement: window.HTMLElement, HTMLIFrameElement: window.HTMLIFrameElement ?? class {}, Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, IS_REACT_ACT_ENVIRONMENT: true }); + Object.assign(globalThis, { getComputedStyle: () => ({ backgroundImage: 'none', backgroundColor: 'transparent', getPropertyValue: () => '' }) }); + const root = createRoot(document.getElementById('root')!); + try { + for (const reason of ['connections-open-503', 'constructor', '__proto__', 'Network error', '外部错误 token=secret']) { + const snapshot = { sessionId: reason, provider: 'dingtalk', state: 'connected', warningCode: 'saved_not_connected', warningDetail: reason, nextPollAfterMs: 1000 }; + Object.assign(window, { maka: { settings: { bots: { onboarding: { async start() { return { ok: true, data: snapshot }; }, async cancel() { return { ok: true }; } } } } } }); + await act(async () => { root.render(withLocale(locale, createElement(BotOnboardingModal, { key: reason, provider: 'dingtalk', isOpen: true, onOpenChange() {}, onConnected() {} }))); }); + const copy = getBotSettingsCopy(locale); + const expected = copy.onboarding.savedNotConnectedDetail(reason === 'connections-open-503' ? copy.statusReasons.withCode.connectionsOpen('503') : copy.status.detailsInLogs); + assert.equal(document.querySelector('.settingsBotOnboardingStatus')?.textContent, expected); + } + for (const code of ['network_error', 'future-code', 'constructor', '__proto__', 'toString']) { + const snapshot = { sessionId: code, provider: 'dingtalk', state: 'error', errorCode: code, error: 'raw external error token=secret', nextPollAfterMs: 1000 }; + Object.assign(window, { maka: { settings: { bots: { onboarding: { async start() { return { ok: true, data: snapshot }; }, async cancel() { return { ok: true }; } } } } } }); + await act(async () => { root.render(withLocale(locale, createElement(BotOnboardingModal, { key: `error-${code}`, provider: 'dingtalk', isOpen: true, onOpenChange() {}, onConnected() {} }))); }); + const copy = getBotSettingsCopy(locale).onboarding; + assert.equal(document.querySelector('.settingsBotOnboardingStatus')?.textContent, code === 'network_error' ? copy.errors.network_error : copy.failed); + } + for (const reason of ['connections-open-503', '外部错误 token=secret']) { + await act(async () => root.render(null)); + const snapshot = { sessionId: reason, provider: 'dingtalk', state: 'connected', warningCode: 'saved_not_connected', warningDetail: reason, nextPollAfterMs: 1000 }; + Object.assign(window, { maka: { settings: { bots: { onboarding: { async start() { return { ok: true, data: snapshot }; }, async cancel() { return { ok: true }; } } } } } }); + await act(async () => { root.render(withLocale(locale, createElement(BotChatChannelDetail, detailProps({ provider: 'dingtalk', channel: createDefaultBotChannel('dingtalk') })))); }); + const copy = getBotSettingsCopy(locale); + const button = [...document.querySelectorAll('button')].find((button) => button.textContent === copy.detail.scanConnect); + assert.ok(button); + await act(async () => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); + const toast = document.querySelector('[data-toast-id]'); + assert.ok(toast, 'completion must render a warning toast'); + assert.ok(toast.textContent.includes(copy.onboarding.savedNotConnectedDetail(reason === 'connections-open-503' ? copy.statusReasons.withCode.connectionsOpen('503') : copy.status.detailsInLogs))); + assert.ok(!document.body.textContent.includes('token=secret')); + } + } finally { + await act(async () => root.unmount()); + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + }); +} 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..a57b5742bf 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -26,7 +26,10 @@ import { type AppSettings, type UpdateAppSettingsInput, } from '@maka/core/settings'; -import type { BotRegistry } from '@maka/runtime/bots'; +import { BotRegistry } from '@maka/runtime/bots'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; +import { loadRuntimeUndici } from './runtime-undici.js'; import type { SettingsStore } from '@maka/storage/settings-store'; import { BotOnboardingService, @@ -105,6 +108,34 @@ function startResult() { } describe('BotOnboardingService', () => { + it('preserves a failed live Stream probe through the onboarding warning and localized presenter', async () => { + const { MockAgent, getGlobalDispatcher, setGlobalDispatcher } = loadRuntimeUndici(); + const previous = getGlobalDispatcher(); + const agent = new MockAgent(); + agent.disableNetConnect(); + setGlobalDispatcher(agent); + const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} }); + agent.get('https://oapi.dingtalk.com').intercept({ path: '/gettoken?appkey=public-id&appsecret=private-secret', method: 'GET' }).reply(200, { access_token: 'test-access-token', expires_in: 7200 }); + agent.get('https://api.dingtalk.com').intercept({ path: '/v1.0/gateway/connections/open', method: 'POST' }).reply(503, { message: 'unavailable' }); + const flow = harness({ + async start() { return startResult(); }, + async poll() { return { status: 'confirmed', credential: { provider: 'dingtalk', clientId: 'public-id', clientSecret: 'private-secret' } }; }, + }, async (settings) => registry.applySettings(settings.botChat), { getStatus: () => ({ ...registry.getStatus('dingtalk') }) }); + try { + const started = await flow.service.start({ provider: 'dingtalk' }); + flow.advance(5000); + const snapshot = await flow.service.poll(started.sessionId); + agent.assertNoPendingInterceptors(); + assert.equal(snapshot.state, 'connected'); + assert.equal(snapshot.warningCode, 'saved_not_connected'); + assert.equal(snapshot.warningDetail, 'connections-open-503'); + for (const locale of UI_LOCALES) assert.equal(botStatusReasonMessage(snapshot.warningDetail, locale), getBotSettingsCopy(locale).statusReasons.withCode.connectionsOpen('503')); + } finally { + await registry.stopAll(); + setGlobalDispatcher(previous); + await agent.close(); + } + }); it('persists confirmed credentials in main while returning a secret-free snapshot', async () => { const adapter: BotOnboardingProviderAdapter = { async start() { return startResult(); }, @@ -351,8 +382,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 +407,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__/permission-center-bot-reason.test.ts b/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts new file mode 100644 index 0000000000..c540f55f2a --- /dev/null +++ b/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts @@ -0,0 +1,117 @@ +/* + * 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 assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { after, before, test } from 'node:test'; +import { build } from 'esbuild'; +import { UI_LOCALES, type UiLocale } from '@maka/core/ui-locale'; +import type { HealthSignal } from '@maka/core/health'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; +import { getHealthCenterCopy, type HealthCenterCopy } from '../../renderer/locales/settings-health-copy.js'; + +const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); +let localizedSignalDetail: ( + signal: HealthSignal, + copy: HealthCenterCopy, + locale: UiLocale, +) => string | undefined; + +before(async () => { + // The health page pre-resolves bot capability reasons at the page layer + // (copy catalogs may not runtime-import each other); bundle it the same way + // bot-chat-detail.test.ts does so node's ESM resolver sees a self-contained + // module graph. + const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/bot-reason-')); + await build({ + entryPoints: [resolve(REPO_ROOT, 'apps/desktop/src/renderer/settings/health-center-page.tsx')], + outdir, + outExtension: { '.js': '.mjs' }, + bundle: true, + packages: 'external', + platform: 'node', + format: 'esm', + jsx: 'automatic', + target: 'node20', + logLevel: 'silent', + }); + ({ localizedSignalDetail } = await import(pathToFileURL(resolve(outdir, 'health-center-page.mjs')).href)); + after(() => rm(outdir, { recursive: true, force: true })); +}); + +// Producers emit machine codes; every renderer surface must resolve them +// through the bot copy table. A raw code such as `gateway-closed-4004` must +// never survive to the page in any locale (P2: Permission Center regression). +const BOT_REASONS = ['gateway-closed-4004', 'stream-failed', 'connections-open-503', 'rate-limited'] as const; + +test('bot capability reasons resolve to localized sentences for every locale', () => { + for (const reason of BOT_REASONS) { + for (const locale of UI_LOCALES) { + const rendered = botStatusReasonMessage(reason, locale); + assert.ok(rendered, `${locale}: ${reason} must render copy`); + assert.notEqual(rendered, reason, `${locale}: ${reason} must not render raw`); + assert.notEqual(rendered, getBotSettingsCopy(locale).status.detailsInLogs, `${locale}: ${reason} must localize, not fall back to detailsInLogs`); + } + } +}); + +test('unknown bot reasons degrade to the localized generic line, never the raw code', () => { + for (const locale of UI_LOCALES) { + assert.equal(botStatusReasonMessage('future-code', locale), getBotSettingsCopy(locale).status.detailsInLogs); + } +}); + +test('health center renders localized bot capability reasons in all locales', () => { + const signal = (reason: string): HealthSignal => ({ + id: 'capability:bot:discord', + label: 'Discord Bot', + scope: 'bot', + layer: 'runtime_probe', + status: 'warning', + source: 'capability_snapshot', + checkedAt: 1, + message: 'capability_degraded', + detail: { kind: 'capability_reason', reason }, + relatedCapabilityId: 'bot:discord', + }); + for (const locale of UI_LOCALES) { + const expected = getBotSettingsCopy(locale).statusReasons.withCode.gatewayClosed('4004'); + assert.equal(localizedSignalDetail(signal('gateway-closed-4004'), getHealthCenterCopy(locale), locale), expected); + assert.ok(!localizedSignalDetail(signal('stream-failed'), getHealthCenterCopy(locale), locale)?.includes('stream-failed')); + } +}); + +test('health center keeps the interim CJK passthrough for non-bot capability reasons', () => { + const signal: HealthSignal = { + id: 'capability:computer_use', + label: 'Computer Use', + scope: 'capability', + layer: 'runtime_probe', + status: 'warning', + source: 'capability_snapshot', + checkedAt: 1, + message: 'capability_degraded', + detail: { kind: 'capability_reason', reason: 'maka-cu service 启动失败、已退出或已停止。' }, + relatedCapabilityId: 'computer_use', + }; + assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('zh-CN'), 'zh-CN'), 'maka-cu service 启动失败、已退出或已停止。'); + assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('en'), 'en'), 'See the corresponding settings page for details.'); +}); \ No newline at end of file diff --git a/apps/desktop/src/main/__tests__/runtime-undici.ts b/apps/desktop/src/main/__tests__/runtime-undici.ts new file mode 100644 index 0000000000..d66a899f83 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-undici.ts @@ -0,0 +1,42 @@ +/* + * 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 { createRequire } from 'node:module'; + +type MockInterceptor = { + reply(status: number, body?: unknown): void; + replyWithError(error: Error): void; +}; + +export type MockAgentLike = { + disableNetConnect(): void; + get(origin: string): { intercept(options: { path: string; method: string }): MockInterceptor }; + assertNoPendingInterceptors(): void; + close(): Promise; +}; + +type UndiciLike = { + MockAgent: new () => MockAgentLike; + getGlobalDispatcher(): unknown; + setGlobalDispatcher(dispatcher: unknown): void; +}; + +export function loadRuntimeUndici(): UndiciLike { + return createRequire(import.meta.resolve('@maka/runtime/bots'))('undici'); +} 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..3cbd2eb556 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,6 @@ describe("settings IPC helpers", () => { const result = toSettingsTestResult("telegram", { ok: true, identity: { id: "42", username: "maka_bot", displayName: "Maka" }, - hint: "ready", }); assert.equal(result.ok, true); @@ -115,20 +114,16 @@ describe("settings IPC helpers", () => { username: "maka_bot", displayName: "Maka", }); - assert.equal(result.details?.hint, "ready"); }); - test("redacts and generalizes bot test errors before returning SettingsTestResult", () => { + test("redacts bot test error diagnostics before returning SettingsTestResult", () => { const result = toSettingsTestResult("telegram", { ok: false, + errorCode: "connection_failed", error: "401 Authorization: Bearer sk-live-secret-token-value", }); assert.equal(result.code, "bot_connection_failed"); - assert.equal( - result.message, - "Telegram connection test failed: Authentication failed.", - ); assert.equal( JSON.stringify(result).includes("sk-live-secret-token-value"), false, 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..6a9cc2f356 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 @@ -18,8 +18,18 @@ */ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import { settingsTestResultMessage } from "../../renderer/locales/settings-test-result-copy.js"; +import { toSettingsTestResult } from "../settings-ipc-helpers.js"; +import { createDefaultBotChannel, type BotProvider } from '@maka/core/bot-chat-settings'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import { BotRegistry, testBotChannel, WechatBridge, testWechatIlinkCredentials, type BotTestErrorCode } from '@maka/runtime/bots'; +import { createDefaultSettings, mergeSettings, type SettingsTestResult, type UpdateAppSettingsInput } from '@maka/core/settings'; +import type { SettingsStore } from '@maka/storage/settings-store'; +import type { IpcMain } from 'electron'; +import { registerSettingsBotsIpc } from '../settings-bots-ipc-main.js'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; +import { loadRuntimeUndici } from './runtime-undici.js'; test("missing proxy credentials have actionable bilingual copy", () => { const result = { @@ -37,3 +47,158 @@ 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("feishu", { + ok: false, + errorCode: "feishu_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.", + ); +}); + +test('WeChat failed probe survives start and localized status presentation', async () => { + const bridge = new WechatBridge({ + ...createDefaultBotChannel('wechat'), enabled: true, webhookUrl: 'https://remote.invalid', + }); + const statuses: string[] = []; + bridge.on('statusChange', (status) => statuses.push(status.reason)); + await bridge.start(); + assert.equal(bridge.getStatus().reason, 'wechat_bridge_url_invalid'); + assert.deepEqual(statuses, ['wechat_bridge_url_invalid']); + for (const locale of UI_LOCALES) { + assert.equal(botStatusReasonMessage(bridge.getStatus().reason, locale), getBotSettingsCopy(locale).testErrors.wechat_bridge_url_invalid); + const probe = await testWechatIlinkCredentials(createDefaultBotChannel('wechat')); + const result = toSettingsTestResult('wechat', probe); + assert.equal(result.code, 'wechat_ilink_credentials_incomplete'); + assert.equal(settingsTestResultMessage(result, locale), getBotSettingsCopy(locale).testErrors.wechat_ilink_credentials_incomplete); + } +}); + +test('missing credentials retain provider-specific fields through the adapter in all locales', async () => { + for (const [provider, code, fields] of [ + ['slack', 'slack_tokens_missing', ['Bot Token', 'App-Level Token']], + ['wecom', 'wecom_credentials_missing', ['Bot ID', 'Secret']], + ['dingtalk', 'dingtalk_credentials_missing', ['AppKey', 'Client Secret']], + ['qq', 'qq_credentials_missing', ['App ID', 'AppSecret']], + ] as const) { + const result = toSettingsTestResult(provider, await testBotChannel(provider, createDefaultBotChannel(provider))); + assert.equal(result.code, code); + for (const locale of UI_LOCALES) { + const message = settingsTestResultMessage(result, locale); + for (const field of fields) assert.ok(message.includes(field), `${locale}: ${message}`); + assert.ok(!message.includes('App Secret'), message); + } + } +}); + +test('Telegram credential rejection is distinct from transient and malformed responses', async () => { + const { MockAgent, getGlobalDispatcher, setGlobalDispatcher } = loadRuntimeUndici(); + const previous = getGlobalDispatcher(); + const agent = new MockAgent(); + agent.disableNetConnect(); + setGlobalDispatcher(agent); + const token = '12345:test-token-secret'; + const log = mock.method(console, 'warn', () => {}); + try { + for (const [status, body, expected] of [ + [429, { ok: false, error_code: 429, description: `retry token=${token}` }, 'connection_failed'], + [401, { ok: false, error_code: 401 }, 'token_invalid'], + [200, { ok: false, error_code: 401 }, 'token_invalid'], + [500, { ok: false, error_code: 500 }, 'connection_failed'], + [503, { ok: false, error_code: 401 }, 'connection_failed'], + [403, { ok: false, error_code: 403 }, 'connection_failed'], + [200, { ok: false }, 'connection_failed'], + [503, 'not JSON', 'connection_failed'], + ] as const) { + agent.get('https://api.telegram.org').intercept({ path: `/bot${token}/getMe`, method: 'GET' }).reply(status, body); + const probe = await testBotChannel('telegram', { ...createDefaultBotChannel('telegram'), token }); + agent.assertNoPendingInterceptors(); + assert.equal(probe.errorCode, expected, `HTTP ${status}`); + assert.ok(probe.error, `HTTP ${status} must retain a diagnostic fallback`); + assert.ok(!probe.error.includes(token)); + const result = toSettingsTestResult('telegram', probe); + for (const locale of UI_LOCALES) { + const message = settingsTestResultMessage(result, locale); + assert.ok(message.length > 0); + assert.ok(!message.includes(token)); + assert.equal(result.code === 'bot_token_invalid', expected === 'token_invalid'); + } + } + agent.get('https://api.telegram.org').intercept({ path: `/bot${token}/getMe`, method: 'GET' }).replyWithError(new Error(`Network error ${token}`)); + const network = await testBotChannel('telegram', { ...createDefaultBotChannel('telegram'), token }); + agent.assertNoPendingInterceptors(); + assert.equal(network.errorCode, 'connection_failed'); + const diagnostic = log.mock.calls.map((call) => call.arguments.join(' ')).join('\n'); + assert.match(diagnostic, /retry token=\[redacted\]/); + assert.ok(!diagnostic.includes(token)); + } finally { + log.mock.restore(); + setGlobalDispatcher(previous); + await agent.close(); + } +}); + +test('settings IPC persists stable failure codes consumed by status presenters', async () => { + let settings = createDefaultSettings(); + const handlers = new Map[1]>(); + const handle = registerSettingsBotsIpc({ + ipcMain: { handle(channel, listener) { handlers.set(channel, listener); } }, + settingsStore: { + async get() { return settings; }, + async update(patch: UpdateAppSettingsInput) { settings = mergeSettings(settings, patch); return settings; }, + } as SettingsStore, + botRegistry: new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} }), + async applySettingsRuntimeEffects() {}, + productVersion: 'test', + async openExternal() {}, + }); + try { + const testChannel = handlers.get('settings:testBotChannel'); + assert.ok(testChannel); + for (const provider of ['slack', 'telegram', 'dingtalk', 'qq'] as const) { + const result = await testChannel({} as never, provider) as SettingsTestResult; + const channel = settings.botChat.channels[provider]; + const probe = await testBotChannel(provider, channel); + assert.equal(channel.lastError, probe.errorCode); + assert.equal(channel.readinessReason, probe.errorCode); + for (const locale of UI_LOCALES) assert.equal(botStatusReasonMessage(channel.lastError, locale), settingsTestResultMessage(result, locale)); + } + } finally { handle.dispose(); } +}); + +test('unknown status and settings codes never render arbitrary diagnostics or inherited keys', () => { + for (const locale of UI_LOCALES) { + for (const reason of ['Network error', '外部错误 token=secret', 'Bad Request: chat not found', 'future-code', 'constructor', 'toString', '__proto__']) { + assert.equal(botStatusReasonMessage(reason, locale), getBotSettingsCopy(locale).status.detailsInLogs); + assert.equal(settingsTestResultMessage({ ok: false, code: reason as never, message: reason }, locale), settingsTestResultMessage({ ok: false, code: 'bot_connection_failed', message: '' }, locale)); + assert.equal(toSettingsTestResult('slack', { ok: false, errorCode: reason as never, error: reason }).code, 'bot_connection_failed'); + } + assert.equal(botStatusReasonMessage(undefined, locale), undefined); + assert.notEqual(botStatusReasonMessage('slack-disconnected', locale), 'slack-disconnected'); + } +}); + +for (const locale of UI_LOCALES) { + test(`${locale}: producer error codes match between settings and status copy`, () => { + const providers = { + connection_failed: 'telegram', token_missing: 'telegram', token_invalid: 'telegram', + slack_tokens_missing: 'slack', feishu_credentials_missing: 'feishu', + wecom_credentials_missing: 'wecom', dingtalk_credentials_missing: 'dingtalk', + dingtalk_no_access_token: 'dingtalk', qq_credentials_missing: 'qq', + qq_no_access_token: 'qq', wechat_bridge_url_invalid: 'wechat', + wechat_ilink_credentials_incomplete: 'wechat', + } satisfies Record; + for (const [code, provider] of Object.entries(providers)) { + const result = toSettingsTestResult(provider, { ok: false, errorCode: code as BotTestErrorCode }); + const message = settingsTestResultMessage(result, locale); + assert.equal(message, botStatusReasonMessage(code, locale), code); + assert.notEqual(message, getBotSettingsCopy(locale).status.detailsInLogs, code); + } + }); +} 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..6d75d2c821 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, + generalizedErrorMessageForLocale, + 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,12 +491,17 @@ 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 超时 / 鉴权失败 / // 网络错误 survive as specific Chinese copy instead of collapsing to one generic // line. The helper redacts secrets before returning. - return generalizedErrorMessageChinese(error, '扫码接入暂时不可用,请稍后重试。'); + return generalizedErrorMessageForLocale(error, '扫码接入暂时不可用,请稍后重试。', 'zh-CN'); } /** diff --git a/apps/desktop/src/main/settings-bots-ipc-main.ts b/apps/desktop/src/main/settings-bots-ipc-main.ts index 5910c3b117..3cc97d37f0 100644 --- a/apps/desktop/src/main/settings-bots-ipc-main.ts +++ b/apps/desktop/src/main/settings-bots-ipc-main.ts @@ -32,10 +32,7 @@ import { BotOnboardingService, type BotOnboardingProviderAdapter, } from './bot-onboarding-main.js'; -import { - botTestErrorMessage, - toSettingsTestResult, -} from './settings-ipc-helpers.js'; +import { toSettingsTestResult } from './settings-ipc-helpers.js'; export interface SettingsBotsIpcDeps { readonly ipcMain: Pick; @@ -90,12 +87,12 @@ export function registerSettingsBotsIpc( : 'configured') as BotReadinessState, readinessReason: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : result.errorCode ?? 'connection_failed', readinessUpdatedAt: Date.now(), lastTestAt: Date.now(), lastError: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : result.errorCode ?? 'connection_failed', }; 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..5327a06f01 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,68 +195,48 @@ 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, + // Presenters localize through settingsTestResultMessage; this field stays + // an English diagnostic for support dumps and is never rendered. message: result.ok ? `${botDisplayLabel(provider)} credentials are valid${result.identity?.username ? ` for ${result.identity.username}` : ""}.` - : (failure?.message ?? - `${botDisplayLabel(provider)} connection test failed.`), + : `${botDisplayLabel(provider)} connection test failed (${failure?.code ?? "bot_connection_failed"}).`, details: { ...(result.identity ? { identity: result.identity } : {}), ...(result.capabilities ? { capabilities: result.capabilities } : {}), - ...(result.hint ? { hint: result.hint } : {}), }, }; } -export function botTestErrorMessage( - provider: BotProvider, - error: unknown, -): string { - return botTestFailure(provider, error).message; -} +const BOT_TEST_FAILURE_CODES = { + token_missing: 'bot_token_missing', + token_invalid: 'bot_token_invalid', + feishu_credentials_missing: 'bot_app_credentials_missing', + slack_tokens_missing: 'slack_tokens_missing', + wecom_credentials_missing: 'wecom_credentials_missing', + dingtalk_credentials_missing: 'dingtalk_credentials_missing', + dingtalk_no_access_token: 'dingtalk_no_access_token', + qq_credentials_missing: 'qq_credentials_missing', + qq_no_access_token: 'qq_no_access_token', + wechat_bridge_url_invalid: 'wechat_bridge_url_invalid', + wechat_ilink_credentials_incomplete: 'wechat_ilink_credentials_incomplete', + connection_failed: 'bot_connection_failed', +} satisfies Record; function botTestFailure( provider: BotProvider, - error: unknown, -): { 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")) { - return { - code: "bot_token_missing", - message: `${label} requires a Bot Token.`, - }; - } - if (lower.includes("invalid bot token")) { - return { - code: "bot_token_invalid", - message: `${label} rejected the Bot Token.`, - }; - } - if ( - provider === "feishu" && - /appid|app_id|appsecret|app_secret|required/.test(lower) - ) { - return { - code: "bot_app_credentials_missing", - message: "Feishu requires an App ID and App Secret.", - }; + result: Pick, +): { code: SettingsTestResultCode } { + const resolved = + result.errorCode && Object.hasOwn(BOT_TEST_FAILURE_CODES, result.errorCode) + ? BOT_TEST_FAILURE_CODES[result.errorCode] + : 'bot_connection_failed'; + if (result.error) { + // Redacted diagnostic for the support log only; product copy is code-keyed. + console.warn(`[bots:${provider}] ${resolved}: ${redactSecrets(result.error)}`); } - - const classified = generalizedErrorMessage(raw, ""); - return { - code: "bot_connection_failed", - message: classified - ? `${label} connection test failed: ${classified}.` - : `${label} connection test failed.`, - }; + return { code: resolved }; } diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index b34d2fbca1..e96f13a3fc 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 { GENERALIZED_ERROR_COPY } from '@maka/core/redaction'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string @@ -54,6 +57,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': '消息接收失败,请检查网络和运行日志', + ...GENERALIZED_ERROR_COPY['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 +129,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 +152,13 @@ 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: '扫码接入已取消。', + ...GENERALIZED_ERROR_COPY['zh-CN'], + unavailable: '扫码接入暂时不可用,请稍后重试。', + } satisfies Record, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -140,6 +196,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': '訊息接收失敗,請檢查網路和執行記錄', + ...GENERALIZED_ERROR_COPY['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 +268,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 +291,13 @@ 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: '掃碼串接已取消。', + ...GENERALIZED_ERROR_COPY['zh-TW'], + unavailable: '掃碼串接暫時無法使用,請稍後重試。', + } satisfies Record, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -217,14 +326,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', + ...GENERALIZED_ERROR_COPY.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.', 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.', + ...GENERALIZED_ERROR_COPY.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' }, }; @@ -238,3 +402,57 @@ 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`. + * 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; +} diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index bca1c83724..acf0339e99 100644 --- a/apps/desktop/src/renderer/locales/settings-health-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-health-copy.ts @@ -123,7 +123,7 @@ const SETTINGS_HEALTH_COPY = { source: '来源:', blocksSend: '阻塞发送', blocksCapability: '阻塞能力', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 运行态` : signal.label), signalMessage: (signal) => signalMessagesZh[signal.message], - signalDetail: (signal) => signalDetailZh(signal.detail), + signalDetail: (signal) => signalDetailZh(signal), }, 'zh-TW': { loading: '正在載入健康快照', readFailed: '無法讀取健康快照', noData: '健康服務未返回資料。', readAgain: '重新讀取', @@ -142,7 +142,7 @@ const SETTINGS_HEALTH_COPY = { source: '來源:', blocksSend: '阻塞傳送', blocksCapability: '阻塞能力', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 執行狀態` : signal.label), signalMessage: (signal) => signalMessagesZhTw[signal.message], - signalDetail: (signal) => signalDetailZhTw(signal.detail), + signalDetail: (signal) => signalDetailZhTw(signal), }, en: { loading: 'Loading health snapshot', readFailed: 'Could not read health snapshot', noData: 'The health service returned no data.', readAgain: 'Read again', @@ -161,7 +161,7 @@ const SETTINGS_HEALTH_COPY = { source: 'Source: ', blocksSend: 'Blocks sending', blocksCapability: 'Blocks capability', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} runtime` : signal.label), signalMessage: (signal) => signalMessagesEn[signal.message], - signalDetail: (signal) => signalDetailEn(signal.detail), + signalDetail: (signal) => signalDetailEn(signal), }, } satisfies UiCatalog; @@ -253,7 +253,8 @@ const connectionTestErrorMessages = { }, } satisfies UiCatalog>; -function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefined { +function signalDetailZh(signal: HealthSignal): string | undefined { + const detail = signal.detail; if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': @@ -272,7 +273,8 @@ function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefi ].join(' · '); case 'capability_reason': // Interim: capability-snapshot still emits zh-CN prose; code it as a - // CapabilityReasonCode to drop this sniff. + // CapabilityReasonCode to drop this sniff. Bot reasons pre-resolve at the + // page layer (copy catalogs may not runtime-import each other). return /[\u3400-\u9fff]/u.test(detail.reason) ? detail.reason : '状态详情请见对应设置页。'; case 'last_test_error_class': return connectionTestErrorMessages['zh-CN'][detail.errorClass]; @@ -283,7 +285,8 @@ function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefi } } -function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | undefined { +function signalDetailZhTw(signal: HealthSignal): string | undefined { + const detail = signal.detail; if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': @@ -311,7 +314,8 @@ function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | unde } } -function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefined { +function signalDetailEn(signal: HealthSignal): string | undefined { + const detail = signal.detail; if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': diff --git a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts index ee5ce9b90d..46c54c86d0 100644 --- a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts @@ -17,9 +17,11 @@ * under the License. */ -import type { SettingsTestResult } from '@maka/core/settings'; +import type { SettingsTestResult, SettingsTestResultCode } from '@maka/core/settings'; +import type { BotTestErrorCode } from '@maka/runtime/bots'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { lookupCopy } from '@maka/core/ui-locale'; type SettingsTestResultCopy = { proxy: { @@ -40,6 +42,10 @@ type SettingsTestResultCopy = { tokenInvalid: string; appCredentialsMissing: string; connectionFailed: string; + errors: Record< + Exclude, + string + >; }; }; @@ -67,6 +73,16 @@ const COPY = { tokenInvalid: "Bot Token 无效,请检查后重试。", appCredentialsMissing: "请填写 App ID 和 App Secret 后再测试。", connectionFailed: "请检查凭据和网络设置后重试。", + errors: { + slack_tokens_missing: '请填写 Slack Bot Token 和 App-Level Token 后再测试。', + 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。', + }, }, }, 'zh-TW': { @@ -86,12 +102,22 @@ const COPY = { bot: { credentialsValid: (username) => username - ? `憑據檢查已透過 · ${username}。這不代表訊息收發服務已啟動。` - : "憑據檢查已透過。這不代表訊息收發服務已啟動。", + ? `憑證檢查已透過 · ${username}。這不代表訊息收發服務已啟動。` + : "憑證檢查已透過。這不代表訊息收發服務已啟動。", tokenMissing: "請填寫 Bot Token 後再測試。", tokenInvalid: "Bot Token 無效,請檢查後重試。", appCredentialsMissing: "請填寫 App ID 和 App Secret 後再測試。", - connectionFailed: "請檢查憑據和網路設定後重試。", + connectionFailed: "請檢查憑證和網路設定後重試。", + errors: { + slack_tokens_missing: '請填寫 Slack Bot Token 和 App-Level Token 後再測試。', + 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。', + }, }, }, en: { @@ -124,6 +150,16 @@ const COPY = { "Enter an App ID and App Secret before testing the connection.", connectionFailed: "Check the credentials and network settings, then try again.", + errors: { + slack_tokens_missing: 'Enter a Slack Bot Token and App-Level Token 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 UiCatalog; @@ -162,10 +198,10 @@ export function settingsTestResultMessage( return copy.bot.appCredentialsMissing; case "bot_connection_failed": return copy.bot.connectionFailed; + // Producer error codes resolve through the local per-locale table; + // lookupCopy keeps unknown/inherited keys from leaking. default: - return locale === "en" && result.message.trim() - ? result.message - : copy.bot.connectionFailed; + return lookupCopy(copy.bot.errors, result.code) ?? copy.bot.connectionFailed; } } diff --git a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx index ae9ee1aeaf..5fcf445bd7 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx @@ -53,7 +53,7 @@ import { botStatusDetail, type BotPendingActionName, } from './bot-chat-shared'; -import { getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; import { SettingsPage, SettingsSection } from './settings-section'; import { dotForStatus } from '@maka/ui'; @@ -305,7 +305,7 @@ export function BotChatChannelDetail(props: { title={detailCopy.latestFailure} description={( - {locale === 'zh-CN' ? viewState.currentError : detailCopy.latestFailureDetail} + {botStatusReasonMessage(viewState.currentError, locale)} )} /> )} @@ -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 = botCopy.onboarding; toast.warning( detailCopy.credentialsSaved(providerPresentation.label), - locale === 'zh-CN' ? snapshot.warning : detailCopy.savedButNotConnected, + snapshot.warningDetail + ? onboardingCopy.savedNotConnectedDetail(botStatusReasonMessage(snapshot.warningDetail, locale)) + : onboardingCopy.savedNotConnected, ); return; } @@ -635,7 +638,7 @@ function BotAllowedUserIdsField(props: { if (!same) props.onChange(next); }; const warning = invalidEntries.length > 0 - ? `${copy.invalidUsers(invalidEntries.slice(0, 3).join(locale !== 'en' ? '、' : ', '))}${invalidEntries.length > 3 ? copy.moreInvalid(invalidEntries.length) : ''}` + ? copy.invalidUsers(invalidEntries) : undefined; return ( @@ -647,7 +650,7 @@ function BotAllowedUserIdsField(props: { hasSpellCheck={false} placeholder={copy.allowedUsersPlaceholder} label={copy.allowedUsersLabel(parsed.length, MAX_ALLOWED_USER_IDS)} - description={`${copy.allowedUsersHelp}${atCap ? ` ${copy.limitReached}` : ''}`} + description={copy.allowedUsersHelp(atCap)} status={warning ? { type: 'warning', message: warning } : undefined} /> ); diff --git a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx index b2e248a006..5ff5872d5b 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); if (status?.reason) return botStatusDetail(status, locale); return fallback; } diff --git a/apps/desktop/src/renderer/settings/bot-chat-shared.tsx b/apps/desktop/src/renderer/settings/bot-chat-shared.tsx index 5ec2b5dee9..edfba91217 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-shared.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-shared.tsx @@ -21,7 +21,7 @@ import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-setting import type { UiLocale } from '@maka/core/ui-locale'; import type { BotStatus } from '@maka/runtime/bots'; import { BotBrandLogo as BotBrandMark } from '@maka/ui'; -import { getBotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../locales/settings-bot-copy'; /** * Per-platform brand presentation. @@ -102,17 +102,13 @@ export function botStatusDetail(status: BotStatus, locale: UiLocale): string { const copy = getBotSettingsCopy(locale).status; switch (status.reason) { case 'disabled': return copy.disabled; - case 'no-token': return copy.noToken; - case 'missing-feishu-credentials': return copy.missingFeishuCredentials; + case 'token_missing': return copy.noToken; + case 'feishu_credentials_missing': return copy.missingFeishuCredentials; case 'feishu-domain-required': return copy.feishuDomainRequired; case 'feishu-events-not-connected': return copy.feishuEventsNotConnected; case 'scaffold-only': return copy.unavailable; case 'unimplemented': return copy.unavailable; case 'stopped': return copy.stopped; - // PR-BOT-CHAT-POLISH-0: the previous fallback `status.reason ?? - // '暂无运行细节'` would surface a raw reason code (e.g. - // `polling-timeout`) for any unmapped state. That's noise the - // user can't act on; collapse to a generalized copy. - default: return copy.detailsInLogs; + default: return botStatusReasonMessage(status.reason, locale) ?? copy.detailsInLogs; } } diff --git a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx index 90496c3070..48c6a32348 100644 --- a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx +++ b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx @@ -37,7 +37,7 @@ import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; import { ICON_SIZE, AlertCircle, Check } from '@maka/ui/icons'; import { BotBrandLogo } from './bot-chat-shared'; import { settingsActionErrorMessage } from './settings-error-copy'; -import { getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; +import { botOnboardingErrorMessage, botStatusReasonMessage, getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; export function BotOnboardingModal(props: { provider: BotOnboardingProvider; @@ -190,7 +190,7 @@ export function BotOnboardingModal(props: { ) : starting || snapshot?.state === 'connecting' ? ( ) : snapshot?.state === 'connected' ? ( - snapshot.warning ? ( + snapshot.warningCode ? ( @@ -251,7 +251,7 @@ function statusCopy( starting: boolean, error: string | null, copy: BotSettingsCopy['onboarding']['providers'][BotOnboardingProvider], - locale: 'zh-CN' | 'zh-TW' | 'en' = 'zh-CN', + locale: 'zh-CN' | 'zh-TW' | 'en', ): string { const shared = getBotSettingsCopy(locale).onboarding; if (starting) return shared.generating; @@ -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(botStatusReasonMessage(snapshot.warningDetail, locale)) + : 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 botOnboardingErrorMessage(snapshot.errorCode, locale); 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..b9e776fe6b 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); @@ -120,6 +121,9 @@ export function WechatQrLoginModal(props: { void window.maka.settings.bots.wechatQrCode() .then((next) => { if (!active) return; + // The raw error is an English diagnostic, never product copy; the + // dialog renders hint-code titles and the localized generic hint. + if (!next.ok && next.error) console.warn(`[bots:wechat] QR sign-in: ${next.error}`); setResult(next); if (next.ok && next.loggedIn && !notifiedLoggedInRef.current) { notifiedLoggedInRef.current = true; @@ -128,10 +132,10 @@ export function WechatQrLoginModal(props: { }) .catch((error) => { if (!active) return; + console.warn('[bots:wechat] QR sign-in failed:', error); setResult({ ok: false, error: settingsActionErrorMessage(error, locale), - hint: copy.readQrFailed, }); }) .finally(() => { @@ -221,8 +225,8 @@ export function WechatQrLoginModal(props: { } - title={error.error} - description={error.hint} + title={error.hintCode ? botCopy.testHints[error.hintCode] : copy.readQrFailed} + description={copy.readQrFailed} actions={