diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..7a9193fae9 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1620,7 +1620,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/settings-daily-review-copy.ts": { "bridgePaths": {}, 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..2a38c6da20 --- /dev/null +++ b/apps/desktop/src/main/__tests__/bot-chat-detail.test.ts @@ -0,0 +1,291 @@ +/* + * 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'; + +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(), ['网络错误,请检查网络和代理设置', '網路錯誤,請檢查網路和代理設定', 'Network error. Check the network and proxy settings']]); + 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) => code === 'wechat_bridge_url_invalid' ? getBotSettingsCopy(locale).testHints.wechat_bridge_local_only : getBotSettingsCopy(locale).testHints.wechat_ilink_login_required)]); + } + 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__/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..390c5d8001 100644 --- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts @@ -101,7 +101,7 @@ describe("settings IPC helpers", () => { const result = toSettingsTestResult("telegram", { ok: true, identity: { id: "42", username: "maka_bot", displayName: "Maka" }, - hint: "ready", + hintCode: "telegram_send_start", }); assert.equal(result.ok, true); @@ -115,7 +115,7 @@ describe("settings IPC helpers", () => { username: "maka_bot", displayName: "Maka", }); - assert.equal(result.details?.hint, "ready"); + assert.equal(result.details?.hintCode, "telegram_send_start"); }); test("redacts and generalizes bot test errors before returning SettingsTestResult", () => { diff --git a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts index 9f86548727..b8c82dbe44 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,155 @@ 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).testHints.wechat_bridge_local_only); + 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).testHints.wechat_ilink_login_required); + } +}); + +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(); } +}); + +for (const locale of UI_LOCALES) { + test(`${locale}: every producer error code has matching 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); + } + }); + test(`${locale}: unknown status and settings codes never render arbitrary diagnostics or inherited keys`, () => { + 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'); + }); +} 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..6348cb9dc2 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -33,7 +33,7 @@ import { maskSensitive, type TestProxyResult, } from "@maka/core/settings/network-settings"; -import type { BotTestResult } from '@maka/runtime/bots'; +import type { BotTestErrorCode, BotTestResult } from '@maka/runtime/bots'; import { collectPersonalizationWarnings } from '@maka/runtime/system-prompt/personalization-prompt'; import { getTavilyCredentialSource } from "./web-search/credentials.js"; @@ -195,9 +195,7 @@ export function toSettingsTestResult( provider: BotProvider, result: BotTestResult, ): SettingsTestResult { - const failure = result.ok - ? undefined - : botTestFailure(provider, result.error); + const failure = result.ok ? undefined : botTestFailure(provider, result); return { ok: result.ok, code: result.ok ? "bot_credentials_valid" : failure?.code, @@ -208,53 +206,57 @@ export function toSettingsTestResult( details: { ...(result.identity ? { identity: result.identity } : {}), ...(result.capabilities ? { capabilities: result.capabilities } : {}), - ...(result.hint ? { hint: result.hint } : {}), + ...(result.hintCode ? { hintCode: result.hintCode } : {}), }, }; } -export function botTestErrorMessage( - provider: BotProvider, - error: unknown, -): 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, + result: Pick, ): { code: SettingsTestResultCode; message: string } { const label = botDisplayLabel(provider); - const raw = redactSecrets( - error instanceof Error ? error.message : String(error ?? ""), - ).trim(); - const lower = raw.toLowerCase(); - - if (lower.includes("bot token is required")) { + if (result.errorCode === "token_missing") { return { code: "bot_token_missing", message: `${label} requires a Bot Token.`, }; } - if (lower.includes("invalid bot token")) { + if (result.errorCode === "token_invalid") { return { code: "bot_token_invalid", message: `${label} rejected the Bot Token.`, }; } - if ( - provider === "feishu" && - /appid|app_id|appsecret|app_secret|required/.test(lower) - ) { + if (result.errorCode === 'feishu_credentials_missing') { return { code: "bot_app_credentials_missing", - message: "Feishu requires an App ID and App Secret.", + message: `${label} requires an App ID and App Secret.`, }; } + const raw = redactSecrets( + typeof result.error === "string" ? result.error : "", + ).trim(); const classified = generalizedErrorMessage(raw, ""); return { - code: "bot_connection_failed", + code: result.errorCode && Object.hasOwn(BOT_TEST_FAILURE_CODES, result.errorCode) + ? BOT_TEST_FAILURE_CODES[result.errorCode] : 'bot_connection_failed', message: classified ? `${label} connection test failed: ${classified}.` : `${label} connection test failed.`, diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index b34d2fbca1..a6827d40d7 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -19,8 +19,9 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; +import type { BotStatusCode, BotTestErrorCode } from '@maka/runtime/bots'; -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 +55,57 @@ const zhCopy = { unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志', polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无', }, + testHints: { + slack_socket_ready: '凭据有效,Socket Mode 连接可用。', + wechat_mp_callback_required: '凭据有效;消息收发还需要公众号服务器配置和回调验证。', + telegram_send_start: '发送 /start 给机器人后可在运行态接收消息。', + wecom_runtime_authority: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。', + dingtalk_stream_required: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。', + qq_gateway_required: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。', + wechat_bridge_local_only: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_start_required: '先在本机启动 wechat-bridge,并确认 WeChat 已登录;发送能力需要 wxp_act_ 激活码。', + wechat_ilink_login_required: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。', + wechat_ilink_polling: '扫码登录凭据已保存;运行态会通过 iLink 长轮询接收消息。', + wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。', + }, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack 连接已断开,正在等待重新连接', + disconnected: '连接已断开', + reconnecting: '正在重新连接', + 'stream-failed': '消息接收失败,请检查网络和运行日志', + timeout: '请求超时,请稍后重试', + rate_limited: '请求过于频繁,请稍后重试', + auth_failed: '鉴权失败,请检查凭据', + provider_error: '平台服务暂时不可用,请稍后重试', + network_error: '网络错误,请检查网络和代理设置', + '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,请检查凭据和网络后重试。', + } satisfies Record, string>, overview: { loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序', empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置', @@ -87,8 +139,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 +162,12 @@ const zhCopy = { generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开', done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…', connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', + savedNotConnected: '凭据已保存,但连接未建立,可稍后在设置中重试。', + savedNotConnectedDetail: (detail: string) => `凭据已保存,但连接未建立:${detail},可稍后在设置中重试。`, + errors: { + cancelled: '扫码接入已取消。', timeout: '请求超时', rate_limited: '触发模型速率限制', auth_failed: '鉴权失败', + provider_error: '模型服务返回错误', network_error: '网络错误', unavailable: '扫码接入暂时不可用,请稍后重试。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -140,6 +205,57 @@ const zhTwCopy = { unavailable: '該平台目前不可作為遠端串接管道', stopped: '監聽已停止', detailsInLogs: '執行狀態詳情請見記錄', polling: '長輪詢', gateway: '事件通道', webhook: 'Webhook', none: '無', }, + testHints: { + slack_socket_ready: '憑證有效,Socket Mode 連線可用。', + wechat_mp_callback_required: '憑證有效;訊息收發還需要公眾號伺服器設定和回呼驗證。', + telegram_send_start: '傳送 /start 給機器人後可在執行狀態接收訊息。', + wecom_runtime_authority: '已儲存憑證;企業微信 AI 機器人的連線狀態以執行狀態長連線為準。', + dingtalk_stream_required: '憑證有效;接收訊息需要 outgoing 機器人或 Stream 模式設定。', + qq_gateway_required: '憑證有效;接收訊息需要 QQ Gateway WebSocket 接入。', + wechat_bridge_local_only: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_bridge_start_required: '先在本機啟動 wechat-bridge,並確認 WeChat 已登入;傳送能力需要 wxp_act_ 啟用碼。', + wechat_ilink_login_required: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。', + wechat_ilink_polling: '掃碼登入憑證已儲存;執行狀態會透過 iLink 長輪詢接收訊息。', + wechat_bridge_remote_url: '微信掃碼登入只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_bridge_unreachable: '先啟動本機 wechat-bridge,並確認它暴露了 iLink 相容的 /api/weixin/qrcode 或 /qrcode 介面。', + }, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack 連線已中斷,正在等待重新連線', + disconnected: '連線已中斷', + reconnecting: '正在重新連線', + 'stream-failed': '訊息接收失敗,請檢查網路和執行記錄', + timeout: '請求逾時,請稍後重試', + rate_limited: '請求過於頻繁,請稍後重試', + auth_failed: '驗證失敗,請檢查憑證', + provider_error: '平台服務暫時無法使用,請稍後重試', + network_error: '網路錯誤,請檢查網路和代理設定', + '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,請檢查憑證和網路後重試。', + }, overview: { loadFailed: '遠端串接狀態載入失敗', reload: '重新載入', active: '正在使用', sortHint: '按需要處理、最近活動排序', empty: '還沒有正在使用的管道', emptyHelp: '從下方選擇一個訊息平台開始設定。', more: '串接更多管道', choose: '選擇平台開始設定', @@ -173,8 +289,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 +312,12 @@ const zhTwCopy = { generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟', done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…', connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', + savedNotConnected: '憑證已儲存,但連線未建立,可稍後在設定中重試。', + savedNotConnectedDetail: (detail: string) => `憑證已儲存,但連線未建立:${detail},可稍後在設定中重試。`, + errors: { + cancelled: '掃碼串接已取消。', timeout: '請求逾時', rate_limited: '已達模型速率限制', auth_failed: '驗證失敗', + provider_error: '模型服務傳回錯誤', network_error: '網路錯誤', unavailable: '掃碼串接暫時無法使用,請稍後重試。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -217,14 +346,80 @@ const enCopy: BotSettingsCopy = { }, planned: { label: 'Unavailable', detail: 'This platform is not saved as a remote-access channel or scheduled-task delivery target.', tone: 'neutral' }, status: { disabled: 'Turned off', noToken: 'Waiting for Bot Token', missingFeishuCredentials: 'Waiting for Feishu App ID or App Secret', feishuDomainRequired: 'Feishu credentials are valid; add the event subscription domain', feishuEventsNotConnected: 'Feishu credentials are valid; connect the event callback', unavailable: 'This platform cannot currently be used for remote access', stopped: 'Listener stopped', detailsInLogs: 'See logs for runtime details', polling: 'Long polling', gateway: 'Event channel', webhook: 'Webhook', none: 'None' }, + testHints: { + slack_socket_ready: 'Credentials are valid and the Socket Mode connection is available.', + wechat_mp_callback_required: 'Credentials are valid; messaging still needs the Official Account server configuration and callback verification.', + telegram_send_start: 'Send /start to the bot to receive messages at runtime.', + wecom_runtime_authority: 'Credentials saved; the WeCom AI bot connection state follows the live persistent connection.', + dingtalk_stream_required: 'Credentials are valid; receiving messages needs an outgoing bot or Stream mode configuration.', + qq_gateway_required: 'Credentials are valid; receiving messages needs the QQ Gateway WebSocket.', + wechat_bridge_local_only: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_start_required: 'Start the local wechat-bridge and make sure WeChat is signed in; sending needs a wxp_act_ activation code.', + wechat_ilink_login_required: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.', + wechat_ilink_polling: 'QR sign-in credentials saved; the runtime receives messages over iLink long polling.', + wechat_bridge_remote_url: 'WeChat QR sign-in only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_unreachable: 'Start the local wechat-bridge first and make sure it exposes an iLink-compatible /api/weixin/qrcode or /qrcode endpoint.', + }, + statusReasons: { + codes: { + 'slack-disconnected': 'Slack disconnected; waiting to reconnect', + disconnected: 'Connection lost', + reconnecting: 'Reconnecting', + 'stream-failed': 'Failed to receive messages. Check the network and runtime logs', + timeout: 'Request timed out. Try again later', + rate_limited: 'Too many requests. Try again later', + auth_failed: 'Authentication failed. Check the credentials', + provider_error: 'The platform is temporarily unavailable. Try again later', + network_error: 'Network error. Check the network and proxy settings', + '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.', + }, 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.', timeout: 'Request timed out', rate_limited: 'Rate limit exceeded', auth_failed: 'Authentication failed', + provider_error: 'Provider returned an error', network_error: 'Network error', unavailable: 'QR setup is temporarily unavailable. Try again later.', + }, }, wechat: { token: 'WeChat Bot Token', tokenPlaceholder: 'Local wechat-bridge Bearer Token', collapseAdvanced: 'Hide advanced settings', expandAdvanced: 'Advanced settings (Official Account / local bridge URL)', bridgeAddress: 'Local bridge URL', appId: 'Official Account App ID', appIdPlaceholder: 'WeChat Official Account App ID', appSecret: 'Official Account App Secret', appSecretPlaceholder: 'WeChat Official Account App Secret', advancedNotice: 'The local bridge defaults to http://127.0.0.1:18400. Official Account App ID and App Secret are used only for Official Account messaging; personal WeChat QR sign-in uses the local bridge.', readQrFailed: 'Could not read a QR code from the local wechat-bridge. Make sure the bridge is running.', title: 'WeChat QR sign-in', subtitle: 'Scan the QR code with WeChat and confirm signing in to the local wechat-bridge on your phone.', close: 'Close WeChat QR sign-in', generating: 'Generating QR code…', loggedIn: 'WeChat is signed in. Return to test the connection or restart the listener.', expired: 'QR code expired', expiredHint: 'Refresh the QR code and scan again to continue signing in.', refreshing: 'Refreshing…', refresh: 'Refresh QR code', qrAlt: 'WeChat sign-in QR code', waiting: 'Waiting for confirmation… Sign-in status refreshes every 3 seconds.', retrying: 'Retrying…', retry: 'Retry', bridgeGenerating: 'The bridge is generating a QR code', bridgeGeneratingHint: 'The QR code appears automatically once ready; you can also fetch it again.', fetching: 'Fetching…', fetchAgain: 'Fetch again' }, }; @@ -238,3 +433,39 @@ const BOT_SETTINGS_COPY = { export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy { return BOT_SETTINGS_COPY[locale]; } + +const BOT_STATUS_REASON_PATTERNS: ReadonlyArray<{ + pattern: RegExp; + key: keyof BotSettingsCopy['statusReasons']['withCode']; +}> = [ + { pattern: /^gateway-bot-(\d+)$/, key: 'gatewayBot' }, + { pattern: /^gateway-closed-(\d+)$/, key: 'gatewayClosed' }, + { pattern: /^connections-open-(\d+)$/, key: 'connectionsOpen' }, + { pattern: /^stream-closed-(\d+)$/, key: 'streamClosed' }, + { pattern: /^send-failed-(\d+)$/, key: 'sendFailed' }, + { pattern: /^getAppAccessToken-(\d+)$/, key: 'getAppAccessToken' }, +]; + +/** Localize a machine-readable bridge status reason such as `gateway-closed-4004`. */ +export function botStatusReasonMessage( + reason: string | undefined, + locale: UiLocale, +): string | undefined { + if (!reason) return undefined; + const settings = BOT_SETTINGS_COPY[locale]; + const copy = settings.statusReasons; + const fixed = lookupCopy({ + ...copy.codes, + ...settings.testErrors, + disabled: settings.status.disabled, + stopped: settings.status.stopped, + wechat_bridge_url_invalid: settings.testHints.wechat_bridge_local_only, + wechat_ilink_credentials_incomplete: settings.testHints.wechat_ilink_login_required, + } 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 settings.status.detailsInLogs; +} 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..e6199db7a3 100644 --- a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts @@ -17,7 +17,8 @@ * 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'; @@ -40,6 +41,7 @@ type SettingsTestResultCopy = { tokenInvalid: string; appCredentialsMissing: string; connectionFailed: string; + errors: Record, string>; }; }; @@ -67,6 +69,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': { @@ -92,6 +104,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。', + }, }, }, en: { @@ -124,6 +146,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 +194,17 @@ export function settingsTestResultMessage( return copy.bot.appCredentialsMissing; case "bot_connection_failed": return copy.bot.connectionFailed; + case 'slack_tokens_missing': + case 'wecom_credentials_missing': + case 'dingtalk_credentials_missing': + case 'dingtalk_no_access_token': + case 'qq_credentials_missing': + case 'qq_no_access_token': + case 'wechat_bridge_url_invalid': + case 'wechat_ilink_credentials_incomplete': + return copy.bot.errors[result.code]; default: - return locale === "en" && result.message.trim() - ? result.message - : copy.bot.connectionFailed; + return 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..f43115e958 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) ?? detailCopy.latestFailureDetail} )} /> )} @@ -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) ?? detailCopy.savedButNotConnected) + : 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..249c17de72 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx @@ -26,7 +26,7 @@ import { EmptyState, Item, StatusDot } from '@astryxdesign/core'; import { Button, RelativeTime, useUiLocale, Banner } from '@maka/ui'; import { deriveBotChannelViewState } from './bot-settings-view-model'; import { BOT_LABELS, BotBrandLogo, botReadinessCopyForSupport, botStatusDetail } from './bot-chat-shared'; -import { getBotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../locales/settings-bot-copy'; import { SettingsPage, SettingsSection } from './settings-section'; import { dotForStatus } from '@maka/ui'; @@ -157,7 +157,7 @@ function botOverviewDetail( ); } - if (currentError) return locale === 'zh-CN' ? currentError : fallback; + if (currentError) return botStatusReasonMessage(currentError, locale) ?? fallback; if (status?.reason) return botStatusDetail(status, locale); return fallback; } diff --git a/apps/desktop/src/renderer/settings/bot-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..a375fe84d2 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 { 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.connectedWarning) + : shared.savedNotConnected) : shared.connected(getBotSettingsCopy(locale).providers[snapshot.provider].label); case 'expired': return shared.expired; case 'denied': return shared.denied; case 'cancelled': return shared.cancelled; - case 'error': return locale === 'zh-CN' ? (snapshot.error ?? shared.failed) : shared.failed; + case 'error': return snapshot.errorCode && Object.hasOwn(shared.errors, snapshot.errorCode) ? shared.errors[snapshot.errorCode] : shared.failed; default: return shared.preparing; } } diff --git a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts index f75c27860a..f3aea677f6 100644 --- a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts +++ b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts @@ -17,7 +17,7 @@ * under the License. */ -import { humanizeBotStatusReason } from '@maka/core/bot-events'; +import { botStatusErrorReason } from '@maka/core/bot-events'; import { type BotChannelSettings, type BotReadinessState } from '@maka/core/bot-chat-settings'; import type { BotStatus } from '@maka/runtime/bots'; @@ -43,7 +43,7 @@ export function deriveBotChannelViewState(input: { || isConfiguredReadiness(readiness); const liveOperational = status?.running === true && readiness === 'operational'; const liveError = readiness === 'degraded' - ? humanizeBotStatusReason(status?.reason) + ? botStatusErrorReason(status?.reason) : undefined; const currentError = liveOperational ? undefined : liveError ?? channel.lastError; const needsAttention = configured && ( diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx index 796b6b20ac..fc372fb7ce 100644 --- a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx +++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx @@ -99,7 +99,8 @@ export function WechatQrLoginModal(props: { onRefreshStatuses(): void | Promise; }) { const locale = useUiLocale(); - const copy = getBotSettingsCopy(locale).wechat; + const botCopy = getBotSettingsCopy(locale); + const copy = botCopy.wechat; const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [reloadNonce, setReloadNonce] = useState(0); @@ -131,7 +132,6 @@ export function WechatQrLoginModal(props: { setResult({ ok: false, error: settingsActionErrorMessage(error, locale), - hint: copy.readQrFailed, }); }) .finally(() => { @@ -222,7 +222,7 @@ export function WechatQrLoginModal(props: { headingLevel={4} icon={} title={error.error} - description={error.hint} + description={error.hintCode ? botCopy.testHints[error.hintCode] : copy.readQrFailed} actions={