diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 6fbd2f754f4..6c3912b1a18 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -1,5 +1,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; +import { createPatchedAccountSetupAdapter } from "../channels/plugins/setup-helpers.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { createScopedChannelConfigAdapter } from "../plugin-sdk/channel-config-helpers.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { configMocks, offsetMocks } from "./channels.mock-harness.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; @@ -25,10 +30,283 @@ import { const runtime = createTestRuntime(); let clackPrompterModule: typeof import("../wizard/clack-prompter.js"); +type ChannelSectionConfig = { + enabled?: boolean; + name?: string; + token?: string; + botToken?: string; + appToken?: string; + account?: string; + accounts?: Record>; +}; + function formatChannelStatusJoined(channelAccounts: Record) { return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n"); } +function listConfiguredAccountIds(channelConfig: ChannelSectionConfig | undefined): string[] { + const accountIds = Object.keys(channelConfig?.accounts ?? {}); + if (accountIds.length > 0) { + return accountIds; + } + if ( + channelConfig?.token || + channelConfig?.botToken || + channelConfig?.appToken || + channelConfig?.account || + channelConfig?.name + ) { + return [DEFAULT_ACCOUNT_ID]; + } + return []; +} + +function resolveScopedAccount( + cfg: Parameters>[0], + channelKey: string, + accountId?: string | null, +): Record { + const resolvedAccountId = normalizeAccountId(accountId); + const channel = cfg.channels?.[channelKey] as ChannelSectionConfig | undefined; + const scoped = channel?.accounts?.[resolvedAccountId]; + const base = + resolvedAccountId === DEFAULT_ACCOUNT_ID + ? channel + : undefined; + return { + ...(base ?? {}), + ...(scoped ?? {}), + enabled: + typeof scoped?.enabled === "boolean" + ? scoped.enabled + : typeof channel?.enabled === "boolean" + ? channel.enabled + : true, + }; +} + +function createScopedCommandTestPlugin(params: { + id: "discord" | "signal" | "slack" | "telegram" | "whatsapp"; + label: string; + buildPatch: (input: { + token?: string; + botToken?: string; + appToken?: string; + signalNumber?: string; + }) => Record; + clearBaseFields: string[]; + onAccountConfigChanged?: NonNullable["onAccountConfigChanged"]; + onAccountRemoved?: NonNullable["onAccountRemoved"]; + collectStatusIssues?: NonNullable["collectStatusIssues"]>; +}): ChannelPlugin { + return { + ...createChannelTestPluginBase({ + id: params.id, + label: params.label, + docsPath: `/channels/${params.id}`, + }), + config: createScopedChannelConfigAdapter({ + sectionKey: params.id, + listAccountIds: (cfg) => + listConfiguredAccountIds(cfg.channels?.[params.id] as ChannelSectionConfig | undefined), + resolveAccount: (cfg, accountId) => resolveScopedAccount(cfg, params.id, accountId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + clearBaseFields: params.clearBaseFields, + resolveAllowFrom: () => [], + formatAllowFrom: (allowFrom) => allowFrom.map(String), + }), + setup: createPatchedAccountSetupAdapter({ + channelKey: params.id, + buildPatch: (input) => + params.buildPatch({ + token: input.token, + botToken: input.botToken, + appToken: input.appToken, + signalNumber: input.signalNumber, + }), + }), + lifecycle: + params.onAccountConfigChanged || params.onAccountRemoved + ? { + ...(params.onAccountConfigChanged + ? { onAccountConfigChanged: params.onAccountConfigChanged } + : {}), + ...(params.onAccountRemoved ? { onAccountRemoved: params.onAccountRemoved } : {}), + } + : undefined, + status: params.collectStatusIssues + ? { + collectStatusIssues: params.collectStatusIssues, + } + : undefined, + } as ChannelPlugin; +} + +function createTelegramCommandTestPlugin(): ChannelPlugin { + const resolveTelegramAccount = ( + cfg: Parameters>[0], + accountId?: string | null, + ) => resolveScopedAccount(cfg, "telegram", accountId) as { botToken?: string }; + + return createScopedCommandTestPlugin({ + id: "telegram", + label: "Telegram", + buildPatch: ({ token }) => (token ? { botToken: token } : {}), + clearBaseFields: ["botToken", "name", "dmPolicy", "allowFrom", "groupPolicy", "streaming"], + onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { + const prevTelegram = resolveTelegramAccount(prevCfg, accountId); + const nextTelegram = resolveTelegramAccount(nextCfg, accountId); + if ((prevTelegram.botToken ?? "").trim() !== (nextTelegram.botToken ?? "").trim()) { + await offsetMocks.deleteTelegramUpdateOffset({ accountId }); + } + }, + onAccountRemoved: async ({ accountId }) => { + await offsetMocks.deleteTelegramUpdateOffset({ accountId }); + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + if (account.enabled !== true || account.configured !== true) { + return []; + } + const issues = []; + if (account.allowUnmentionedGroups === true) { + issues.push({ + channel: "telegram", + accountId: String(account.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "config", + message: + "Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.", + }); + } + const audit = account.audit as + | { + hasWildcardUnmentionedGroups?: boolean; + groups?: Array<{ chatId?: string; ok?: boolean; status?: string; error?: string }>; + } + | undefined; + if (audit?.hasWildcardUnmentionedGroups === true) { + issues.push({ + channel: "telegram", + accountId: String(account.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "config", + message: + 'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.', + }); + } + for (const group of audit?.groups ?? []) { + if (group.ok === true || !group.chatId) { + continue; + } + issues.push({ + channel: "telegram", + accountId: String(account.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "runtime", + message: `Group ${group.chatId} not reachable by bot.${group.status ? ` status=${group.status}` : ""}${group.error ? `: ${group.error}` : ""}`, + }); + } + return issues; + }), + }); +} + +function setMinimalChannelsCommandRegistryForTests(): void { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createTelegramCommandTestPlugin(), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createScopedCommandTestPlugin({ + id: "whatsapp", + label: "WhatsApp", + buildPatch: () => ({}), + clearBaseFields: ["name"], + }), + source: "test", + }, + { + pluginId: "discord", + plugin: createScopedCommandTestPlugin({ + id: "discord", + label: "Discord", + buildPatch: ({ token }) => (token ? { token } : {}), + clearBaseFields: ["token", "name"], + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + if (account.enabled !== true || account.configured !== true) { + return []; + } + const issues = []; + const messageContent = ( + account.application as + | { intents?: { messageContent?: string } } + | undefined + )?.intents?.messageContent; + if (messageContent === "disabled") { + issues.push({ + channel: "discord", + accountId: String(account.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "intent", + message: + "Message Content Intent is disabled. Bot may not see normal channel messages.", + }); + } + const audit = account.audit as + | { + channels?: Array<{ + channelId?: string; + ok?: boolean; + missing?: string[]; + error?: string; + }>; + } + | undefined; + for (const channel of audit?.channels ?? []) { + if (channel.ok === true || !channel.channelId) { + continue; + } + issues.push({ + channel: "discord", + accountId: String(account.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "permissions", + message: `Channel ${channel.channelId} permission audit failed.${channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""}${channel.error ? `: ${channel.error}` : ""}`, + }); + } + return issues; + }), + }), + source: "test", + }, + { + pluginId: "slack", + plugin: createScopedCommandTestPlugin({ + id: "slack", + label: "Slack", + buildPatch: ({ botToken, appToken }) => ({ + ...(botToken ? { botToken } : {}), + ...(appToken ? { appToken } : {}), + }), + clearBaseFields: ["botToken", "appToken", "name"], + }), + source: "test", + }, + { + pluginId: "signal", + plugin: createScopedCommandTestPlugin({ + id: "signal", + label: "Signal", + buildPatch: ({ signalNumber }) => (signalNumber ? { account: signalNumber } : {}), + clearBaseFields: ["account", "name"], + }), + source: "test", + }, + ]), + ); +} + describe("channels command", () => { beforeAll(async () => { clackPrompterModule = await import("../wizard/clack-prompter.js"); @@ -46,7 +324,7 @@ describe("channels command", () => { version: 1, profiles: {}, }); - setDefaultChannelPluginRegistryForTests(); + setMinimalChannelsCommandRegistryForTests(); }); function getWrittenConfig(): T {