Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,9 @@
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
"dependencyPaths": {
"@maka/core/ui-locale": 1
}
},
"src/renderer/locales/settings-daily-review-copy.ts": {
"bridgePaths": {},
Expand Down
291 changes: 291 additions & 0 deletions apps/desktop/src/main/__tests__/bot-chat-detail.test.ts

Large diffs are not rendered by default.

39 changes: 35 additions & 4 deletions apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(); },
Expand Down Expand Up @@ -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);
});

Expand All @@ -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 () => {
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-undici.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

type UndiciLike = {
MockAgent: new () => MockAgentLike;
getGlobalDispatcher(): unknown;
setGlobalDispatcher(dispatcher: unknown): void;
};

export function loadRuntimeUndici(): UndiciLike {
return createRequire(import.meta.resolve('@maka/runtime/bots'))('undici');
}
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
164 changes: 163 additions & 1 deletion apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, Parameters<IpcMain['handle']>[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<BotTestErrorCode, BotProvider>;
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');
});
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main/bot-incoming-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (closed) return;
await deps.botRegistry.sendMessage(
Expand Down Expand Up @@ -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 桌面端审批后才能继续。';
Expand Down
Loading