fix(config): write built-in channel enabled state to channels, not plugins.entries

Built-in channels (telegram, slack, discord, etc.) were being auto-enabled
via `plugins.entries.<channel>.enabled`, which fails config validation
because these channels are not plugins.

Now built-in channels are enabled via `channels.<channel>.enabled` and
plugin channels continue to use `plugins.entries.<plugin>.enabled`.

This also means built-in channels no longer need to be added to
`plugins.allow` since they're not loaded through the plugin system.

Fixes #3741
This commit is contained in:
HirokiKobayashi-R 2026-01-29 17:07:15 +09:00
parent 6372242da7
commit 253f6cff98
2 changed files with 90 additions and 17 deletions

View File

@ -11,21 +11,27 @@ describe("applyPluginAutoEnable", () => {
env: {}, env: {},
}); });
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true); // Built-in channels (slack) are enabled via channels.<id>.enabled, not plugins.entries.
expect(result.config.plugins?.allow).toEqual(["telegram", "slack"]); expect((result.config.channels as Record<string, unknown>)?.slack).toMatchObject({
enabled: true,
});
// Built-in channels don't need plugins.allow entry.
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.changes.join("\n")).toContain("Slack configured, not enabled yet."); expect(result.changes.join("\n")).toContain("Slack configured, not enabled yet.");
}); });
it("respects explicit disable", () => { it("respects explicit disable", () => {
const result = applyPluginAutoEnable({ const result = applyPluginAutoEnable({
config: { config: {
channels: { slack: { botToken: "x" } }, channels: { slack: { botToken: "x", enabled: false } },
plugins: { entries: { slack: { enabled: false } } },
}, },
env: {}, env: {},
}); });
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false); // Built-in channels check enabled in channels.<id>.enabled.
expect((result.config.channels as Record<string, unknown>)?.slack).toMatchObject({
enabled: false,
});
expect(result.changes).toEqual([]); expect(result.changes).toEqual([]);
}); });
@ -72,8 +78,12 @@ describe("applyPluginAutoEnable", () => {
env: {}, env: {},
}); });
// bluebubbles is a plugin channel, so it goes to plugins.entries.
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined(); // imessage is a built-in channel, but it's skipped due to preferOver.
expect((result.config.channels as Record<string, unknown>)?.imessage).not.toMatchObject({
enabled: true,
});
expect(result.changes.join("\n")).toContain("bluebubbles configured, not enabled yet."); expect(result.changes.join("\n")).toContain("bluebubbles configured, not enabled yet.");
expect(result.changes.join("\n")).not.toContain("iMessage configured, not enabled yet."); expect(result.changes.join("\n")).not.toContain("iMessage configured, not enabled yet.");
}); });
@ -83,15 +93,17 @@ describe("applyPluginAutoEnable", () => {
config: { config: {
channels: { channels: {
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" }, bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
imessage: { cliPath: "/usr/local/bin/imsg" }, imessage: { cliPath: "/usr/local/bin/imsg", enabled: true },
}, },
plugins: { entries: { imessage: { enabled: true } } },
}, },
env: {}, env: {},
}); });
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); // imessage was already enabled in channels, stays enabled.
expect((result.config.channels as Record<string, unknown>)?.imessage).toMatchObject({
enabled: true,
});
}); });
it("allows imessage auto-enable when bluebubbles is explicitly disabled", () => { it("allows imessage auto-enable when bluebubbles is explicitly disabled", () => {
@ -107,7 +119,10 @@ describe("applyPluginAutoEnable", () => {
}); });
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); // imessage is a built-in channel, so it goes to channels.imessage.enabled.
expect((result.config.channels as Record<string, unknown>)?.imessage).toMatchObject({
enabled: true,
});
expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet.");
}); });
@ -124,7 +139,10 @@ describe("applyPluginAutoEnable", () => {
}); });
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined(); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined();
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); // imessage is a built-in channel, so it goes to channels.imessage.enabled.
expect((result.config.channels as Record<string, unknown>)?.imessage).toMatchObject({
enabled: true,
});
}); });
it("enables imessage normally when only imessage is configured", () => { it("enables imessage normally when only imessage is configured", () => {
@ -135,7 +153,10 @@ describe("applyPluginAutoEnable", () => {
env: {}, env: {},
}); });
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); // imessage is a built-in channel, so it goes to channels.imessage.enabled.
expect((result.config.channels as Record<string, unknown>)?.imessage).toMatchObject({
enabled: true,
});
expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet.");
}); });
}); });

View File

@ -270,10 +270,28 @@ function resolveConfiguredPlugins(
} }
function isPluginExplicitlyDisabled(cfg: MoltbotConfig, pluginId: string): boolean { function isPluginExplicitlyDisabled(cfg: MoltbotConfig, pluginId: string): boolean {
// Built-in channels use channels.<id>.enabled, not plugins.entries.
const builtinChannelId = normalizeChatChannelId(pluginId);
if (builtinChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[builtinChannelId];
return isRecord(entry) && entry.enabled === false;
}
const entry = cfg.plugins?.entries?.[pluginId]; const entry = cfg.plugins?.entries?.[pluginId];
return entry?.enabled === false; return entry?.enabled === false;
} }
function isPluginAlreadyEnabled(cfg: MoltbotConfig, pluginId: string): boolean {
// Built-in channels use channels.<id>.enabled, not plugins.entries.
const builtinChannelId = normalizeChatChannelId(pluginId);
if (builtinChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[builtinChannelId];
return isRecord(entry) && entry.enabled === true;
}
return cfg.plugins?.entries?.[pluginId]?.enabled === true;
}
function isPluginDenied(cfg: MoltbotConfig, pluginId: string): boolean { function isPluginDenied(cfg: MoltbotConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny; const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId); return Array.isArray(deny) && deny.includes(pluginId);
@ -317,7 +335,29 @@ function ensureAllowlisted(cfg: MoltbotConfig, pluginId: string): MoltbotConfig
}; };
} }
function enableBuiltinChannel(cfg: MoltbotConfig, channelId: string): MoltbotConfig {
const channels = cfg.channels as Record<string, unknown> | undefined;
const existingEntry = channels?.[channelId];
const channelEntry = isRecord(existingEntry) ? existingEntry : {};
return {
...cfg,
channels: {
...channels,
[channelId]: {
...channelEntry,
enabled: true,
},
},
};
}
function enablePluginEntry(cfg: MoltbotConfig, pluginId: string): MoltbotConfig { function enablePluginEntry(cfg: MoltbotConfig, pluginId: string): MoltbotConfig {
// Built-in channels should be enabled via channels.<id>.enabled, not plugins.entries.
const builtinChannelId = normalizeChatChannelId(pluginId);
if (builtinChannelId) {
return enableBuiltinChannel(cfg, builtinChannelId);
}
const entries = { const entries = {
...cfg.plugins?.entries, ...cfg.plugins?.entries,
[pluginId]: { [pluginId]: {
@ -366,12 +406,24 @@ export function applyPluginAutoEnable(params: {
if (isPluginDenied(next, entry.pluginId)) continue; if (isPluginDenied(next, entry.pluginId)) continue;
if (isPluginExplicitlyDisabled(next, entry.pluginId)) continue; if (isPluginExplicitlyDisabled(next, entry.pluginId)) continue;
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured)) continue; if (shouldSkipPreferredPluginAutoEnable(next, entry, configured)) continue;
const allow = next.plugins?.allow;
const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId); const isBuiltinChannel = normalizeChatChannelId(entry.pluginId) !== null;
const alreadyEnabled = next.plugins?.entries?.[entry.pluginId]?.enabled === true; const alreadyEnabled = isPluginAlreadyEnabled(next, entry.pluginId);
if (alreadyEnabled && !allowMissing) continue;
// For plugin channels, also check plugins.allow list.
if (!isBuiltinChannel) {
const allow = next.plugins?.allow;
const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId);
if (alreadyEnabled && !allowMissing) continue;
} else {
if (alreadyEnabled) continue;
}
next = enablePluginEntry(next, entry.pluginId); next = enablePluginEntry(next, entry.pluginId);
next = ensureAllowlisted(next, entry.pluginId); // Built-in channels don't need plugins.allow entry.
if (!isBuiltinChannel) {
next = ensureAllowlisted(next, entry.pluginId);
}
changes.push(formatAutoEnableChange(entry)); changes.push(formatAutoEnableChange(entry));
} }