diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 4570aa0895..e4930fdc14 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -311,7 +311,7 @@
"nonTriviaTokens": 92
},
"src/renderer/app-shell-chat-actions.ts": {
- "importDeclarations": 9,
+ "importDeclarations": 8,
"bridgePaths": {
"window.maka.newTasks.create": 1,
"window.maka.sessions.remove": 1,
@@ -337,11 +337,11 @@
"@maka/core/session-name": 1,
"@maka/ui": 1
},
- "importSpecifiers": 16,
+ "importSpecifiers": 14,
"nonTriviaTokens": 4086
},
"src/renderer/app-shell-chrome-actions.tsx": {
- "importDeclarations": 5,
+ "importDeclarations": 4,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
@@ -357,11 +357,11 @@
"@maka/ui": 1,
"@maka/ui/icons": 1
},
- "importSpecifiers": 8,
+ "importSpecifiers": 7,
"nonTriviaTokens": 408
},
"src/renderer/app-shell-command-actions.ts": {
- "importDeclarations": 7,
+ "importDeclarations": 5,
"bridgePaths": {
"window.maka.connections.setDefault": 1,
"window.maka.connections.test": 1,
@@ -388,11 +388,11 @@
"./locales/shell-copy.js": 1,
"react": 1
},
- "importSpecifiers": 11,
+ "importSpecifiers": 9,
"nonTriviaTokens": 2307
},
"src/renderer/app-shell-context-compaction.ts": {
- "importDeclarations": 1,
+ "importDeclarations": 0,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {},
@@ -402,11 +402,11 @@
"dependencyPaths": {
"./locales/shell-copy.js": 1
},
- "importSpecifiers": 1,
+ "importSpecifiers": 0,
"nonTriviaTokens": 612
},
"src/renderer/app-shell-copy.ts": {
- "importDeclarations": 2,
+ "importDeclarations": 1,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {},
@@ -417,7 +417,7 @@
"./locales/shell-copy.js": 1,
"@maka/core/redaction": 1
},
- "importSpecifiers": 2,
+ "importSpecifiers": 1,
"nonTriviaTokens": 504
},
"src/renderer/app-shell-detail-panel.tsx": {
@@ -453,7 +453,7 @@
"nonTriviaTokens": 672
},
"src/renderer/app-shell-effects.ts": {
- "importDeclarations": 12,
+ "importDeclarations": 11,
"bridgePaths": {
"window.maka.app.info": 1,
"window.maka.appWindow.subscribeCommand": 1,
@@ -502,11 +502,11 @@
"@maka/core/session-event-health": 1,
"react": 1
},
- "importSpecifiers": 20,
+ "importSpecifiers": 19,
"nonTriviaTokens": 3816
},
"src/renderer/app-shell-overlays.tsx": {
- "importDeclarations": 8,
+ "importDeclarations": 7,
"bridgePaths": {},
"environmentCapabilities": {
"window": 2,
@@ -534,11 +534,11 @@
"@maka/ui": 1,
"react": 1
},
- "importSpecifiers": 12,
+ "importSpecifiers": 11,
"nonTriviaTokens": 977
},
"src/renderer/app-shell-project-actions.ts": {
- "importDeclarations": 5,
+ "importDeclarations": 4,
"bridgePaths": {
"window.maka.app.openPath": 4,
"window.maka.app.resolveProjectGitInfo": 1,
@@ -564,11 +564,11 @@
"./open-path": 1,
"./session-workspace-errors": 1
},
- "importSpecifiers": 9,
+ "importSpecifiers": 7,
"nonTriviaTokens": 2284
},
"src/renderer/app-shell-revision-actions.ts": {
- "importDeclarations": 6,
+ "importDeclarations": 4,
"bridgePaths": {
"window.maka.sessions.abandonSessionCopy": 2,
"window.maka.sessions.reviseBeforeTurn": 1
@@ -588,11 +588,11 @@
"./session-workspace-errors.js": 1,
"@maka/core/session": 1
},
- "importSpecifiers": 10,
+ "importSpecifiers": 8,
"nonTriviaTokens": 2316
},
"src/renderer/app-shell-session-events.ts": {
- "importDeclarations": 3,
+ "importDeclarations": 2,
"bridgePaths": {},
"environmentCapabilities": {
"requestAnimationFrame": 1,
@@ -610,11 +610,11 @@
"./model-connection-errors.js": 1,
"@maka/ui": 1
},
- "importSpecifiers": 12,
+ "importSpecifiers": 11,
"nonTriviaTokens": 2974
},
"src/renderer/app-shell-session-start-actions.ts": {
- "importDeclarations": 3,
+ "importDeclarations": 2,
"bridgePaths": {
"window.maka.newTasks.create": 1,
"window.maka.onboarding.setMilestone": 1
@@ -631,7 +631,7 @@
"./model-connection-errors.js": 1,
"./session-workspace-errors.js": 1
},
- "importSpecifiers": 7,
+ "importSpecifiers": 5,
"nonTriviaTokens": 650
},
"src/renderer/app-shell-session-ui-state.ts": {
@@ -649,7 +649,7 @@
"nonTriviaTokens": 7
},
"src/renderer/app-shell-stop-action.ts": {
- "importDeclarations": 2,
+ "importDeclarations": 0,
"bridgePaths": {
"window.maka.sessions.stop": 1
},
@@ -664,11 +664,11 @@
"./locales/conversation-copy.js": 1,
"./locales/shell-copy.js": 1
},
- "importSpecifiers": 2,
+ "importSpecifiers": 0,
"nonTriviaTokens": 302
},
"src/renderer/app-shell-turn-actions.ts": {
- "importDeclarations": 4,
+ "importDeclarations": 2,
"bridgePaths": {
"window.maka.sessions.branchFromTurn": 1,
"window.maka.sessions.regenerateTurn": 1
@@ -686,7 +686,7 @@
"./session-copy-attempt.js": 1,
"./session-workspace-errors.js": 1
},
- "importSpecifiers": 5,
+ "importSpecifiers": 3,
"nonTriviaTokens": 650
},
"src/renderer/app-shell-turn-view-model.ts": {
@@ -713,7 +713,7 @@
"nonTriviaTokens": 1408
},
"src/renderer/app-shell.tsx": {
- "importDeclarations": 92,
+ "importDeclarations": 81,
"bridgePaths": {
"window.maka.app.installUpdate": 1,
"window.maka.app.retryUpdateDownload": 1,
@@ -895,7 +895,7 @@
"@maka/ui/icons": 1,
"react": 1
},
- "importSpecifiers": 147,
+ "importSpecifiers": 124,
"nonTriviaTokens": 15588
},
"src/renderer/use-app-shell-composer-quotes.ts": {
@@ -916,7 +916,7 @@
"nonTriviaTokens": 360
},
"src/renderer/use-app-shell-session-list.ts": {
- "importDeclarations": 10,
+ "importDeclarations": 8,
"bridgePaths": {
"window.maka.sessions.list": 1
},
@@ -941,7 +941,7 @@
"@maka/ui": 1,
"react": 1
},
- "importSpecifiers": 13,
+ "importSpecifiers": 11,
"nonTriviaTokens": 581
},
"src/renderer/use-app-shell-session-ui-reads.ts": {
@@ -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/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs
index 606952781f..f83be21cb5 100644
--- a/apps/desktop/scripts/check-renderer-architecture.mjs
+++ b/apps/desktop/scripts/check-renderer-architecture.mjs
@@ -1249,6 +1249,8 @@ export function analyzeRendererSource(source, file = 'fixture.ts') {
const moduleReexports = [];
let importDeclarations = 0;
let importSpecifiers = 0;
+ const importDeclarationsBySource = {};
+ const importSpecifiersBySource = {};
let unresolvedDependencies = 0;
function recordBridgePath(path) {
@@ -1261,9 +1263,14 @@ export function analyzeRendererSource(source, file = 'fixture.ts') {
function visit(node, parent) {
if (node.type === 'ImportDeclaration' && !typeOnlySourceDependency(node)) {
+ const specifierCount = node.specifiers.filter((specifier) => specifier.importKind !== 'type').length;
importDeclarations += 1;
- importSpecifiers += node.specifiers.filter((specifier) => specifier.importKind !== 'type').length;
+ importSpecifiers += specifierCount;
const source = staticString(node.source);
+ if (source !== undefined) {
+ importDeclarationsBySource[source] = (importDeclarationsBySource[source] ?? 0) + 1;
+ importSpecifiersBySource[source] = (importSpecifiersBySource[source] ?? 0) + specifierCount;
+ }
if (source !== undefined && node.importKind !== 'type') {
for (const specifier of node.specifiers) {
if (specifier.importKind === 'type') continue;
@@ -1544,7 +1551,9 @@ export function analyzeRendererSource(source, file = 'fixture.ts') {
environmentCapabilities: sortedObject(environmentCapabilities),
hookCalls: sortedObject(hookCalls),
importDeclarations,
+ importDeclarationsBySource: sortedObject(importDeclarationsBySource),
importSpecifiers,
+ importSpecifiersBySource: sortedObject(importSpecifiersBySource),
lifecycleMethods: sortedObject(lifecycleMethods),
moduleImports: moduleImports.map((entry, index) => ({
...entry,
@@ -1651,11 +1660,20 @@ function capabilityDebtMetrics(analysis) {
};
}
-function debtMetrics(analysis) {
+// Imports from a sanctioned target (a validated copy catalog, or for AppShell
+// files a shell / public application / public feature module) are the edges
+// the migration wants a legacy file to take on; they cost no import debt, so
+// a legacy file is never pushed to inline a helper it could import.
+function debtMetrics(analysis, isSanctionedSource = () => false) {
+ const unsanctioned = (bySource) =>
+ Object.entries(bySource).reduce(
+ (total, [source, count]) => (isSanctionedSource(source) ? total : total + count),
+ 0,
+ );
return {
- importDeclarations: analysis.importDeclarations,
+ importDeclarations: unsanctioned(analysis.importDeclarationsBySource),
...capabilityDebtMetrics(analysis),
- importSpecifiers: analysis.importSpecifiers,
+ importSpecifiers: unsanctioned(analysis.importSpecifiersBySource),
nonTriviaTokens: analysis.nonTriviaTokens,
};
}
@@ -2247,15 +2265,15 @@ function validateMetric(path, metric, actual, expected, violations) {
}
}
-function validateDebtFile(desktopRoot, path, expected, violations, metrics = ROOT_DEBT_METRICS) {
- const absolutePath = resolve(desktopRoot, path);
- if (!existsSync(absolutePath)) {
+function validateDebtFile(desktopRoot, path, expected, violations, section) {
+ if (!existsSync(resolve(desktopRoot, path))) {
violations.push(`${path}: debt ledger entry points to a missing file`);
return;
}
- const analysis = analyzeRendererSource(readFileSync(absolutePath, 'utf8'), path);
- for (const metric of metrics) {
- validateMetric(path, metric, analysis[metric], expected[metric], violations);
+ const rootSection = section === 'legacyAppShell' || section === 'rootDebt';
+ const actual = rootSection ? debtForPath(desktopRoot, path, section) : capabilityDebtForPath(desktopRoot, path);
+ for (const metric of rootSection ? ROOT_DEBT_METRICS : CAPABILITY_DEBT_METRICS) {
+ validateMetric(path, metric, actual[metric], expected[metric], violations);
}
}
@@ -2282,7 +2300,7 @@ function validateLegacyLedger(desktopRoot, config, violations) {
}
for (const [path, expected] of Object.entries(config.legacyAppShell.files)) {
- validateDebtFile(desktopRoot, path, expected, violations);
+ validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShell');
}
const actualClosure = collectRootDependencyClosure(desktopRoot, expectedFiles, violations, 'AppShell');
const expectedClosure = Object.keys(config.legacyAppShell.closure).sort();
@@ -2299,10 +2317,10 @@ function validateLegacyLedger(desktopRoot, config, violations) {
if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) {
violations.push(`${path}: AppShell closure debt must point to a non-owner Desktop source`);
}
- validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS);
+ validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShellClosure');
}
for (const [path, expected] of Object.entries(config.rootDebt)) {
- validateDebtFile(desktopRoot, path, expected, violations);
+ validateDebtFile(desktopRoot, path, expected, violations, 'rootDebt');
}
const appShellDebtPaths = new Set([...expectedFiles, ...expectedClosure]);
const rootDebtPaths = Object.keys(config.rootDebt).sort();
@@ -2322,7 +2340,7 @@ function validateLegacyLedger(desktopRoot, config, violations) {
if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) {
violations.push(`${path}: renderer root closure debt must point to a non-owner Desktop source`);
}
- validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS);
+ validateDebtFile(desktopRoot, path, expected, violations, 'rootDebtClosure');
}
const ownedPaths = new Map();
@@ -2981,7 +2999,7 @@ function validateCopyCatalog(desktopRoot, relativePath) {
if (metricTotal(value) > 0) return `catalog carries ${metric} (${describeMetric(value)})`;
}
const forbidden = inspection.runtimeDependencies.find(
- (dependency) => dependency.startsWith('.') || dependency.startsWith(DESKTOP_SELF_PREFIX),
+ (dependency) => !isBarePackageSpecifier(dependency),
);
if (forbidden !== undefined) {
return `runtime import ${forbidden} is not a bare package specifier`;
@@ -3008,9 +3026,17 @@ function validateCopyCatalogFiles(desktopRoot, violations) {
}
}
+function isBarePackageSpecifier(dependency) {
+ return !dependency.startsWith('.') && !dependency.startsWith(DESKTOP_SELF_PREFIX);
+}
+
function withoutSanctionedDependencies(desktopRoot, section, importerPath, dependencyPaths) {
+ // A validated catalog is already restricted to bare package runtime imports;
+ // pricing them again would push copy helpers back inline into the catalog.
+ const importerIsCatalog = isValidatedCopyCatalog(desktopRoot, importerPath);
const filtered = {};
for (const [dependency, count] of Object.entries(dependencyPaths)) {
+ if (importerIsCatalog && isBarePackageSpecifier(dependency)) continue;
if (isSanctionedDependencyTarget(desktopRoot, section, importerPath, dependency)) continue;
filtered[dependency] = count;
}
@@ -3043,8 +3069,11 @@ function allowsMigrationDependency({ base, current, dependency, desktopRoot, pat
return swapZones.includes(zoneFor(normalizePath(relative(desktopRoot, target))).kind);
}
-function debtForPath(desktopRoot, path) {
- return debtMetrics(analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path));
+function debtForPath(desktopRoot, path, section) {
+ return debtMetrics(
+ analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path),
+ (source) => isSanctionedDependencyTarget(desktopRoot, section, path, source),
+ );
}
function capabilityDebtForPath(desktopRoot, path) {
@@ -3091,7 +3120,7 @@ export function generateArchitectureConfig(desktopRoot, config) {
const imports = collectLegacyImportEdges(desktopRoot);
const rootDebt = {};
for (const path of Object.keys(config.rootDebt ?? {}).sort()) {
- if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path);
+ if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path, 'rootDebt');
}
const appShellDebtPaths = new Set([...appShellFiles, ...closureFiles]);
const rootDebtClosureFiles = collectRootDependencyClosure(
@@ -3111,7 +3140,7 @@ export function generateArchitectureConfig(desktopRoot, config) {
legacyPlatformImports: imports.platform,
controllerOwners: controllerOwnersOf(config),
legacyAppShell: {
- files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path)])),
+ files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path, 'legacyAppShell')])),
closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])),
},
rootDebt,
diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs
index 46d6e81b7c..db2c11d8cf 100644
--- a/apps/desktop/scripts/check-renderer-architecture.test.mjs
+++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs
@@ -2229,9 +2229,15 @@ describe('renderer architecture checker fixtures', () => {
return ;
}
`;
- const currentDebt = debtForSource(appShellSource, appShellPath);
+ const currentDebt = {
+ ...debtForSource(appShellSource, appShellPath),
+ importDeclarations: 0,
+ importSpecifiers: 0,
+ };
const baseDebt = {
...currentDebt,
+ importDeclarations: 2,
+ importSpecifiers: 2,
dependencyPaths: {
'./legacy-alpha-owner.js': 1,
'./legacy-session-owner.js': 1,
@@ -2271,6 +2277,37 @@ describe('renderer architecture checker fixtures', () => {
);
});
+ for (const [targetPath, pricing] of [
+ ['src/renderer/application/contracts/fixture-diagnostics.ts', 'free'],
+ ['src/renderer/application/sessions/fixture-service.ts', 'priced'],
+ ]) {
+ it(`${pricing === 'free' ? 'exempts' : 'prices'} AppShell import specifiers from ${targetPath}`, async () => {
+ const specifier = `./${targetPath.slice('src/renderer/'.length).replace(/\.ts$/u, '.js')}`;
+ await withDesktopFixture(
+ {
+ [TRANSITIVE_APP_SHELL_PATH]: `
+ import { reportFixture } from '${specifier}';
+ export const AppShell = reportFixture('shell');
+ `,
+ [targetPath]: `export function reportFixture(scope: string): string { return scope; }`,
+ },
+ (desktopRoot) => {
+ const currentConfig = generateArchitectureConfig(desktopRoot, transitiveAppShellSeedConfig());
+ const baseConfig = structuredClone(currentConfig);
+ Object.assign(baseConfig.legacyAppShell.files[TRANSITIVE_APP_SHELL_PATH], {
+ importDeclarations: 0,
+ importSpecifiers: 0,
+ dependencyPaths: {},
+ });
+ const priced = violationsFor(desktopRoot, currentConfig, baseConfig).some((violation) =>
+ violation.startsWith(`${TRANSITIVE_APP_SHELL_PATH}: importSpecifiers debt increased`),
+ );
+ assert.equal(priced, pricing === 'priced');
+ },
+ );
+ });
+ }
+
it('rejects replacing legacy AppShell debt with a feature private import', async () => {
const appShellPath = 'src/renderer/app-shell.tsx';
const appShellSource = `
@@ -2831,6 +2868,29 @@ describe('validated copy catalog dependencies', () => {
);
});
+ it('does not price a catalog\'s own bare package runtime import', async () => {
+ await withDesktopFixture(
+ transitiveAppShellFiles(
+ `
+ import { FIXTURE_COPY } from './locales/fixture-copy.js';
+ export const legacySessionHelper = FIXTURE_COPY.en.notice;
+ `,
+ {
+ [CATALOG_PATH]: catalogSource(`
+ import { lookupCopy } from '@maka/core/ui-locale';
+ export const noticeFor = (code: string) => lookupCopy(FIXTURE_COPY.en, code);
+ `),
+ },
+ ),
+ (desktopRoot) => {
+ const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig());
+ const baseConfig = structuredClone(currentConfig);
+ baseConfig.legacyAppShell.closure[CATALOG_PATH].dependencyPaths = {};
+ assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []);
+ },
+ );
+ });
+
const INVALID_CATALOGS = [
['a hook call', catalogSource(`
import { useState } from 'react';
diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
index 83d5744dc2..97c3035534 100644
--- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
+++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
@@ -351,8 +351,8 @@ describe('BotOnboardingService', () => {
test.advance(5_000);
const connected = await test.service.poll(started.sessionId);
assert.equal(connected.state, 'connected');
- assert.match(connected.warning ?? '', /凭据已保存,但连接未建立/);
- assert.match(connected.warning ?? '', /鉴权失败/);
+ assert.equal(connected.warningCode, 'saved_not_connected');
+ assert.match(connected.warningDetail ?? '', /鉴权失败/);
assert.equal(JSON.stringify(connected).includes('private-client-secret'), false);
});
@@ -376,7 +376,7 @@ describe('BotOnboardingService', () => {
test.advance(5_000);
const connected = await test.service.poll(started.sessionId);
assert.equal(connected.state, 'connected');
- assert.equal(connected.warning, undefined);
+ assert.equal(connected.warningCode, undefined);
});
it('invalidates an older session when the same provider starts again', async () => {
diff --git a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
index f905cba983..390c5d8001 100644
--- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
+++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
@@ -101,7 +101,7 @@ describe("settings IPC helpers", () => {
const result = toSettingsTestResult("telegram", {
ok: true,
identity: { id: "42", username: "maka_bot", displayName: "Maka" },
- hint: "ready",
+ hintCode: "telegram_send_start",
});
assert.equal(result.ok, true);
@@ -115,7 +115,7 @@ describe("settings IPC helpers", () => {
username: "maka_bot",
displayName: "Maka",
});
- assert.equal(result.details?.hint, "ready");
+ assert.equal(result.details?.hintCode, "telegram_send_start");
});
test("redacts and generalizes bot test errors before returning SettingsTestResult", () => {
diff --git a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts
index 9f86548727..8dc5c859f5 100644
--- a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts
+++ b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts
@@ -20,6 +20,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { settingsTestResultMessage } from "../../renderer/locales/settings-test-result-copy.js";
+import { toSettingsTestResult } from "../settings-ipc-helpers.js";
test("missing proxy credentials have actionable bilingual copy", () => {
const result = {
@@ -37,3 +38,17 @@ test("missing proxy credentials have actionable bilingual copy", () => {
"Proxy authentication is enabled. Enter a proxy password before testing.",
);
});
+
+
+test("renders a bot-test error code per locale without content sniffing", () => {
+ const result = toSettingsTestResult("dingtalk", {
+ ok: false,
+ errorCode: "dingtalk_credentials_missing",
+ });
+ assert.equal(result.code, "bot_app_credentials_missing");
+ assert.equal(settingsTestResultMessage(result, "zh-CN"), "请填写 App ID 和 App Secret 后再测试。");
+ assert.equal(
+ settingsTestResultMessage(result, "en"),
+ "Enter an App ID and App Secret before testing the connection.",
+ );
+});
diff --git a/apps/desktop/src/main/bot-incoming-main.ts b/apps/desktop/src/main/bot-incoming-main.ts
index 545518a885..2b1a521f73 100644
--- a/apps/desktop/src/main/bot-incoming-main.ts
+++ b/apps/desktop/src/main/bot-incoming-main.ts
@@ -183,6 +183,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps):
}
}
+ // bot-channel notices follow the bot audience language; localization tracked under #2672
async function sendTransientBotNotice(message: BotIncomingMessage, text: string, ttlMs: number): Promise {
if (closed) return;
await deps.botRegistry.sendMessage(
@@ -424,6 +425,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps):
return { handleBotIncomingMessage, invalidateSessionBindings, close };
}
+// bot-channel notices follow the bot audience language; localization tracked under #2672
function botReply(result: BotSessionTurnResult): string {
if (result.kind === 'suspended') {
return '这条请求需要在 Maka 桌面端审批后才能继续。';
diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts
index 6ed3ff7535..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..acc1cc6768 100644
--- a/apps/desktop/src/main/settings-bots-ipc-main.ts
+++ b/apps/desktop/src/main/settings-bots-ipc-main.ts
@@ -90,12 +90,12 @@ export function registerSettingsBotsIpc(
: 'configured') as BotReadinessState,
readinessReason: result.ok
? undefined
- : botTestErrorMessage(provider, result.error),
+ : botTestErrorMessage(provider, result),
readinessUpdatedAt: Date.now(),
lastTestAt: Date.now(),
lastError: result.ok
? undefined
- : botTestErrorMessage(provider, result.error),
+ : botTestErrorMessage(provider, result),
};
await deps.settingsStore.update({
botChat: { channels: { [provider]: channelPatch } },
diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts
index df8952d38b..a2b27d05da 100644
--- a/apps/desktop/src/main/settings-ipc-helpers.ts
+++ b/apps/desktop/src/main/settings-ipc-helpers.ts
@@ -33,7 +33,7 @@ import {
maskSensitive,
type TestProxyResult,
} from "@maka/core/settings/network-settings";
-import type { BotTestResult } from '@maka/runtime/bots';
+import type { BotTestErrorCode, BotTestResult } from '@maka/runtime/bots';
import { collectPersonalizationWarnings } from '@maka/runtime/system-prompt/personalization-prompt';
import { getTavilyCredentialSource } from "./web-search/credentials.js";
@@ -195,9 +195,7 @@ export function toSettingsTestResult(
provider: BotProvider,
result: BotTestResult,
): SettingsTestResult {
- const failure = result.ok
- ? undefined
- : botTestFailure(provider, result.error);
+ const failure = result.ok ? undefined : botTestFailure(provider, result);
return {
ok: result.ok,
code: result.ok ? "bot_credentials_valid" : failure?.code,
@@ -208,50 +206,54 @@ export function toSettingsTestResult(
details: {
...(result.identity ? { identity: result.identity } : {}),
...(result.capabilities ? { capabilities: result.capabilities } : {}),
- ...(result.hint ? { hint: result.hint } : {}),
+ ...(result.hintCode ? { hintCode: result.hintCode } : {}),
},
};
}
export function botTestErrorMessage(
provider: BotProvider,
- error: unknown,
+ result: Pick,
): string {
- return botTestFailure(provider, error).message;
+ return botTestFailure(provider, result).message;
}
+const MISSING_CREDENTIAL_CODES: ReadonlySet = new Set([
+ "slack_tokens_missing",
+ "feishu_credentials_missing",
+ "wecom_credentials_missing",
+ "dingtalk_credentials_missing",
+ "qq_credentials_missing",
+ "wechat_ilink_credentials_incomplete",
+]);
+
function botTestFailure(
provider: BotProvider,
- error: unknown,
+ result: Pick,
): { code: SettingsTestResultCode; message: string } {
const label = botDisplayLabel(provider);
- const raw = redactSecrets(
- error instanceof Error ? error.message : String(error ?? ""),
- ).trim();
- const lower = raw.toLowerCase();
-
- if (lower.includes("bot token is required")) {
+ if (result.errorCode === "token_missing") {
return {
code: "bot_token_missing",
message: `${label} requires a Bot Token.`,
};
}
- if (lower.includes("invalid bot token")) {
+ if (result.errorCode === "token_invalid") {
return {
code: "bot_token_invalid",
message: `${label} rejected the Bot Token.`,
};
}
- if (
- provider === "feishu" &&
- /appid|app_id|appsecret|app_secret|required/.test(lower)
- ) {
+ if (result.errorCode && MISSING_CREDENTIAL_CODES.has(result.errorCode)) {
return {
code: "bot_app_credentials_missing",
- message: "Feishu requires an App ID and App Secret.",
+ message: `${label} requires an App ID and App Secret.`,
};
}
+ const raw = redactSecrets(
+ typeof result.error === "string" ? result.error : "",
+ ).trim();
const classified = generalizedErrorMessage(raw, "");
return {
code: "bot_connection_failed",
diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts
index b34d2fbca1..0e68f6b4f0 100644
--- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts
@@ -20,7 +20,7 @@
import type { StatusSemantic } from '@maka/ui';
import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings';
-import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';
+import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale';
type WidenCopy = T extends string
? string
@@ -54,6 +54,36 @@ const zhCopy = {
unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志',
polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无',
},
+ testHints: {
+ slack_socket_ready: '凭据有效,Socket Mode 连接可用。',
+ wechat_mp_callback_required: '凭据有效;消息收发还需要公众号服务器配置和回调验证。',
+ telegram_send_start: '发送 /start 给机器人后可在运行态接收消息。',
+ wecom_runtime_authority: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。',
+ dingtalk_stream_required: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。',
+ qq_gateway_required: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。',
+ wechat_bridge_local_only: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。',
+ wechat_bridge_start_required: '先在本机启动 wechat-bridge,并确认 WeChat 已登录;发送能力需要 wxp_act_ 激活码。',
+ wechat_ilink_login_required: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。',
+ wechat_ilink_polling: '扫码登录凭据已保存;运行态会通过 iLink 长轮询接收消息。',
+ wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。',
+ wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。',
+ },
+ statusReasons: {
+ codes: {
+ 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次',
+ 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效',
+ 'send-failed': '上一次发送失败,详细原因 Telegram 没有返回',
+ 'get-me-failed': '凭据探测失败;请检查 Bot Token',
+ },
+ withCode: {
+ gatewayBot: (code: string) => `获取 Gateway 失败(HTTP ${code})`,
+ gatewayClosed: (code: string) => `Gateway 连接关闭(${code});正在重连`,
+ connectionsOpen: (code: string) => `Stream 订阅打开失败(HTTP ${code})`,
+ streamClosed: (code: string) => `Stream 连接关闭(${code});正在重连`,
+ sendFailed: (code: string) => `发送失败(HTTP ${code})`,
+ getAppAccessToken: (code: string) => `获取 access_token 失败(HTTP ${code})`,
+ },
+ },
overview: {
loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序',
empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置',
@@ -88,7 +118,7 @@ const zhCopy = {
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} 项`,
+ limitReached: '(已达到上限)', invalidUsers: (values: string) => `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 项`, idSeparator: '、',
},
onboarding: {
providers: {
@@ -103,6 +133,12 @@ const zhCopy = {
generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开',
done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…',
connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…',
+ savedNotConnected: '凭据已保存,但连接未建立,可稍后在设置中重试。',
+ savedNotConnectedDetail: (detail: string) => `凭据已保存,但连接未建立:${detail},可稍后在设置中重试。`,
+ errors: {
+ cancelled: '扫码接入已取消。', timeout: '请求超时', rate_limited: '触发模型速率限制', auth_failed: '鉴权失败',
+ provider_error: '模型服务返回错误', network_error: '网络错误', unavailable: '扫码接入暂时不可用,请稍后重试。',
+ },
},
wechat: {
token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)',
@@ -140,6 +176,36 @@ const zhTwCopy = {
unavailable: '該平台目前不可作為遠端串接管道', stopped: '監聽已停止', detailsInLogs: '執行狀態詳情請見記錄',
polling: '長輪詢', gateway: '事件通道', webhook: 'Webhook', none: '無',
},
+ testHints: {
+ slack_socket_ready: '憑證有效,Socket Mode 連線可用。',
+ wechat_mp_callback_required: '憑證有效;訊息收發還需要公眾號伺服器設定和回呼驗證。',
+ telegram_send_start: '傳送 /start 給機器人後可在執行狀態接收訊息。',
+ wecom_runtime_authority: '已儲存憑證;企業微信 AI 機器人的連線狀態以執行狀態長連線為準。',
+ dingtalk_stream_required: '憑證有效;接收訊息需要 outgoing 機器人或 Stream 模式設定。',
+ qq_gateway_required: '憑證有效;接收訊息需要 QQ Gateway WebSocket 接入。',
+ wechat_bridge_local_only: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。',
+ wechat_bridge_start_required: '先在本機啟動 wechat-bridge,並確認 WeChat 已登入;傳送能力需要 wxp_act_ 啟用碼。',
+ wechat_ilink_login_required: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。',
+ wechat_ilink_polling: '掃碼登入憑證已儲存;執行狀態會透過 iLink 長輪詢接收訊息。',
+ wechat_bridge_remote_url: '微信掃碼登入只允許存取本機 wechat-bridge,不能指向遠端 URL。',
+ wechat_bridge_unreachable: '先啟動本機 wechat-bridge,並確認它暴露了 iLink 相容的 /api/weixin/qrcode 或 /qrcode 介面。',
+ },
+ statusReasons: {
+ codes: {
+ 'rate-limited': '傳送被節流(429);上一則回覆可能截斷,可以請使用者再發一次',
+ 'polling-timeout': '事件輪詢逾時;可能是網路抖動或代理失效',
+ 'send-failed': '上一次傳送失敗,詳細原因 Telegram 沒有回傳',
+ 'get-me-failed': '憑證探測失敗;請檢查 Bot Token',
+ },
+ withCode: {
+ gatewayBot: (code: string) => `取得 Gateway 失敗(HTTP ${code})`,
+ gatewayClosed: (code: string) => `Gateway 連線關閉(${code});正在重連`,
+ connectionsOpen: (code: string) => `Stream 訂閱開啟失敗(HTTP ${code})`,
+ streamClosed: (code: string) => `Stream 連線關閉(${code});正在重連`,
+ sendFailed: (code: string) => `傳送失敗(HTTP ${code})`,
+ getAppAccessToken: (code: string) => `取得 access_token 失敗(HTTP ${code})`,
+ },
+ },
overview: {
loadFailed: '遠端串接狀態載入失敗', reload: '重新載入', active: '正在使用', sortHint: '按需要處理、最近活動排序',
empty: '還沒有正在使用的管道', emptyHelp: '從下方選擇一個訊息平台開始設定。', more: '串接更多管道', choose: '選擇平台開始設定',
@@ -174,7 +240,7 @@ const zhTwCopy = {
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} 項`,
+ limitReached: '(已達到上限)', invalidUsers: (values: string) => `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 項`, idSeparator: '、',
},
onboarding: {
providers: {
@@ -189,6 +255,12 @@ const zhTwCopy = {
generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟',
done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…',
connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…',
+ savedNotConnected: '憑證已儲存,但連線未建立,可稍後在設定中重試。',
+ savedNotConnectedDetail: (detail: string) => `憑證已儲存,但連線未建立:${detail},可稍後在設定中重試。`,
+ errors: {
+ cancelled: '掃碼串接已取消。', timeout: '請求逾時', rate_limited: '已達模型速率限制', auth_failed: '驗證失敗',
+ provider_error: '模型服務傳回錯誤', network_error: '網路錯誤', unavailable: '掃碼串接暫時無法使用,請稍後重試。',
+ },
},
wechat: {
token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)',
@@ -217,14 +289,50 @@ const enCopy: BotSettingsCopy = {
},
planned: { label: 'Unavailable', detail: 'This platform is not saved as a remote-access channel or scheduled-task delivery target.', tone: 'neutral' },
status: { disabled: 'Turned off', noToken: 'Waiting for Bot Token', missingFeishuCredentials: 'Waiting for Feishu App ID or App Secret', feishuDomainRequired: 'Feishu credentials are valid; add the event subscription domain', feishuEventsNotConnected: 'Feishu credentials are valid; connect the event callback', unavailable: 'This platform cannot currently be used for remote access', stopped: 'Listener stopped', detailsInLogs: 'See logs for runtime details', polling: 'Long polling', gateway: 'Event channel', webhook: 'Webhook', none: 'None' },
+ testHints: {
+ slack_socket_ready: 'Credentials are valid and the Socket Mode connection is available.',
+ wechat_mp_callback_required: 'Credentials are valid; messaging still needs the Official Account server configuration and callback verification.',
+ telegram_send_start: 'Send /start to the bot to receive messages at runtime.',
+ wecom_runtime_authority: 'Credentials saved; the WeCom AI bot connection state follows the live persistent connection.',
+ dingtalk_stream_required: 'Credentials are valid; receiving messages needs an outgoing bot or Stream mode configuration.',
+ qq_gateway_required: 'Credentials are valid; receiving messages needs the QQ Gateway WebSocket.',
+ wechat_bridge_local_only: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.',
+ wechat_bridge_start_required: 'Start the local wechat-bridge and make sure WeChat is signed in; sending needs a wxp_act_ activation code.',
+ wechat_ilink_login_required: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.',
+ wechat_ilink_polling: 'QR sign-in credentials saved; the runtime receives messages over iLink long polling.',
+ wechat_bridge_remote_url: 'WeChat QR sign-in only accepts the local wechat-bridge, not a remote URL.',
+ wechat_bridge_unreachable: 'Start the local wechat-bridge first and make sure it exposes an iLink-compatible /api/weixin/qrcode or /qrcode endpoint.',
+ },
+ statusReasons: {
+ codes: {
+ 'rate-limited': 'Sending was throttled (429); the last reply may be truncated, so ask the user to resend',
+ 'polling-timeout': 'Event polling timed out; the network or proxy may be unstable',
+ 'send-failed': 'The last send failed; Telegram returned no details',
+ 'get-me-failed': 'Credential probe failed; check the Bot Token',
+ },
+ withCode: {
+ gatewayBot: (code) => `Failed to fetch the Gateway (HTTP ${code})`,
+ gatewayClosed: (code) => `Gateway connection closed (${code}); reconnecting`,
+ connectionsOpen: (code) => `Failed to open the Stream subscription (HTTP ${code})`,
+ streamClosed: (code) => `Stream connection closed (${code}); reconnecting`,
+ sendFailed: (code) => `Send failed (HTTP ${code})`,
+ getAppAccessToken: (code) => `Failed to fetch access_token (HTTP ${code})`,
+ },
+ },
overview: { loadFailed: 'Failed to load remote-access status', reload: 'Reload', active: 'In use', sortHint: 'Sorted by attention needed and recent activity', empty: 'No channels are in use', emptyHelp: 'Choose a messaging platform below to begin setup.', more: 'Connect more channels', choose: 'Choose a platform to begin setup', listening: 'Listening', manageAria: (name, status) => `Manage ${name}, ${status}`, connectAria: (name) => `Connect ${name}` },
page: { saveFailed: (name) => `Failed to save ${name}`, loadFailed: 'Failed to load remote-access status', refreshFailed: 'Failed to refresh remote-access status', credentialVerified: (name) => `${name} credentials verified`, credentialVerifiedDetail: 'The credential check passed.', credentialTestFailed: (name) => `${name} credential test failed`, credentialTestFailedDetail: 'Check the credentials and network settings, then try again.', testError: (name) => `${name} test error`, listening: (name) => `${name} is listening`, notListening: (name) => `${name} did not start listening`, startFailed: (name) => `Failed to start ${name}`, disconnectTitle: 'Disconnect WeChat?', disconnectDescription: 'This clears the saved local QR sign-in credentials. You will need to scan again to keep using WeChat.', disconnect: 'Disconnect', cancel: 'Cancel', disconnected: 'WeChat disconnected', credentialsCleared: 'Local linked-session credentials cleared.' },
detail: {
- 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: '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`, idSeparator: ', ',
},
onboarding: {
providers: { dingtalk: { title: 'Set up DingTalk', ariaLabel: 'Set up DingTalk with a QR code', qrAlt: 'DingTalk setup QR code', subtitle: 'Scan in DingTalk to register the app', waiting: 'Scan with DingTalk and confirm authorization', scanned: 'Scanned. Complete confirmation in DingTalk.' }, feishu: { title: 'Set up Feishu', ariaLabel: 'Set up Feishu with a QR code', qrAlt: 'Feishu setup QR code', subtitle: 'Scan with Feishu to create and configure the bot', waiting: 'Scan with Feishu and confirm creation', scanned: 'Scanned. Complete confirmation in Feishu.' }, wecom: { title: 'Set up WeCom', ariaLabel: 'Set up WeCom with a QR code', qrAlt: 'WeCom setup QR code', subtitle: 'Quick setup creates and connects a WeCom bot', waiting: 'Open WeCom and scan to create the bot', scanned: 'Scanned. Complete confirmation in WeCom.' }, wechat: { title: 'Scan to sign in', ariaLabel: 'WeChat QR sign-in', qrAlt: 'WeChat sign-in QR code', subtitle: 'Scan with WeChat to connect', waiting: 'Scan with WeChat and confirm on your phone', scanned: 'Scanned. Complete confirmation in WeChat.' }, qq: { title: 'Set up QQ', ariaLabel: 'Set up QQ with a QR code', qrAlt: 'QQ setup QR code', subtitle: 'Scan with mobile QQ to create and bind a bot', waiting: 'Scan with mobile QQ and confirm binding', scanned: 'Scanned. Complete confirmation in QQ.' } },
lark: { title: 'Set up Lark', ariaLabel: 'Set up Lark with a QR code', qrAlt: 'Lark setup QR code', subtitle: 'Scan with Lark to create and configure the bot', waiting: 'Scan with Lark and confirm creation', scanned: 'Scanned. Complete confirmation in Lark.' }, connectedRefreshFailed: (message) => `Connected, but status refresh failed: ${message}`, close: (title) => `Close ${title}`, generatingAria: 'Generating QR code', privacy: 'Credentials stay on this device and are never sent to the renderer or Maka cloud.', openBrowser: 'Cannot scan? Open in browser', done: 'Done', regenerate: 'Generate again', refreshQr: 'Refresh QR code', cancel: 'Cancel', generating: 'Generating a secure QR code…', connecting: 'Authorization complete. Saving credentials and starting connection…', connected: (name) => `${name} connected`, connectedWarning: 'Credentials were saved, but the connection did not start.', expired: 'QR code expired. Generate a new one.', denied: 'Authorization cancelled. Generate a new QR code.', cancelled: 'QR setup cancelled', failed: 'QR setup failed. Try again.', preparing: 'Preparing QR setup…',
+ savedNotConnected: 'Credentials were saved, but the connection did not start. Retry from settings later.',
+ savedNotConnectedDetail: (detail) => `Credentials were saved, but the connection did not start: ${detail}. Retry from settings later.`,
+ errors: {
+ cancelled: 'QR setup was cancelled.', timeout: 'Request timed out', rate_limited: 'Rate limit exceeded', auth_failed: 'Authentication failed',
+ provider_error: 'Provider returned an error', network_error: 'Network error', unavailable: 'QR setup is temporarily unavailable. Try again later.',
+ },
},
wechat: { token: 'WeChat Bot Token', tokenPlaceholder: 'Local wechat-bridge Bearer Token', collapseAdvanced: 'Hide advanced settings', expandAdvanced: 'Advanced settings (Official Account / local bridge URL)', bridgeAddress: 'Local bridge URL', appId: 'Official Account App ID', appIdPlaceholder: 'WeChat Official Account App ID', appSecret: 'Official Account App Secret', appSecretPlaceholder: 'WeChat Official Account App Secret', advancedNotice: 'The local bridge defaults to http://127.0.0.1:18400. Official Account App ID and App Secret are used only for Official Account messaging; personal WeChat QR sign-in uses the local bridge.', readQrFailed: 'Could not read a QR code from the local wechat-bridge. Make sure the bridge is running.', title: 'WeChat QR sign-in', subtitle: 'Scan the QR code with WeChat and confirm signing in to the local wechat-bridge on your phone.', close: 'Close WeChat QR sign-in', generating: 'Generating QR code…', loggedIn: 'WeChat is signed in. Return to test the connection or restart the listener.', expired: 'QR code expired', expiredHint: 'Refresh the QR code and scan again to continue signing in.', refreshing: 'Refreshing…', refresh: 'Refresh QR code', qrAlt: 'WeChat sign-in QR code', waiting: 'Waiting for confirmation… Sign-in status refreshes every 3 seconds.', retrying: 'Retrying…', retry: 'Retry', bridgeGenerating: 'The bridge is generating a QR code', bridgeGeneratingHint: 'The QR code appears automatically once ready; you can also fetch it again.', fetching: 'Fetching…', fetchAgain: 'Fetch again' },
};
@@ -238,3 +346,33 @@ const BOT_SETTINGS_COPY = {
export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy {
return BOT_SETTINGS_COPY[locale];
}
+
+const BOT_STATUS_REASON_PATTERNS: ReadonlyArray<{
+ pattern: RegExp;
+ key: keyof BotSettingsCopy['statusReasons']['withCode'];
+}> = [
+ { pattern: /^gateway-bot-(\d+)$/, key: 'gatewayBot' },
+ { pattern: /^gateway-closed-(\d+)$/, key: 'gatewayClosed' },
+ { pattern: /^connections-open-(\d+)$/, key: 'connectionsOpen' },
+ { pattern: /^stream-closed-(\d+)$/, key: 'streamClosed' },
+ { pattern: /^send-failed-(\d+)$/, key: 'sendFailed' },
+ { pattern: /^getAppAccessToken-(\d+)$/, key: 'getAppAccessToken' },
+];
+
+/** Localize a machine-readable bridge status reason such as `gateway-closed-4004`. */
+export function botStatusReasonMessage(
+ reason: string | undefined,
+ locale: UiLocale,
+): string | undefined {
+ if (!reason) return undefined;
+ const copy = BOT_SETTINGS_COPY[locale].statusReasons;
+ const fixed = lookupCopy(copy.codes, reason);
+ if (fixed) return fixed;
+ for (const { pattern, key } of BOT_STATUS_REASON_PATTERNS) {
+ const match = pattern.exec(reason);
+ if (match) return copy.withCode[key](match[1]);
+ }
+ // Platform-supplied description ("Bad Request: chat not found", …): external
+ // error text passes through verbatim rather than being classified by content.
+ return reason;
+}
diff --git a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx
index ae9ee1aeaf..b78c67432a 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 = getBotSettingsCopy(locale).onboarding;
toast.warning(
detailCopy.credentialsSaved(providerPresentation.label),
- locale === 'zh-CN' ? snapshot.warning : detailCopy.savedButNotConnected,
+ snapshot.warningDetail
+ ? onboardingCopy.savedNotConnectedDetail(snapshot.warningDetail)
+ : onboardingCopy.savedNotConnected,
);
return;
}
@@ -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.slice(0, 3).join(copy.idSeparator))}${invalidEntries.length > 3 ? copy.moreInvalid(invalidEntries.length) : ''}`
: undefined;
return (
diff --git a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx
index b2e248a006..249c17de72 100644
--- a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx
+++ b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx
@@ -26,7 +26,7 @@ import { EmptyState, Item, StatusDot } from '@astryxdesign/core';
import { Button, RelativeTime, useUiLocale, Banner } from '@maka/ui';
import { deriveBotChannelViewState } from './bot-settings-view-model';
import { BOT_LABELS, BotBrandLogo, botReadinessCopyForSupport, botStatusDetail } from './bot-chat-shared';
-import { getBotSettingsCopy } from '../locales/settings-bot-copy';
+import { botStatusReasonMessage, getBotSettingsCopy } from '../locales/settings-bot-copy';
import { SettingsPage, SettingsSection } from './settings-section';
import { dotForStatus } from '@maka/ui';
@@ -157,7 +157,7 @@ function botOverviewDetail(
>
);
}
- if (currentError) return locale === 'zh-CN' ? currentError : fallback;
+ if (currentError) return botStatusReasonMessage(currentError, locale) ?? fallback;
if (status?.reason) return botStatusDetail(status, locale);
return fallback;
}
diff --git a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx
index 90496c3070..036e3e50d1 100644
--- a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx
+++ b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx
@@ -190,7 +190,7 @@ export function BotOnboardingModal(props: {
) : starting || snapshot?.state === 'connecting' ? (
) : snapshot?.state === 'connected' ? (
- snapshot.warning ? (
+ snapshot.warningCode ? (
@@ -262,13 +262,15 @@ function statusCopy(
case 'connecting': return shared.connecting;
// PR1197 review (P0-3): honour the honest "saved but not connected" notice
// instead of claiming a healthy connection.
- case 'connected': return snapshot.warning
- ? (locale === 'zh-CN' ? snapshot.warning : shared.connectedWarning)
+ case 'connected': return snapshot.warningCode
+ ? (snapshot.warningDetail
+ ? shared.savedNotConnectedDetail(snapshot.warningDetail)
+ : shared.savedNotConnected)
: shared.connected(getBotSettingsCopy(locale).providers[snapshot.provider].label);
case 'expired': return shared.expired;
case 'denied': return shared.denied;
case 'cancelled': return shared.cancelled;
- case 'error': return locale === 'zh-CN' ? (snapshot.error ?? shared.failed) : shared.failed;
+ case 'error': return snapshot.errorCode ? shared.errors[snapshot.errorCode] : shared.failed;
default: return shared.preparing;
}
}
diff --git a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts
index f75c27860a..f3aea677f6 100644
--- a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts
+++ b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { humanizeBotStatusReason } from '@maka/core/bot-events';
+import { botStatusErrorReason } from '@maka/core/bot-events';
import { type BotChannelSettings, type BotReadinessState } from '@maka/core/bot-chat-settings';
import type { BotStatus } from '@maka/runtime/bots';
@@ -43,7 +43,7 @@ export function deriveBotChannelViewState(input: {
|| isConfiguredReadiness(readiness);
const liveOperational = status?.running === true && readiness === 'operational';
const liveError = readiness === 'degraded'
- ? humanizeBotStatusReason(status?.reason)
+ ? botStatusErrorReason(status?.reason)
: undefined;
const currentError = liveOperational ? undefined : liveError ?? channel.lastError;
const needsAttention = configured && (
diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx
index 796b6b20ac..fc372fb7ce 100644
--- a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx
+++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx
@@ -99,7 +99,8 @@ export function WechatQrLoginModal(props: {
onRefreshStatuses(): void | Promise;
}) {
const locale = useUiLocale();
- const copy = getBotSettingsCopy(locale).wechat;
+ const botCopy = getBotSettingsCopy(locale);
+ const copy = botCopy.wechat;
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
const [reloadNonce, setReloadNonce] = useState(0);
@@ -131,7 +132,6 @@ export function WechatQrLoginModal(props: {
setResult({
ok: false,
error: settingsActionErrorMessage(error, locale),
- hint: copy.readQrFailed,
});
})
.finally(() => {
@@ -222,7 +222,7 @@ export function WechatQrLoginModal(props: {
headingLevel={4}
icon={}
title={error.error}
- description={error.hint}
+ description={error.hintCode ? botCopy.testHints[error.hintCode] : copy.readQrFailed}
actions={}
/>
diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts
index 57094a2c32..6689071875 100644
--- a/packages/core/src/__tests__/redaction.test.ts
+++ b/packages/core/src/__tests__/redaction.test.ts
@@ -22,9 +22,7 @@ import { formatWithOptions } from 'node:util';
import { describe, test } from 'node:test';
import {
generalizedErrorMessage,
- generalizedErrorMessageChinese,
generalizedErrorMessageForLocale,
- generalizedErrorMessageTraditionalChinese,
redactSecrets,
} from '../redaction.js';
@@ -298,6 +296,18 @@ describe('redactSecrets', () => {
});
});
+describe('generalizedErrorMessageForLocale', () => {
+ test('renders the same classification in each locale and the fallback when none matches', () => {
+ const timeout = new Error('request timeout after 30s');
+ assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'en'), 'Request timed out');
+ assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'zh-CN'), '请求超时');
+ assert.equal(
+ generalizedErrorMessageForLocale(new Error('unclassified'), '操作失败', 'zh-CN'),
+ '操作失败',
+ );
+ });
+});
+
describe('generalizedErrorMessage', () => {
test('classifies provider failures without exposing secret-bearing input', () => {
for (const [raw, expected] of [
@@ -335,7 +345,7 @@ describe('generalizedErrorMessage', () => {
});
});
-describe('generalizedErrorMessageChinese', () => {
+describe('generalizedErrorMessageForLocale zh-CN', () => {
test('maps provider failures to Chinese categories without leaking secrets', () => {
for (const [raw, expected] of [
['Request timeout after 30s', '请求超时'],
@@ -355,19 +365,23 @@ describe('generalizedErrorMessageChinese', () => {
['something weird happened', '操作失败'],
['401 Authorization: Bearer sk-live-secret-token-value', '鉴权失败'],
]) {
- const message = generalizedErrorMessageChinese(new Error(raw));
+ const message = generalizedErrorMessageForLocale(new Error(raw), '操作失败', 'zh-CN');
assert.equal(message, expected);
assert.match(message, /[一-鿿]/);
assert.doesNotMatch(message, /sk-live-secret-token-value/);
}
- assert.equal(generalizedErrorMessageChinese('non-Error string input'), '操作失败');
+ assert.equal(
+ generalizedErrorMessageForLocale('non-Error string input', '操作失败', 'zh-CN'),
+ '操作失败',
+ );
});
test('uses a caller-supplied Chinese fallback for unknown errors', () => {
assert.equal(
- generalizedErrorMessageChinese(
+ generalizedErrorMessageForLocale(
new Error('something weird happened'),
'会话已创建但发送失败,请重试。',
+ 'zh-CN',
),
'会话已创建但发送失败,请重试。',
);
@@ -375,9 +389,10 @@ describe('generalizedErrorMessageChinese', () => {
test('does not mistake runtime authority errors for authentication failures', () => {
assert.equal(
- generalizedErrorMessageChinese(
+ generalizedErrorMessageForLocale(
new Error('Conversation copy contains durable runtime authority facts'),
'无法基于该上下文创建新会话。',
+ 'zh-CN',
),
'无法基于该上下文创建新会话。',
);
@@ -404,7 +419,7 @@ describe('localized generalized error messages', () => {
['network unreachable', '網路錯誤'],
['something weird happened', '操作失敗'],
]) {
- assert.equal(generalizedErrorMessageTraditionalChinese(new Error(raw)), expected);
+ assert.equal(generalizedErrorMessageForLocale(new Error(raw), '操作失敗', 'zh-TW'), expected);
}
});
diff --git a/packages/core/src/bot-events.ts b/packages/core/src/bot-events.ts
index 08593a4edf..f35e3b139b 100644
--- a/packages/core/src/bot-events.ts
+++ b/packages/core/src/bot-events.ts
@@ -74,6 +74,7 @@ export interface BotMessageEvent {
* different copy without diluting the core message. Exported so the
* handler can use it AND a contract test can pin it.
*/
+// bot-channel notices follow the bot audience language; localization tracked under #2672
export function nonTextMessageAck(kind: BotAttachmentKind): string {
switch (kind) {
case 'photo':
@@ -213,73 +214,27 @@ function sanitizeBotUserName(value: string): string {
}
/**
- * PR-BOT-LASTERROR-FROM-SEND-0 (external bot research): translate the bridge's
- * machine-readable `BotStatus.reason` into a short user-readable string
- * suitable for persistence in `BotChannelSettings.lastError`. The Settings
- * page reads `lastError` from persisted settings (not live status), so
- * without this persistence step the user sees stale connection-test
- * errors instead of the actual send-path failure that happened minutes
- * ago.
- *
- * Returns `undefined` for non-error reasons (disabled/stopped/missing
- * credentials — those have their own UI surface) and for unrecognized
- * inputs whose pass-through risks leaking unredacted payloads.
- *
- * Length-capped at 200 chars defensively; a real Telegram error
- * description is typically well under 80 chars.
+ * Non-error `BotStatus.reason` values. These states have their own UI surface,
+ * so an error readout must not repeat them as failures.
*/
-const BOT_REASON_HUMANIZE: Record = {
- 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次',
- 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效',
- 'send-failed': '上一次发送失败,详细原因 Telegram 没有返回',
- 'get-me-failed': '凭据探测失败;请检查 Bot Token',
- // Non-error states surface elsewhere in the UI — return undefined so
- // we do not overwrite a real lastError with a benign status change.
- disabled: undefined,
- stopped: undefined,
- 'no-token': undefined,
- 'missing-feishu-credentials': undefined,
- 'feishu-domain-required': undefined,
- 'feishu-events-not-connected': undefined,
- 'scaffold-only': undefined,
- unimplemented: undefined,
-};
+const BOT_BENIGN_STATUS_REASONS: ReadonlySet = new Set([
+ 'disabled',
+ 'stopped',
+ 'no-token',
+ 'missing-feishu-credentials',
+ 'feishu-domain-required',
+ 'feishu-events-not-connected',
+ 'scaffold-only',
+ 'unimplemented',
+]);
/**
- * PR-BOT-RUNTIME-REASON-HUMANIZE-0: Discord / DingTalk / QQ bridges
- * emit parameterized reason strings like `gateway-closed-4004` and
- * `connections-open-500`. Without these patterns the user sees the
- * raw machine code in `lastError`; with them they get a translated
- * description plus the diagnostic code preserved in parentheses.
- *
- * Each entry is a regex with one numeric capture group; the matched
- * code is preserved verbatim so support diagnostics still survive.
+ * `BotStatus.reason` stays a stable code; presenters own the copy. Benign
+ * states yield `undefined` so they never overwrite a real error readout.
*/
-const BOT_REASON_HUMANIZE_PATTERNS: Array<{ pattern: RegExp; format: (code: string) => string }> = [
- { pattern: /^gateway-bot-(\d+)$/, format: (code) => `获取 Gateway 失败(HTTP ${code})` },
- { pattern: /^gateway-closed-(\d+)$/, format: (code) => `Gateway 连接关闭(${code});正在重连` },
- { pattern: /^connections-open-(\d+)$/, format: (code) => `Stream 订阅打开失败(HTTP ${code})` },
- { pattern: /^stream-closed-(\d+)$/, format: (code) => `Stream 连接关闭(${code});正在重连` },
- { pattern: /^send-failed-(\d+)$/, format: (code) => `发送失败(HTTP ${code})` },
- {
- pattern: /^getAppAccessToken-(\d+)$/,
- format: (code) => `获取 access_token 失败(HTTP ${code})`,
- },
-];
-
-export function humanizeBotStatusReason(reason: string | undefined): string | undefined {
- if (typeof reason !== 'string' || reason.length === 0) return undefined;
- if (reason in BOT_REASON_HUMANIZE) {
- return BOT_REASON_HUMANIZE[reason];
- }
- for (const { pattern, format } of BOT_REASON_HUMANIZE_PATTERNS) {
- const match = pattern.exec(reason);
- if (match) return format(match[1]);
- }
- // Pass-through for platform-supplied descriptions ("Bad Request:
- // chat not found", etc.). Trim + length-cap to keep `lastError`
- // bounded.
+export function botStatusErrorReason(reason: string | undefined): string | undefined {
+ if (typeof reason !== 'string') return undefined;
const trimmed = reason.trim();
- if (trimmed.length === 0) return undefined;
+ if (trimmed.length === 0 || BOT_BENIGN_STATUS_REASONS.has(trimmed)) return undefined;
return trimmed.length > 200 ? trimmed.slice(0, 200) : trimmed;
}
diff --git a/packages/core/src/bot-onboarding.ts b/packages/core/src/bot-onboarding.ts
index 9bfbcbdeb4..a6d8aa018d 100644
--- a/packages/core/src/bot-onboarding.ts
+++ b/packages/core/src/bot-onboarding.ts
@@ -67,16 +67,29 @@ export interface BotOnboardingSnapshot {
displayName?: string;
};
error?: string;
+ /** Stable machine code for a terminal `error` state; presenters own copy. */
+ errorCode?: BotOnboardingErrorCode;
/**
* Set on a `connected` snapshot when the channel was saved successfully but
* the live bridge did not reach a running/healthy state within the commit
- * window. The saved channel is valid and persisted; this is an honest,
- * redacted notice (never carries provider credentials) that the connection
- * still needs to be (re)established — never a hard failure of onboarding.
+ * window. The saved channel is valid and persisted; this is an honest notice
+ * that the connection still needs to be (re)established — never a hard
+ * failure of onboarding.
*/
- warning?: string;
+ warningCode?: 'saved_not_connected';
+ /** Redacted live-bridge failure reason (external text), rendered verbatim. */
+ warningDetail?: string;
}
+export type BotOnboardingErrorCode =
+ | 'cancelled'
+ | 'timeout'
+ | 'rate_limited'
+ | 'auth_failed'
+ | 'provider_error'
+ | 'network_error'
+ | 'unavailable';
+
export function isBotOnboardingProvider(value: unknown): value is BotOnboardingProvider {
return (
typeof value === 'string' && (BOT_ONBOARDING_PROVIDERS as readonly string[]).includes(value)
diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts
index add461e25a..2e3a40faf7 100644
--- a/packages/core/src/redaction.ts
+++ b/packages/core/src/redaction.ts
@@ -208,94 +208,70 @@ function isAssignmentSensitiveKey(key: string): boolean {
return suffix !== 'auth' && suffix !== 'authorization';
}
-type GeneralizedErrorCategory = 'timeout' | 'rateLimit' | 'authentication' | 'provider' | 'network';
+export type GeneralizedErrorClass =
+ | 'timeout'
+ | 'rate_limited'
+ | 'auth_failed'
+ | 'provider_error'
+ | 'network_error';
-const GENERALIZED_ERROR_COPY: UiCatalog> = {
- 'zh-CN': {
- timeout: '请求超时',
- rateLimit: '触发模型速率限制',
- authentication: '鉴权失败',
- provider: '模型服务返回错误',
- network: '网络错误',
- },
- 'zh-TW': {
- timeout: '請求逾時',
- rateLimit: '已達模型速率限制',
- authentication: '驗證失敗',
- provider: '模型服務傳回錯誤',
- network: '網路錯誤',
- },
- en: {
- timeout: 'Request timed out',
- rateLimit: 'Rate limit exceeded',
- authentication: 'Authentication failed',
- provider: 'Provider returned an error',
- network: 'Network error',
- },
-};
-
-function classifyGeneralizedError(error: unknown): GeneralizedErrorCategory | null {
+/**
+ * Keyword classification shared by the localized message helpers and by
+ * producers that emit a stable machine code instead of prose.
+ */
+export function classifyGeneralizedError(error: unknown): GeneralizedErrorClass | undefined {
const message = error instanceof Error ? error.message : String(error);
- const redacted = redactSecrets(message);
- const lower = redacted.toLowerCase();
+ const lower = redactSecrets(message).toLowerCase();
if (lower.includes('timeout')) return 'timeout';
- if (lower.includes('429') || lower.includes('rate')) return 'rateLimit';
+ if (lower.includes('429') || lower.includes('rate')) return 'rate_limited';
if (lower.includes('401') || lower.includes('403') || isAuthenticationErrorText(lower))
- return 'authentication';
- if (lower.includes('5') && /\b5\d\d\b/.test(lower)) return 'provider';
+ return 'auth_failed';
+ if (lower.includes('5') && /\b5\d\d\b/.test(lower)) return 'provider_error';
if (
lower.includes('network') ||
lower.includes('fetch') ||
lower.includes('econn') ||
lower.includes('enotfound')
)
- return 'network';
- return null;
+ return 'network_error';
+ return undefined;
}
-function localizedGeneralizedErrorMessage(
+const GENERALIZED_ERROR_COPY = {
+ 'zh-CN': {
+ timeout: '请求超时',
+ rate_limited: '触发模型速率限制',
+ auth_failed: '鉴权失败',
+ provider_error: '模型服务返回错误',
+ network_error: '网络错误',
+ },
+ 'zh-TW': {
+ timeout: '請求逾時',
+ rate_limited: '已達模型速率限制',
+ auth_failed: '驗證失敗',
+ provider_error: '模型服務傳回錯誤',
+ network_error: '網路錯誤',
+ },
+ en: {
+ timeout: 'Request timed out',
+ rate_limited: 'Rate limit exceeded',
+ auth_failed: 'Authentication failed',
+ provider_error: 'Provider returned an error',
+ network_error: 'Network error',
+ },
+} satisfies UiCatalog>;
+
+export function generalizedErrorMessageForLocale(
error: unknown,
fallback: string,
locale: UiLocale,
): string {
- const category = classifyGeneralizedError(error);
- return category ? GENERALIZED_ERROR_COPY[locale][category] : fallback;
+ const classified = classifyGeneralizedError(error);
+ return classified ? GENERALIZED_ERROR_COPY[locale][classified] : fallback;
}
export function generalizedErrorMessage(error: unknown, fallback = 'Operation failed'): string {
- return localizedGeneralizedErrorMessage(error, fallback, 'en');
-}
-
-/**
- * Chinese-locale companion to `generalizedErrorMessage()` (PR110b
- * follow-up). Same classification rules; returns Chinese phrasing
- * instead of English. Used by surfaces that must enforce a
- * Chinese-only error copy contract (session start, onboarding setup
- * banners, etc.) — the English version would have leaked through any
- * matched category, breaking the gate.
- *
- * The fallback default is also Chinese so callers that don't supply
- * one still produce a Chinese-only result. Pass a more specific
- * Chinese fallback (e.g. "会话已创建但发送失败,请重试。") for better
- * UX when the classifier can't categorize.
- */
-export function generalizedErrorMessageChinese(error: unknown, fallback = '操作失败'): string {
- return localizedGeneralizedErrorMessage(error, fallback, 'zh-CN');
-}
-
-export function generalizedErrorMessageTraditionalChinese(
- error: unknown,
- fallback = '操作失敗',
-): string {
- return localizedGeneralizedErrorMessage(error, fallback, 'zh-TW');
-}
-
-export function generalizedErrorMessageForLocale(
- error: unknown,
- fallback: string,
- locale: UiLocale,
-): string {
- return localizedGeneralizedErrorMessage(error, fallback, locale);
+ return generalizedErrorMessageForLocale(error, fallback, 'en');
}
export function isAuthenticationErrorText(message: string): boolean {
diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts
index 069254891c..7c3ee8cd88 100644
--- a/packages/core/src/ui-locale.ts
+++ b/packages/core/src/ui-locale.ts
@@ -169,3 +169,11 @@ export function resolveUiLocale(
export function uiLocaleToIntlLocale(locale: UiLocale): UiLocale {
return locale;
}
+
+/** Copy for a wire code, or undefined when a newer producer sent one this catalog does not know. */
+export function lookupCopy(
+ map: Readonly>,
+ code: string | undefined,
+): string | undefined {
+ return code !== undefined && Object.hasOwn(map, code) ? map[code] : undefined;
+}
diff --git a/packages/runtime/src/bots/bot-test.ts b/packages/runtime/src/bots/bot-test.ts
index f3b5d2e867..485ef3837c 100644
--- a/packages/runtime/src/bots/bot-test.ts
+++ b/packages/runtime/src/bots/bot-test.ts
@@ -54,7 +54,7 @@ export async function testBotChannel(
provider !== 'slack' &&
!channel.token.trim()
) {
- return { ok: false, error: 'Bot token is required' };
+ return { ok: false, errorCode: 'token_missing' };
}
switch (provider) {
case 'telegram':
@@ -80,7 +80,7 @@ async function testSlack(channel: BotChannelSettings): Promise {
const botToken = channel.token.trim();
const appToken = channel.appSecret?.trim() ?? '';
if (!botToken || !appToken) {
- return { ok: false, error: 'Slack 需要 Bot Token 与 App-Level Token' };
+ return { ok: false, errorCode: 'slack_tokens_missing' };
}
try {
const identity = await new WebClient(botToken).auth.test();
@@ -96,7 +96,7 @@ async function testSlack(channel: BotChannelSettings): Promise {
...(identity.user ? { username: identity.user, displayName: identity.user } : {}),
},
capabilities: { auth: true, socketMode: true },
- hint: '凭据有效,Socket Mode 连接可用。',
+ hintCode: 'slack_socket_ready',
};
} catch (error) {
return { ok: false, error: generalizedErrorMessage(error) };
@@ -129,7 +129,7 @@ async function testWechat(channel: BotChannelSettings): Promise {
ok: true,
identity: { id: appId, username: appId, displayName: appId },
capabilities: { auth: true },
- hint: '凭据有效;消息收发还需要公众号服务器配置和回调验证。',
+ hintCode: 'wechat_mp_callback_required',
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -142,7 +142,12 @@ async function testTelegram(channel: BotChannelSettings): Promise
const me = await (
await proxiedFetch(`${base}/getMe`, { method: 'GET', timeoutMs: BOT_TEST_TIMEOUT_MS })
).json();
- if (!me.ok) return { ok: false, error: me.description ?? 'Invalid bot token' };
+ if (!me.ok)
+ return {
+ ok: false,
+ errorCode: 'token_invalid',
+ ...(me.description ? { error: me.description } : {}),
+ };
return {
ok: true,
identity: {
@@ -151,7 +156,7 @@ async function testTelegram(channel: BotChannelSettings): Promise
displayName: me.result.first_name,
},
messageSent: false,
- hint: '发送 /start 给机器人后可在运行态接收消息。',
+ hintCode: 'telegram_send_start',
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -200,14 +205,14 @@ async function testWeCom(channel: BotChannelSettings): Promise {
const botId = channel.appId?.trim() ?? '';
const secret = channel.appSecret?.trim() ?? '';
if (!botId || !secret) {
- return { ok: false, error: '企业微信 AI 机器人需要 Bot ID 与 Secret', verified: false };
+ return { ok: false, errorCode: 'wecom_credentials_missing', verified: false };
}
return {
ok: true,
verified: false,
identity: { id: botId, username: botId, displayName: botId },
capabilities: { auth: false },
- hint: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。',
+ hintCode: 'wecom_runtime_authority',
};
}
@@ -230,7 +235,7 @@ async function testDingTalk(channel: BotChannelSettings): Promise
const appkey = channel.appId?.trim() ?? '';
const appsecret = channel.appSecret?.trim() ?? '';
if (!appkey || !appsecret) {
- return { ok: false, error: '钉钉需要 appkey 与 appsecret' };
+ return { ok: false, errorCode: 'dingtalk_credentials_missing' };
}
const url =
'https://oapi.dingtalk.com/gettoken?appkey=' +
@@ -246,17 +251,17 @@ async function testDingTalk(channel: BotChannelSettings): Promise
if (json.errcode && json.errcode !== 0) {
return {
ok: false,
- error: json.errmsg ? `钉钉: ${json.errmsg}` : `钉钉 errcode ${json.errcode}`,
+ error: json.errmsg ? String(json.errmsg) : `errcode ${json.errcode}`,
};
}
if (typeof json.access_token !== 'string' || json.access_token.length === 0) {
- return { ok: false, error: '钉钉凭据测试未返回 access_token' };
+ return { ok: false, errorCode: 'dingtalk_no_access_token' };
}
return {
ok: true,
identity: { id: appkey, username: appkey, displayName: appkey },
capabilities: { auth: true },
- hint: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。',
+ hintCode: 'dingtalk_stream_required',
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -282,7 +287,7 @@ async function testQQ(channel: BotChannelSettings): Promise {
const appId = channel.appId?.trim() ?? '';
const clientSecret = channel.appSecret?.trim() ?? '';
if (!appId || !clientSecret) {
- return { ok: false, error: 'QQ 官方机器人需要 App ID 与 Client Secret' };
+ return { ok: false, errorCode: 'qq_credentials_missing' };
}
try {
const response = await proxiedFetch('https://bots.qq.com/app/getAppAccessToken', {
@@ -295,18 +300,18 @@ async function testQQ(channel: BotChannelSettings): Promise {
if (!response.ok) {
const message =
typeof json.message === 'string' && json.message.length > 0
- ? `QQ: ${json.message}`
+ ? json.message
: `HTTP ${response.status}`;
return { ok: false, error: message };
}
if (typeof json.access_token !== 'string' || json.access_token.length === 0) {
- return { ok: false, error: 'QQ 官方机器人凭据测试未返回 access_token' };
+ return { ok: false, errorCode: 'qq_no_access_token' };
}
return {
ok: true,
identity: { id: appId, username: appId, displayName: appId },
capabilities: { auth: true },
- hint: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。',
+ hintCode: 'qq_gateway_required',
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -316,7 +321,7 @@ async function testQQ(channel: BotChannelSettings): Promise {
async function testFeishu(channel: BotChannelSettings): Promise {
const appId = channel.appId ?? '';
const appSecret = channel.appSecret || channel.token;
- if (!appId || !appSecret) return { ok: false, error: 'Feishu appId and appSecret are required' };
+ if (!appId || !appSecret) return { ok: false, errorCode: 'feishu_credentials_missing' };
// PR1197 review (P1-4): brand-switch the account host by the channel domain,
// exactly like the onboarding flow does. A Lark tenant (larksuite.com) cannot
// issue a tenant_access_token from the feishu.cn host, so a hardcoded host
diff --git a/packages/runtime/src/bots/index.ts b/packages/runtime/src/bots/index.ts
index 1a4d0fd6c9..0f7b15dae8 100644
--- a/packages/runtime/src/bots/index.ts
+++ b/packages/runtime/src/bots/index.ts
@@ -35,7 +35,7 @@ export {
testWechatIlinkCredentials,
WechatBridge,
} from './wechat-bridge.js';
-export type { WechatBridgeQrCodeResult } from './wechat-bridge.js';
+export type { WechatBridgeQrCodeResult, WechatBridgeQrHintCode } from './wechat-bridge.js';
export { WeComBotBridge, wecomTextFrameToEvent } from './wecom-bridge.js';
export { SlackBotBridge, slackMessageToEvent } from './slack-bridge.js';
export type {
@@ -46,5 +46,7 @@ export type {
BotReplyStreamOptions,
BotStatus,
BotTestResult,
+ BotTestErrorCode,
+ BotTestHintCode,
SendCapable,
} from './types.js';
diff --git a/packages/runtime/src/bots/types.ts b/packages/runtime/src/bots/types.ts
index 4e9d3719a9..09c84ef18a 100644
--- a/packages/runtime/src/bots/types.ts
+++ b/packages/runtime/src/bots/types.ts
@@ -116,13 +116,42 @@ export interface SendCapable {
sendTypingIndicator?(chatId: string): Promise;
}
+/** Stable machine codes for producer-known test failures; presenters own copy. */
+export type BotTestErrorCode =
+ | 'token_missing'
+ | 'token_invalid'
+ | 'slack_tokens_missing'
+ | 'feishu_credentials_missing'
+ | 'wecom_credentials_missing'
+ | 'dingtalk_credentials_missing'
+ | 'dingtalk_no_access_token'
+ | 'qq_credentials_missing'
+ | 'qq_no_access_token'
+ | 'wechat_bridge_url_invalid'
+ | 'wechat_ilink_credentials_incomplete';
+
+/** Stable machine codes for follow-up guidance; presenters own copy. */
+export type BotTestHintCode =
+ | 'slack_socket_ready'
+ | 'wechat_mp_callback_required'
+ | 'telegram_send_start'
+ | 'wecom_runtime_authority'
+ | 'dingtalk_stream_required'
+ | 'qq_gateway_required'
+ | 'wechat_bridge_local_only'
+ | 'wechat_bridge_start_required'
+ | 'wechat_ilink_login_required'
+ | 'wechat_ilink_polling';
+
export interface BotTestResult {
ok: boolean;
identity?: { id: string; username?: string; displayName?: string };
messageSent?: boolean;
capabilities?: Record;
+ errorCode?: BotTestErrorCode;
+ /** Raw platform-supplied diagnostic (external error text), passed through verbatim. */
error?: string;
- hint?: string;
+ hintCode?: BotTestHintCode;
/**
* `false` when `ok` reflects only a shape/non-empty check and the credentials
* could NOT be verified against a live endpoint (e.g. WeCom AI-bot creds,
diff --git a/packages/runtime/src/bots/wechat-bridge.ts b/packages/runtime/src/bots/wechat-bridge.ts
index 642fbbf17f..8baa9839b8 100644
--- a/packages/runtime/src/bots/wechat-bridge.ts
+++ b/packages/runtime/src/bots/wechat-bridge.ts
@@ -304,6 +304,9 @@ export class WechatBridge extends BaseBotAdapter implements SendCapable {
}
}
+/** Stable machine codes for QR sign-in guidance; presenters own copy. */
+export type WechatBridgeQrHintCode = 'wechat_bridge_remote_url' | 'wechat_bridge_unreachable';
+
export type WechatBridgeQrCodeResult =
| {
ok: true;
@@ -315,7 +318,7 @@ export type WechatBridgeQrCodeResult =
| {
ok: false;
error: string;
- hint: string;
+ hintCode?: WechatBridgeQrHintCode;
};
export async function getWechatBridgeQrCode(
@@ -326,7 +329,7 @@ export async function getWechatBridgeQrCode(
return {
ok: false,
error: 'WeChat bridge URL must be http://127.0.0.1 or http://localhost',
- hint: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。',
+ hintCode: 'wechat_bridge_remote_url',
};
}
@@ -344,7 +347,7 @@ export async function getWechatBridgeQrCode(
return {
ok: false,
error: generalizedErrorMessage(lastError),
- hint: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。',
+ hintCode: 'wechat_bridge_unreachable',
};
}
@@ -447,8 +450,8 @@ export async function testWechatBridge(channel: BotChannelSettings): Promise