refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
import { channelUiPresentation } from "@/channel-plugins/registry";
const SLACK_SOCKET_MODE_MANIFEST = channelUiPresentation("slack")?.setup?.actions?.find(
(action) => action.id === "slack-manifest",
)?.copyText;
describe("Slack setup manifest", () => {
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
channelIsRunning,
channelSetup,
channelStatusLabel,
channelToggleChecked,
} from "@/components/settings/channels/ChannelIdentity";
import type { NanobotFeatureInfo } from "@/lib/types";
function feature(overrides: Partial<NanobotFeatureInfo>): NanobotFeatureInfo {
return {
name: "plugin-chat",
display_name: "Plugin Chat",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
...overrides,
};
}
describe("channelSetup", () => {
it("builds editable fields for a plugin-owned backend contract", () => {
const setup = channelSetup(feature({
setup: {
fields: [
{
key: "channels.plugin-chat.apiToken",
field: "apiToken",
kind: "secret",
choices: [],
required: true,
},
{
key: "channels.plugin-chat.region",
field: "region",
kind: "enum",
choices: ["us", "eu"],
required: false,
},
],
official_url: "https://plugin.example/setup",
},
}));
expect(setup.officialUrl).toBe("https://plugin.example/setup");
expect(setup.officialLabel).toBe("Open official setup");
expect(setup.fields).toEqual([
expect.objectContaining({
key: "channels.plugin-chat.apiToken",
label: "Api Token",
secret: true,
optional: false,
}),
expect.objectContaining({
key: "channels.plugin-chat.region",
options: [
{ value: "us", label: "Us" },
{ value: "eu", label: "Eu" },
],
}),
]);
});
it("filters catalog-only fields that the backend does not accept", () => {
const setup = channelSetup(feature({
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
setup: {
fields: [{
key: "channels.discord.token",
field: "token",
kind: "secret",
choices: [],
required: true,
}],
},
}));
expect(setup.fields?.map((field) => field.key)).toEqual(["channels.discord.token"]);
expect(setup.manualFields).toBeUndefined();
});
it("uses backend defaults and choices with catalog presentation labels", () => {
const setup = channelSetup(feature({
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
setup: {
fields: [{
key: "channels.discord.groupPolicy",
field: "groupPolicy",
kind: "enum",
choices: ["open"],
required: false,
default_value: "open",
}],
},
}));
expect(setup.fields).toEqual([
expect.objectContaining({
key: "channels.discord.groupPolicy",
label: "Group behavior",
defaultValue: "open",
options: [{ value: "open", label: "All messages" }],
}),
]);
});
it("loads setup copy from the channel-owned locale", () => {
const setup = channelSetup(feature({
name: "dingtalk",
display_name: "DingTalk",
webui: "webui/index.ts",
}), "zh-CN");
expect(setup.summary).toBe("钉钉需要 Stream 模式的应用凭据。");
expect(setup.steps[0]).toBe("创建或选择一个已启用 Stream 模式的钉钉应用。");
expect(setup.fields).toContainEqual(expect.objectContaining({
key: "channels.dingtalk.allowFrom",
label: "允许的用户",
}));
});
});
describe("channel runtime state", () => {
const tx = (_key: string, fallback: string) => fallback;
it("only reports a channel on when the runtime is explicitly running", () => {
const running = feature({ enabled: true, runtime_status: "running" });
const unknown = feature({ enabled: true });
expect(channelIsRunning(running)).toBe(true);
expect(channelToggleChecked(running)).toBe(true);
expect(channelStatusLabel(running, tx)).toBe("On");
expect(channelIsRunning(unknown)).toBe(false);
expect(channelToggleChecked(unknown)).toBe(false);
expect(channelStatusLabel(unknown, tx)).toBe("Not running");
});
});
@@ -0,0 +1,112 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { channelFieldMessageKey } from "@/channel-plugins/i18n";
import { registeredChannelLocales } from "@/channel-plugins/locale-registry";
import { registeredChannelUiContributions } from "@/channel-plugins/registry";
import { supportedLocales } from "@/i18n/config";
const expectedChannels = [
"dingtalk",
"discord",
"email",
"feishu",
"matrix",
"mattermost",
"msteams",
"napcat",
"qq",
"signal",
"slack",
"telegram",
"websocket",
"wecom",
"weixin",
"whatsapp",
];
function flatten(value: unknown, prefix = ""): Map<string, string> {
const entries = new Map<string, string>();
if (typeof value === "string") {
entries.set(prefix, value);
return entries;
}
if (!value || typeof value !== "object") return entries;
for (const [key, child] of Object.entries(value)) {
if (!prefix && key === "displayName") continue;
const childPrefix = prefix ? `${prefix}.${key}` : key;
for (const [childKey, text] of flatten(child, childPrefix)) {
entries.set(childKey, text);
}
}
return entries;
}
function interpolationKeys(value: string): string[] {
return [...value.matchAll(/{{\s*([\w.-]+)\s*}}/g)]
.map((match) => match[1])
.sort();
}
describe("channel locale registry", () => {
it("loads every supported locale from every built-in channel package", () => {
const registrations = registeredChannelLocales();
expect([...registrations.keys()].sort()).toEqual(expectedChannels);
for (const [channel, locales] of registrations) {
expect([...locales.keys()].sort()).toEqual(
supportedLocales.map(({ code }) => code).sort(),
);
const english = flatten(locales.get("en"));
for (const [locale, messages] of locales) {
const translated = flatten(messages);
expect([...translated.keys()].sort(), `${channel}/${locale} message keys`).toEqual(
[...english.keys()].sort(),
);
for (const [key, source] of english) {
expect(
interpolationKeys(translated.get(key) ?? ""),
`${channel}/${locale}:${key} interpolation keys`,
).toEqual(interpolationKeys(source));
}
}
}
});
it("keeps structural UI definitions aligned with English locale keys", () => {
const locales = registeredChannelLocales();
for (const { channel, contribution } of registeredChannelUiContributions()) {
const messages = locales.get(channel)?.get("en");
expect(messages, `${channel} English messages`).toBeDefined();
const setup = contribution.presentation.setup;
for (const field of [...(setup?.fields ?? []), ...(setup?.manualFields ?? [])]) {
const messageKey = channelFieldMessageKey(channel, field.key);
expect(messages?.setup.fields?.[messageKey], `${channel} field ${messageKey}`).toBeDefined();
}
for (const action of setup?.actions ?? []) {
expect(messages?.setup.actions?.[action.id], `${channel} action ${action.id}`).toBeTypeOf("string");
}
for (const preset of setup?.presets ?? []) {
expect(messages?.setup.presets?.[preset.id], `${channel} preset ${preset.id}`).toBeTypeOf("string");
}
}
});
it("keeps i18n initialization independent from channel React modules", () => {
const localeRegistry = readFileSync(
resolve(process.cwd(), "src/channel-plugins/locale-registry.ts"),
"utf8",
);
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
expect(localeRegistry).toContain("webui/locales/*.json");
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
expect(i18nEntry).toContain("channel-plugins/locale-registry");
expect(i18nEntry).not.toContain("channel-plugins/registry");
});
});
@@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
channelUiContribution,
channelUiOwner,
channelUiPresentation,
registeredChannelUiContributions,
} from "@/channel-plugins/registry";
describe("channel UI contributions", () => {
it("selects channel-owned UI only through the backend manifest entry", () => {
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function");
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function");
expect(channelUiContribution("feishu", undefined)).toBeUndefined();
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
const registrations = registeredChannelUiContributions();
const channels = registrations.map((entry) => entry.channel);
expect(channels).toEqual(expect.arrayContaining(["feishu", "weixin"]));
expect(new Set(channels).size).toBe(channels.length);
expect(registrations.every((entry) => /^webui\/index\.tsx?$/.test(entry.webui))).toBe(true);
expect(channelUiContribution("slack", "webui/index.ts")?.presentation.displayName).toBe("Slack");
});
it("keeps aliases inside the owning channel contribution", () => {
expect(channelUiPresentation("lark")?.displayName).toBe("Lark");
expect(channelUiPresentation("wechat")?.displayName).toBe("WeChat");
expect(channelUiOwner("lark")).toBe("feishu");
expect(channelUiOwner("wechat")).toBe("weixin");
});
it("uses the DingTalk Open Platform brand mark", () => {
expect(channelUiPresentation("dingtalk")?.logoUrl).toBe(
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
);
});
it("keeps the core setup panel independent of concrete channel plugins", () => {
const source = readFileSync(
resolve(process.cwd(), "src/components/settings/channels/ChannelSetupPanel.tsx"),
"utf8",
);
expect(source).not.toMatch(/feature\.name\s*===\s*["'](?:feishu|weixin)["']/);
expect(source).not.toMatch(/channel-plugins\/(?:feishu|weixin)/);
expect(source).not.toMatch(/(?:Feishu|Weixin)(?:AssistantsPanel|ConnectFlow)/);
});
it("discovers UI contributions only from channel-owned packages", () => {
const source = readFileSync(
resolve(process.cwd(), "src/channel-plugins/registry.ts"),
"utf8",
);
expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}");
expect(source).not.toContain('"./*/index.tsx"');
});
it("derives channel identity from the package directory", () => {
for (const channel of ["feishu", "weixin"]) {
const source = readFileSync(
resolve(process.cwd(), `../nanobot/channels/${channel}/webui/index.tsx`),
"utf8",
);
expect(source).not.toMatch(/\bchannel\s*:/);
}
});
it("includes channel-owned UI in Tailwind's production scan", () => {
const source = readFileSync(resolve(process.cwd(), "tailwind.config.js"), "utf8");
expect(source).toContain("../nanobot/channels/*/webui/**/*.{ts,tsx}");
});
});
+47 -1
View File
@@ -136,6 +136,48 @@ const LOCALIZED_WORKSPACE_COPY_KEYS = [
"workspace.dialog.usePath",
"workspace.dialog.absolutePathRequired",
];
const LOCALIZED_CHANNEL_SHELL_KEYS = [
"settings.channels.advanced",
"settings.channels.checkAndEnable",
"settings.channels.checkConnection",
"settings.channels.checkedAndEnabled",
"settings.channels.checking",
"settings.channels.checkOnly",
"settings.channels.commandCopied",
"settings.channels.commandCopyFailed",
"settings.channels.configuredInstances",
"settings.channels.connectPreview",
"settings.channels.copyCommand",
"settings.channels.filterAll",
"settings.channels.filterOff",
"settings.channels.filterOn",
"settings.channels.helperCopied",
"settings.channels.helperCopyFailed",
"settings.channels.hideSecret",
"settings.channels.instanceConfigured",
"settings.channels.instanceNeedsSetup",
"settings.channels.managedByWebui",
"settings.channels.officialGuide",
"settings.channels.optional",
"settings.channels.providerPreset",
"settings.channels.requiredSetup",
"settings.channels.savedSecret",
"settings.channels.savedSecretPlaceholder",
"settings.channels.savedSettings",
"settings.channels.saveSettings",
"settings.channels.selectChannel",
"settings.channels.setupSteps",
"settings.channels.showSecret",
"settings.channels.toggleChannel",
"settings.channels.toggleInstance",
"settings.channels.tryIt",
"settings.channels.validation.connected",
"settings.channels.validation.configured",
"settings.channels.validation.invalid",
"settings.channels.validation.needs_setup",
"settings.channels.validation.unsupported",
"settings.channels.validationFailed",
];
const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
const PREBOOT_SCRIPT = INDEX_HTML.match(
/<script>\s*(\(function \(\) \{\s*var localeKey = "nanobot\.locale";[\s\S]*?\}\)\(\);)\s*<\/script>/,
@@ -375,7 +417,11 @@ describe("webui i18n", () => {
for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue;
const current = flattenResource(resource.common);
const leaked = [...LOCALIZED_SETTINGS_COPY_KEYS, ...LOCALIZED_WORKSPACE_COPY_KEYS].filter(
const leaked = [
...LOCALIZED_SETTINGS_COPY_KEYS,
...LOCALIZED_WORKSPACE_COPY_KEYS,
...LOCALIZED_CHANNEL_SHELL_KEYS,
].filter(
(key) => current.get(key) === english.get(key),
);
+381 -14
View File
@@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
import type {
ChannelSetupContract,
ChannelSetupContractField,
SettingsPayload,
} from "@/lib/types";
function jsonResponse(body: unknown): Response {
return {
@@ -120,6 +124,118 @@ function settingsPayload(): SettingsPayload {
};
}
function channelSetupField(
channel: string,
field: string,
kind: ChannelSetupContractField["kind"] = "string",
options: {
required?: boolean;
choices?: string[];
defaultValue?: string;
} = {},
): ChannelSetupContractField {
return {
key: `channels.${channel}.${field}`,
field,
kind,
choices: options.choices ?? [],
required: options.required ?? false,
...(options.defaultValue === undefined ? {} : { default_value: options.defaultValue }),
};
}
function channelSetupContract(
channel: "discord" | "email" | "feishu" | "matrix" | "qq",
): ChannelSetupContract {
const field = (
name: string,
kind: ChannelSetupContractField["kind"] = "string",
options: Parameters<typeof channelSetupField>[3] = {},
) => channelSetupField(channel, name, kind, options);
switch (channel) {
case "discord":
return {
official_url: "https://discord.com/developers/applications",
fields: [
field("token", "secret", { required: true }),
field("allowFrom", "list"),
field("allowChannels", "list"),
field("groupPolicy", "enum", {
choices: ["mention", "open"],
defaultValue: "mention",
}),
],
};
case "email":
return {
official_url: "https://support.google.com/accounts/answer/185833",
fields: [
field("consentGranted", "bool", { required: true, defaultValue: "false" }),
field("imapHost", "string", { required: true }),
field("imapPort", "int"),
field("imapUsername", "string", { required: true }),
field("imapPassword", "secret", { required: true }),
field("smtpHost", "string", { required: true }),
field("smtpPort", "int"),
field("smtpUsername", "string", { required: true }),
field("smtpPassword", "secret", { required: true }),
field("fromAddress"),
field("pollIntervalSeconds", "int"),
field("allowFrom", "list"),
field("verifyDkim", "bool", { defaultValue: "true" }),
field("verifySpf", "bool", { defaultValue: "true" }),
],
};
case "feishu":
return {
official_url: "https://open.feishu.cn/app",
fields: [
field("appId", "string", { required: true }),
field("appSecret", "secret", { required: true }),
field("domain", "enum", {
choices: ["feishu", "lark"],
defaultValue: "feishu",
}),
field("groupPolicy", "enum", {
choices: ["mention", "open"],
defaultValue: "mention",
}),
field("allowFrom", "list"),
field("topicIsolation", "bool"),
],
};
case "matrix":
return {
official_url: "https://matrix.org/ecosystem/clients/",
fields: [
field("homeserver", "string", { required: true }),
field("userId", "string", { required: true }),
field("password", "secret"),
field("accessToken", "secret"),
field("deviceId"),
field("groupPolicy", "enum", {
choices: ["allowlist", "mention", "open"],
defaultValue: "open",
}),
],
};
case "qq":
return {
official_url: "https://q.qq.com/",
fields: [
field("appId", "string", { required: true }),
field("secret", "secret", { required: true }),
field("allowFrom", "list"),
field("msgFormat", "enum", {
choices: ["markdown", "plain"],
defaultValue: "plain",
}),
],
};
}
}
function autoDynamicProviderPayload(
options: {
configured: boolean;
@@ -417,6 +533,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: false,
@@ -433,8 +550,11 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
installed: true,
ready: true,
status: "enabled",
@@ -450,6 +570,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
@@ -519,7 +640,7 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
});
it("shows enabled nanobot channels with missing support as enabled", async () => {
it("shows an enabled channel with missing support as failed", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -532,6 +653,9 @@ describe("SettingsView Apps catalog", () => {
display_name: "Matrix",
type: "channel",
enabled: true,
running: false,
runtime_status: "failed",
runtime_error: "Channel dependencies could not be installed. Check gateway logs.",
installed: false,
ready: false,
status: "missing_dependency",
@@ -565,11 +689,11 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
expect(screen.getByText("1 enabled · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false");
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
@@ -583,6 +707,51 @@ describe("SettingsView Apps catalog", () => {
);
});
it("shows a configured channel as failed when its runtime did not start", async () => {
const runtimeError = "Channel failed to start. Check gateway logs.";
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: false,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
status: "failed",
install_supported: true,
requires_restart: false,
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.getByText(runtimeError)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute(
"aria-checked",
"false",
);
});
it("starts Feishu connect in WebUI instead of showing a CLI command", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -594,6 +763,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: false,
@@ -651,6 +821,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: false,
@@ -706,6 +877,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: false,
configured: true,
@@ -723,16 +895,24 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
instances: [{
id: "default",
name: "nanobot",
domain: "feishu",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
app_id: "cli_test",
config_values: { "channels.feishu.appId": "cli_test" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
installed: true,
ready: true,
@@ -787,6 +967,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
@@ -795,26 +976,42 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("feishu"),
instances: [
{
id: "default",
name: "nanobot",
display_name: "Support Bot",
avatar_url: "https://example.com/support.png",
domain: "feishu",
enabled: true,
configured: true,
app_id: "cli_default",
config_values: {
"channels.feishu.appId": "cli_default",
"channels.feishu.domain": "feishu",
"channels.feishu.groupPolicy": "mention",
"channels.feishu.allowFrom": "",
"channels.feishu.topicIsolation": "true",
},
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
"channels.feishu.domain",
"channels.feishu.groupPolicy",
"channels.feishu.topicIsolation",
],
},
{
id: "product",
name: "Product bot",
display_name: "Product Helper",
avatar_url: "https://example.com/product.png",
domain: "feishu",
enabled: false,
configured: true,
app_id: "cli_product",
config_values: { "channels.feishu.appId": "cli_product" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
},
],
}],
@@ -847,6 +1044,8 @@ describe("SettingsView Apps catalog", () => {
"true",
);
expect(screen.getAllByText("cli_def...ault").length).toBeGreaterThan(0);
expect(screen.getByText("Advanced")).toBeInTheDocument();
expect(screen.getByText("Topic isolation")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Product Helper/ }));
expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute(
@@ -859,18 +1058,98 @@ describe("SettingsView Apps catalog", () => {
);
});
it("renders external multi-instance channels from the shared contract", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "multiplugin",
display_name: "Multi Plugin",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: false,
setup: {
fields: [
channelSetupField("multiplugin", "token", "secret", { required: true }),
channelSetupField("multiplugin", "region", "enum", {
choices: ["eu", "us"],
defaultValue: "us",
}),
],
},
instances: [
{
id: "default",
name: "Default worker",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
config_values: { "channels.multiplugin.region": "us" },
configured_fields: ["channels.multiplugin.token"],
},
{
id: "product",
name: "Product worker",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
config_values: { "channels.multiplugin.region": "eu" },
configured_fields: ["channels.multiplugin.token"],
},
],
}],
enabled_count: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByText("Default worker")).toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Multi Plugin channel" })).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Default worker instance" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("switch", { name: "Product worker instance" })).toHaveAttribute(
"aria-checked",
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Product worker" }));
expect(screen.getByRole("radio", { name: "Eu" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("Saved")).toBeInTheDocument();
});
it("shows a single Feishu assistant without a duplicate assistant list", async () => {
const reconnectUrls: string[] = [];
const feishuPayload = {
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: true,
status: "enabled",
running: true,
runtime_status: "running",
install_supported: true,
requires_restart: true,
instances: [{
@@ -878,10 +1157,15 @@ describe("SettingsView Apps catalog", () => {
name: "nanobot",
display_name: "Support Bot",
avatar_url: "https://example.com/support.png",
domain: "feishu",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
app_id: "cli_support",
config_values: { "channels.feishu.appId": "cli_support" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
}],
enabled_count: 1,
@@ -920,6 +1204,67 @@ describe("SettingsView Apps catalog", () => {
expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy();
});
it("does not call a configured Feishu assistant connected after runtime failure", async () => {
const runtimeError = "Channel failed to start. Check gateway logs.";
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "feishu",
display_name: "Feishu",
webui: "webui/index.tsx",
type: "channel",
enabled: true,
configured: true,
installed: true,
ready: false,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
status: "failed",
install_supported: true,
requires_restart: false,
instances: [{
id: "default",
name: "test",
enabled: true,
configured: true,
running: false,
runtime_status: "failed",
runtime_error: runtimeError,
config_values: { "channels.feishu.appId": "cli_test" },
configured_fields: [
"channels.feishu.appId",
"channels.feishu.appSecret",
],
}],
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "channels" });
await screen.findByText("No assistant connected");
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
expect(screen.getByText(runtimeError)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.queryByText("Connected")).not.toBeInTheDocument();
});
it("shows group behavior fields as options", async () => {
vi.stubGlobal(
"fetch",
@@ -933,6 +1278,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
installed: true,
@@ -940,6 +1286,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
});
@@ -1039,6 +1386,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: false,
configured: false,
@@ -1047,6 +1395,7 @@ describe("SettingsView Apps catalog", () => {
status: "not_enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 0,
});
@@ -1064,14 +1413,18 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
configured: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
requires_restart: false,
@@ -1150,6 +1503,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: false,
configured: true,
@@ -1167,6 +1521,7 @@ describe("SettingsView Apps catalog", () => {
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
],
setup: channelSetupContract("discord"),
}],
enabled_count: 0,
});
@@ -1176,6 +1531,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "discord",
display_name: "Discord",
webui: "webui/index.ts",
type: "channel",
enabled: true,
configured: true,
@@ -1184,6 +1540,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract("discord"),
}],
enabled_count: 1,
requires_restart: false,
@@ -1203,7 +1560,10 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("switch", { name: "Discord channel" })).toBeEnabled();
expect(screen.getByText("Configured manually")).toBeInTheDocument();
expect(screen.getByText("Saved")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Saved secret")).toHaveValue("");
const savedSecret = screen.getByPlaceholderText("Saved secret");
expect(savedSecret).toHaveValue("");
expect(savedSecret).toHaveAttribute("autocomplete", "off");
expect(savedSecret.closest("form")).not.toBeNull();
expect(screen.queryByDisplayValue("discord-secret-token")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Advanced"));
@@ -1235,6 +1595,7 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "telegram",
display_name: "Telegram",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
@@ -1291,6 +1652,7 @@ describe("SettingsView Apps catalog", () => {
features: channels.map(([name, displayName]) => ({
name,
display_name: displayName,
webui: ["feishu", "weixin"].includes(name) ? "webui/index.tsx" : "webui/index.ts",
type: "channel",
enabled: name === "websocket",
installed: true,
@@ -1301,6 +1663,7 @@ describe("SettingsView Apps catalog", () => {
})).concat(hiddenChannels.map(([name, displayName]) => ({
name,
display_name: displayName,
settings_visible: false,
type: "channel",
enabled: false,
installed: true,
@@ -1343,6 +1706,7 @@ describe("SettingsView Apps catalog", () => {
features: ["email", "feishu", "matrix", "qq"].map((name) => ({
name,
display_name: name === "qq" ? "QQ" : name[0].toUpperCase() + name.slice(1),
webui: name === "feishu" ? "webui/index.tsx" : "webui/index.ts",
type: "channel",
enabled: true,
installed: true,
@@ -1350,6 +1714,7 @@ describe("SettingsView Apps catalog", () => {
status: "enabled",
install_supported: true,
requires_restart: true,
setup: channelSetupContract(name as "email" | "feishu" | "matrix" | "qq"),
})),
enabled_count: 4,
});
@@ -1409,6 +1774,8 @@ describe("SettingsView Apps catalog", () => {
features: [{
name: "websocket",
display_name: "Websocket",
capabilities: ["always_enabled"],
webui: "webui/index.ts",
type: "channel",
enabled: true,
installed: true,