diff --git a/docs/plugin.md b/docs/plugin.md index 0fec66e92..9b1882f29 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -165,6 +165,45 @@ Plugins export either: - A function: `(api) => { ... }` - An object: `{ id, name, configSchema, register(api) { ... } }` +### Register a messaging channel + +Plugins can register **channel plugins** that behave like built‑in channels +(WhatsApp, Telegram, etc.). Channel config lives under `channels.` and is +validated by your channel plugin code. + +```ts +const myChannel = { + id: "acmechat", + meta: { + id: "acmechat", + label: "AcmeChat", + selectionLabel: "AcmeChat (API)", + docsPath: "/channels/acmechat", + blurb: "demo channel plugin.", + aliases: ["acme"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}), + resolveAccount: (cfg, accountId) => + (cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }), + }, + outbound: { + deliveryMode: "direct", + sendText: async () => ({ ok: true }), + }, +}; + +export default function (api) { + api.registerChannel({ plugin: myChannel }); +} +``` + +Notes: +- Put config under `channels.` (not `plugins.entries`). +- `meta.label` is used for labels in CLI/UI lists. +- `meta.aliases` adds alternate ids for normalization and CLI inputs. + ### Register a tool ```ts diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index cdb0c3453..ea6ff99d2 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,10 +1,8 @@ import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { CHANNEL_IDS } from "../channels/registry.js"; +import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; -const MESSAGE_CHANNEL_OPTIONS = CHANNEL_IDS.join("|"); - export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; @@ -169,6 +167,7 @@ export function buildAgentSystemPrompt(params: { .filter(Boolean); const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); + const messageChannelOptions = listDeliverableMessageChannels().join("|"); const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsSection = skillsPrompt ? [ @@ -319,7 +318,7 @@ export function buildAgentSystemPrompt(params: { "### message tool", "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", "- For `action=send`, include `to` and `message`.", - `- If multiple channels are configured, pass \`channel\` (${MESSAGE_CHANNEL_OPTIONS}).`, + `- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`, inlineButtonsEnabled ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." : runtimeChannel diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index de4b6086e..e3509a465 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -1,7 +1,7 @@ import type { ChannelDock } from "../channels/dock.js"; import { getChannelDock, listChannelDocks } from "../channels/dock.js"; import type { ChannelId } from "../channels/plugins/types.js"; -import { normalizeChannelId } from "../channels/registry.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { MsgContext } from "./templating.js"; diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index f5a802a77..5f5d1ca18 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,7 +1,7 @@ import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index ee8ccc11e..52d114500 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -1,23 +1,24 @@ import { getChannelDock } from "../../channels/dock.js"; -import { CHANNEL_IDS, normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import { + INTERNAL_MESSAGE_CHANNEL, + listDeliverableMessageChannels, +} from "../../utils/message-channel.js"; import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; -const BLOCK_CHUNK_PROVIDERS = new Set([ - ...CHANNEL_IDS, - INTERNAL_MESSAGE_CHANNEL, -]); +const getBlockChunkProviders = () => + new Set([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]); function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined { if (!provider) return undefined; const cleaned = provider.trim().toLowerCase(); - return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider) + return getBlockChunkProviders().has(cleaned as TextChunkProvider) ? (cleaned as TextChunkProvider) : undefined; } diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index be9c5edc8..eb0302dcf 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,5 +1,5 @@ import { getChannelDock } from "../../channels/dock.js"; -import { getChatChannelMeta, normalizeChannelId } from "../../channels/registry.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; @@ -50,7 +50,7 @@ export function buildGroupIntro(params: { const providerLabel = (() => { if (!providerKey) return "chat"; if (isInternalMessageChannel(providerKey)) return "WebChat"; - if (providerId) return getChatChannelMeta(providerId).label; + if (providerId) return getChannelPlugin(providerId)?.meta.label ?? providerId; return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; })(); const subjectLine = subject diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index de637156e..50278c82b 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,6 +1,6 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; diff --git a/src/auto-reply/reply/reply-elevated.ts b/src/auto-reply/reply/reply-elevated.ts index fc7bd7c86..40c222f89 100644 --- a/src/auto-reply/reply/reply-elevated.ts +++ b/src/auto-reply/reply/reply-elevated.ts @@ -1,6 +1,7 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { getChannelDock } from "../../channels/dock.js"; -import { CHAT_CHANNEL_ORDER, normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { CHAT_CHANNEL_ORDER } from "../../channels/registry.js"; import type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { MsgContext } from "../templating.js"; diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index 6398bedbc..fc9a0f2cd 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -1,5 +1,5 @@ import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { ReplyToMode } from "../../config/types.js"; import type { OriginatingChannelType } from "../templating.js"; diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index ce4de8219..4a2b19e47 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -9,7 +9,7 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6aa08d2e1..e8b873161 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { buildGroupDisplayName, diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 495c65a3c..908903dc2 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -7,6 +7,7 @@ import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, @@ -21,9 +22,10 @@ import type { ChannelGroupAdapter, ChannelId, ChannelMentionAdapter, + ChannelPlugin, ChannelThreadingAdapter, } from "./plugins/types.js"; -import { CHAT_CHANNEL_ORDER } from "./registry.js"; +import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js"; export type ChannelDock = { id: ChannelId; @@ -75,7 +77,7 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\ // Adding a channel: // - add a new entry to `DOCKS` // - keep it cheap; push heavy logic into `src/channels/plugins/.ts` or channel modules -const DOCKS: Record = { +const DOCKS: Record = { telegram: { id: "telegram", capabilities: { @@ -311,10 +313,71 @@ const DOCKS: Record = { }, }; +function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { + return { + id: plugin.id, + capabilities: plugin.capabilities, + commands: plugin.commands, + outbound: plugin.outbound?.textChunkLimit + ? { textChunkLimit: plugin.outbound.textChunkLimit } + : undefined, + streaming: plugin.streaming + ? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults } + : undefined, + elevated: plugin.elevated, + config: plugin.config + ? { + resolveAllowFrom: plugin.config.resolveAllowFrom, + formatAllowFrom: plugin.config.formatAllowFrom, + } + : undefined, + groups: plugin.groups, + mentions: plugin.mentions, + threading: plugin.threading, + }; +} + +function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { + const registry = getActivePluginRegistry(); + if (!registry) return []; + const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = []; + const seen = new Set(); + for (const entry of registry.channels) { + const plugin = entry.plugin; + const id = String(plugin.id).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) continue; + const dock = entry.dock ?? buildDockFromPlugin(plugin); + entries.push({ id: plugin.id, dock, order: plugin.meta.order }); + } + return entries; +} + export function listChannelDocks(): ChannelDock[] { - return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]); + const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({ + id, + dock: DOCKS[id], + order: getChatChannelMeta(id).order, + })); + const pluginEntries = listPluginDockEntries(); + const combined = [...baseEntries, ...pluginEntries]; + combined.sort((a, b) => { + const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); + const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); + const orderA = a.order ?? (indexA === -1 ? 999 : indexA); + const orderB = b.order ?? (indexB === -1 ? 999 : indexB); + if (orderA !== orderB) return orderA - orderB; + return String(a.id).localeCompare(String(b.id)); + }); + return combined.map((entry) => entry.dock); } export function getChannelDock(id: ChannelId): ChannelDock | undefined { - return DOCKS[id]; + const core = DOCKS[id as ChatChannelId]; + if (core) return core; + const registry = getActivePluginRegistry(); + const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); + if (!pluginEntry) return undefined; + return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin); } diff --git a/src/channels/plugins/index.test.ts b/src/channels/plugins/index.test.ts index 91f816edd..1afd020db 100644 --- a/src/channels/plugins/index.test.ts +++ b/src/channels/plugins/index.test.ts @@ -3,12 +3,10 @@ import { CHANNEL_IDS } from "../registry.js"; import { listChannelPlugins } from "./index.js"; describe("channel plugin registry", () => { - it("stays in sync with channel ids", () => { - const pluginIds = listChannelPlugins() - .map((plugin) => plugin.id) - .slice() - .sort(); - const channelIds = [...CHANNEL_IDS].slice().sort(); - expect(pluginIds).toEqual(channelIds); + it("includes the built-in channel ids", () => { + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + for (const id of CHANNEL_IDS) { + expect(pluginIds).toContain(id); + } }); }); diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 6b4043e67..06d176220 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -7,6 +7,7 @@ import { slackPlugin } from "./slack.js"; import { telegramPlugin } from "./telegram.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; import { whatsappPlugin } from "./whatsapp.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; // Channel plugins registry (runtime). // @@ -18,7 +19,7 @@ import { whatsappPlugin } from "./whatsapp.js"; // - add `Plugin` import + entry in `resolveChannels()` // - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …) // - add ids/aliases in `src/channels/registry.ts` -function resolveChannels(): ChannelPlugin[] { +function resolveCoreChannels(): ChannelPlugin[] { return [ telegramPlugin, whatsappPlugin, @@ -30,8 +31,27 @@ function resolveChannels(): ChannelPlugin[] { ]; } +function listPluginChannels(): ChannelPlugin[] { + const registry = getActivePluginRegistry(); + if (!registry) return []; + return registry.channels.map((entry) => entry.plugin); +} + +function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] { + const seen = new Set(); + const resolved: ChannelPlugin[] = []; + for (const plugin of channels) { + const id = String(plugin.id).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + resolved.push(plugin); + } + return resolved; +} + export function listChannelPlugins(): ChannelPlugin[] { - return resolveChannels().sort((a, b) => { + const combined = dedupeChannels([...resolveCoreChannels(), ...listPluginChannels()]); + return combined.sort((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); @@ -42,13 +62,24 @@ export function listChannelPlugins(): ChannelPlugin[] { } export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - return resolveChannels().find((plugin) => plugin.id === id); + const resolvedId = String(id).trim(); + if (!resolvedId) return undefined; + return listChannelPlugins().find((plugin) => plugin.id === resolvedId); } export function normalizeChannelId(raw?: string | null): ChannelId | null { // Channel docking: keep input normalization centralized in src/channels/registry.ts // so CLI/API/protocol can rely on stable aliases without plugin init side effects. - return normalizeChatChannelId(raw); + const normalized = normalizeChatChannelId(raw); + if (normalized) return normalized; + const trimmed = raw?.trim(); + if (!trimmed) return null; + const key = trimmed.toLowerCase(); + const plugin = listChannelPlugins().find((entry) => { + if (entry.id.toLowerCase() === key) return true; + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key); + }); + return plugin?.id ?? null; } export { diff --git a/src/channels/plugins/load.ts b/src/channels/plugins/load.ts index 089efeafb..18e3a9398 100644 --- a/src/channels/plugins/load.ts +++ b/src/channels/plugins/load.ts @@ -1,4 +1,6 @@ import type { ChannelId, ChannelPlugin } from "./types.js"; +import type { ChatChannelId } from "../registry.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; type PluginLoader = () => Promise; @@ -6,7 +8,7 @@ type PluginLoader = () => Promise; // // This avoids importing `src/channels/plugins/index.ts` (intentionally heavy) // from shared flows like outbound delivery / followup routing. -const LOADERS: Record = { +const LOADERS: Record = { telegram: async () => (await import("./telegram.js")).telegramPlugin, whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin, discord: async () => (await import("./discord.js")).discordPlugin, @@ -21,7 +23,13 @@ const cache = new Map(); export async function loadChannelPlugin(id: ChannelId): Promise { const cached = cache.get(id); if (cached) return cached; - const loader = LOADERS[id]; + const registry = getActivePluginRegistry(); + const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); + if (pluginEntry) { + cache.set(id, pluginEntry.plugin); + return pluginEntry.plugin; + } + const loader = LOADERS[id as ChatChannelId]; if (!loader) return undefined; const plugin = await loader(); cache.set(id, plugin); diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 9c5591647..bf73fa11b 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -2,23 +2,23 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { DmPolicy } from "../../config/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import type { ChatChannelId } from "../registry.js"; +import type { ChannelId } from "./types.js"; export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; - onSelection?: (selection: ChatChannelId[]) => void; - accountIds?: Partial>; - onAccountId?: (channel: ChatChannelId, accountId: string) => void; + onSelection?: (selection: ChannelId[]) => void; + accountIds?: Partial>; + onAccountId?: (channel: ChannelId, accountId: string) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; onWhatsAppAccountId?: (accountId: string) => void; - forceAllowFromChannels?: ChatChannelId[]; + forceAllowFromChannels?: ChannelId[]; skipDmPolicyPrompt?: boolean; skipConfirm?: boolean; quickstartDefaults?: boolean; - initialSelection?: ChatChannelId[]; + initialSelection?: ChannelId[]; }; export type PromptAccountIdParams = { @@ -33,7 +33,7 @@ export type PromptAccountIdParams = { export type PromptAccountId = (params: PromptAccountIdParams) => Promise; export type ChannelOnboardingStatus = { - channel: ChatChannelId; + channel: ChannelId; configured: boolean; statusLines: string[]; selectionHint?: string; @@ -43,7 +43,7 @@ export type ChannelOnboardingStatus = { export type ChannelOnboardingStatusContext = { cfg: ClawdbotConfig; options?: SetupChannelsOptions; - accountOverrides: Partial>; + accountOverrides: Partial>; }; export type ChannelOnboardingConfigureContext = { @@ -51,7 +51,7 @@ export type ChannelOnboardingConfigureContext = { runtime: RuntimeEnv; prompter: WizardPrompter; options?: SetupChannelsOptions; - accountOverrides: Partial>; + accountOverrides: Partial>; shouldPromptAccountIds: boolean; forceAllowFrom: boolean; }; @@ -63,7 +63,7 @@ export type ChannelOnboardingResult = { export type ChannelOnboardingDmPolicy = { label: string; - channel: ChatChannelId; + channel: ChannelId; policyKey: string; allowFromKey: string; getCurrent: (cfg: ClawdbotConfig) => DmPolicy; @@ -71,7 +71,7 @@ export type ChannelOnboardingDmPolicy = { }; export type ChannelOnboardingAdapter = { - channel: ChatChannelId; + channel: ChannelId; getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; configure: (ctx: ChannelOnboardingConfigureContext) => Promise; dmPolicy?: ChannelOnboardingDmPolicy; diff --git a/src/channels/plugins/outbound/load.ts b/src/channels/plugins/outbound/load.ts index 99cb993a4..d227cad66 100644 --- a/src/channels/plugins/outbound/load.ts +++ b/src/channels/plugins/outbound/load.ts @@ -1,4 +1,6 @@ import type { ChannelId, ChannelOutboundAdapter } from "../types.js"; +import type { ChatChannelId } from "../../registry.js"; +import { getActivePluginRegistry } from "../../../plugins/runtime.js"; type OutboundLoader = () => Promise; @@ -7,7 +9,7 @@ type OutboundLoader = () => Promise; // The full channel plugins (src/channels/plugins/*.ts) pull in status, // onboarding, gateway monitors, etc. Outbound delivery only needs chunking + // send primitives, so we keep a dedicated, lightweight loader here. -const LOADERS: Record = { +const LOADERS: Record = { telegram: async () => (await import("./telegram.js")).telegramOutbound, whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound, discord: async () => (await import("./discord.js")).discordOutbound, @@ -24,9 +26,16 @@ export async function loadChannelOutboundAdapter( ): Promise { const cached = cache.get(id); if (cached) return cached; - const loader = LOADERS[id]; + const registry = getActivePluginRegistry(); + const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); + const outbound = pluginEntry?.plugin.outbound; + if (outbound) { + cache.set(id, outbound); + return outbound; + } + const loader = LOADERS[id as ChatChannelId]; if (!loader) return undefined; - const outbound = await loader(); - cache.set(id, outbound); - return outbound; + const loaded = await loader(); + cache.set(id, loaded); + return loaded; } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index c15b8743b..db14ab48c 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -7,7 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c import type { ChatChannelId } from "../registry.js"; import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js"; -export type ChannelId = ChatChannelId; +export type ChannelId = ChatChannelId | (string & {}); export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; @@ -62,6 +62,10 @@ export type ChannelMeta = { docsLabel?: string; blurb: string; order?: number; + aliases?: string[]; + selectionDocsPrefix?: string; + selectionDocsOmitLabel?: boolean; + selectionExtras?: string[]; showConfigured?: boolean; quickstartAllowFrom?: boolean; forceAccountBinding?: boolean; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index a7b9a607a..4c10a59df 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,3 +1,5 @@ +import type { ChannelMeta } from "./plugins/types.js"; + // Channel docking: add new channels here (order + meta + aliases), then // register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync. export const CHAT_CHANNEL_ORDER = [ @@ -16,23 +18,11 @@ export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const; export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp"; -export type ChatChannelMeta = { - id: ChatChannelId; - label: string; - selectionLabel: string; - docsPath: string; - docsLabel?: string; - blurb: string; - // Channel docking: selection-line formatting for onboarding prompts. - // Keep this data-driven to avoid channel-specific branches in shared code. - selectionDocsPrefix?: string; - selectionDocsOmitLabel?: boolean; - selectionExtras?: string[]; -}; +export type ChatChannelMeta = ChannelMeta; const WEBSITE_URL = "https://clawd.bot"; -const CHAT_CHANNEL_META: Record = { +const CHAT_CHANNEL_META: Record = { telegram: { id: "telegram", label: "Telegram", diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 6e481950a..819f56597 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { listChatChannels } from "../channels/registry.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; import { channelsAddCommand, channelsListCommand, @@ -36,11 +36,10 @@ const optionNamesAdd = [ const optionNamesRemove = ["channel", "account", "delete"] as const; -const channelNames = listChatChannels() - .map((meta) => meta.id) - .join("|"); - export function registerChannelsCli(program: Command) { + const channelNames = listChannelPlugins() + .map((plugin) => plugin.id) + .join("|"); const channels = program .command("channels") .description("Manage chat channel accounts") diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 39408e442..a24d4dfec 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -7,7 +7,7 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { - CRON_CHANNEL_OPTIONS, + getCronChannelOptions, parseAtMs, parseDurationMs, printCronList, @@ -81,7 +81,7 @@ export function registerCronAddCommand(cron: Command) { .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) - .option("--channel ", `Delivery channel (${CRON_CHANNEL_OPTIONS})`, "last") + .option("--channel ", `Delivery channel (${getCronChannelOptions()})`, "last") .option( "--to ", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 5e683b177..9f7df0213 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -4,7 +4,7 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { - CRON_CHANNEL_OPTIONS, + getCronChannelOptions, parseAtMs, parseDurationMs, warnIfCronSchedulerDisabled, @@ -36,7 +36,7 @@ export function registerCronEditCommand(cron: Command) { .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) - .option("--channel ", `Delivery channel (${CRON_CHANNEL_OPTIONS})`) + .option("--channel ", `Delivery channel (${getCronChannelOptions()})`) .option( "--to ", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index ea4154bdc..5e5efc81a 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -1,4 +1,4 @@ -import { CHANNEL_IDS } from "../../channels/registry.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import type { CronJob, CronSchedule } from "../../cron/types.js"; import { defaultRuntime } from "../../runtime.js"; @@ -6,7 +6,8 @@ import { colorize, isRich, theme } from "../../terminal/theme.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; -export const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|"); +export const getCronChannelOptions = () => + ["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|"); export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { diff --git a/src/cli/program/context.ts b/src/cli/program/context.ts index 13a22769a..f4e501068 100644 --- a/src/cli/program/context.ts +++ b/src/cli/program/context.ts @@ -1,4 +1,8 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { loadConfig } from "../../config/config.js"; +import { createSubsystemLogger } from "../../logging.js"; +import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { VERSION } from "../../version.js"; export type ProgramContext = { @@ -8,7 +12,25 @@ export type ProgramContext = { agentChannelOptions: string; }; +const log = createSubsystemLogger("plugins"); + +function primePluginRegistry() { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + loadClawdbotPlugins({ + config, + workspaceDir, + logger: { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }, + }); +} + export function createProgramContext(): ProgramContext { + primePluginRegistry(); const channelOptions = listChannelPlugins().map((plugin) => plugin.id); return { programVersion: VERSION, diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index c5aaa0a22..445bc7a7d 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -1,7 +1,6 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; -import type { ChatChannelId } from "../channels/registry.js"; -import { normalizeChatChannelId } from "../channels/registry.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { AgentBinding } from "../config/types.js"; import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; @@ -82,7 +81,7 @@ export function applyAgentBindings( }; } -function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string { +function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string { const plugin = getChannelPlugin(provider); if (!plugin) return DEFAULT_ACCOUNT_ID; return resolveChannelDefaultAccountId({ plugin, cfg }); @@ -125,7 +124,7 @@ export function parseBindingSpecs(params: { const trimmed = raw?.trim(); if (!trimmed) continue; const [channelRaw, accountRaw] = trimmed.split(":", 2); - const channel = normalizeChatChannelId(channelRaw); + const channel = normalizeChannelId(channelRaw); if (!channel) { errors.push(`Unknown channel "${channelRaw}".`); continue; diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index aa2ff00d1..bc1c85ba9 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -1,13 +1,12 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChatChannelId } from "../channels/registry.js"; -import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js"; +import { getChannelPlugin, listChannelPlugins, normalizeChannelId } from "../channels/plugins/index.js"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { AgentBinding } from "../config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; type ProviderAccountStatus = { - provider: ChatChannelId; + provider: ChannelId; accountId: string; name?: string; state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled"; @@ -15,16 +14,16 @@ type ProviderAccountStatus = { configured?: boolean; }; -function providerAccountKey(provider: ChatChannelId, accountId?: string) { +function providerAccountKey(provider: ChannelId, accountId?: string) { return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`; } function formatChannelAccountLabel(params: { - provider: ChatChannelId; + provider: ChannelId; accountId: string; name?: string; }): string { - const label = getChatChannelMeta(params.provider).label; + const label = getChannelPlugin(params.provider)?.meta.label ?? params.provider; const account = params.name?.trim() ? `${params.accountId} (${params.name.trim()})` : params.accountId; @@ -88,7 +87,7 @@ export async function buildProviderStatusIndex( return map; } -function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string { +function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string { const plugin = getChannelPlugin(provider); if (!plugin) return DEFAULT_ACCOUNT_ID; return resolveChannelDefaultAccountId({ plugin, cfg }); @@ -117,7 +116,7 @@ export function summarizeBindings(cfg: ClawdbotConfig, bindings: AgentBinding[]) if (bindings.length === 0) return []; const seen = new Map(); for (const binding of bindings) { - const channel = normalizeChatChannelId(binding.match.channel); + const channel = normalizeChannelId(binding.match.channel); if (!channel) continue; const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel); const key = providerAccountKey(channel, accountId); @@ -143,7 +142,7 @@ export function listProvidersForAgent(params: { if (params.bindings.length > 0) { const seen = new Set(); for (const binding of params.bindings) { - const channel = normalizeChatChannelId(binding.match.channel); + const channel = normalizeChannelId(binding.match.channel); if (!channel) continue; const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel); const key = providerAccountKey(channel, accountId); diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index 5bcdac5a8..9474f2479 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -15,12 +15,14 @@ type LogLine = ReturnType; const DEFAULT_LIMIT = 200; const MAX_BYTES = 1_000_000; -const CHANNELS = new Set([...listChannelPlugins().map((plugin) => plugin.id), "all"]); + +const getChannelSet = () => + new Set([...listChannelPlugins().map((plugin) => plugin.id), "all"]); function parseChannelFilter(raw?: string) { const trimmed = raw?.trim().toLowerCase(); if (!trimmed) return "all"; - return CHANNELS.has(trimmed) ? trimmed : "all"; + return getChannelSet().has(trimmed) ? trimmed : "all"; } function matchesChannel(line: NonNullable, channel: string) { diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index 3fe5a3d3f..649dbc2ae 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -1,4 +1,4 @@ -import { listChatChannels } from "../channels/registry.js"; +import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -13,7 +13,9 @@ export async function removeChannelConfigWizard( let next = { ...cfg }; const listConfiguredChannels = () => - listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined); + listChannelPlugins() + .map((plugin) => plugin.meta) + .filter((meta) => next.channels?.[meta.id] !== undefined); while (true) { const configured = listConfiguredChannels(); @@ -45,7 +47,7 @@ export async function removeChannelConfigWizard( if (channel === "done") return next; - const label = listChatChannels().find((meta) => meta.id === channel)?.label ?? channel; + const label = getChannelPlugin(channel)?.meta.label ?? channel; const confirmed = guardCancel( await confirm({ message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 762df12c2..aba02ce13 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,9 +1,5 @@ -import { - formatChannelPrimerLine, - formatChannelSelectionLine, - getChatChannelMeta, - listChatChannels, -} from "../channels/registry.js"; +import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js"; +import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -17,7 +13,7 @@ import { import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js"; async function noteChannelPrimer(prompter: WizardPrompter): Promise { - const channelLines = listChatChannels().map((meta) => formatChannelPrimerLine(meta)); + const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta)); await prompter.note( [ "DM security: default is pairing; unknown DMs get a pairing code.", @@ -130,11 +126,12 @@ export async function setupChannels( await noteChannelPrimer(prompter); - const selectionOptions = listChatChannels().map((meta) => { + const selectionOptions = listChannelPlugins().map((plugin) => { + const meta = plugin.meta; const status = statusByChannel.get(meta.id as ChannelChoice); return { value: meta.id, - label: meta.selectionLabel, + label: meta.selectionLabel ?? meta.label, ...(status?.selectionHint ? { hint: status.selectionHint } : {}), }; }); @@ -169,7 +166,10 @@ export async function setupChannels( options?.onSelection?.(selection); const selectionNotes = new Map( - listChatChannels().map((meta) => [meta.id, formatChannelSelectionLine(meta, formatDocsLink)]), + listChannelPlugins().map((plugin) => [ + plugin.id, + formatChannelSelectionLine(plugin.meta, formatDocsLink), + ]), ); const selectedLines = selection .map((channel) => selectionNotes.get(channel)) @@ -214,9 +214,9 @@ export async function setupChannels( if (!status.configured) continue; const adapter = getChannelOnboardingAdapter(channelId); if (!adapter?.disable) continue; - const meta = getChatChannelMeta(channelId); + const meta = getChannelPlugin(channelId)?.meta; const disable = await prompter.confirm({ - message: `Disable ${meta.label} channel?`, + message: `Disable ${meta?.label ?? channelId} channel?`, initialValue: false, }); if (disable) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index c690cfa88..d211da9a8 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -1,4 +1,4 @@ -import type { ChatChannelId } from "../channels/registry.js"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export type OnboardMode = "local" | "remote"; @@ -31,7 +31,7 @@ export type ResetScope = "config" | "config+creds+sessions" | "full"; export type GatewayBind = "loopback" | "lan" | "auto" | "custom"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; -export type ChannelChoice = ChatChannelId; +export type ChannelChoice = ChannelId; // Legacy alias (pre-rename). export type ProviderChoice = ChannelChoice; diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index b37cd9ec8..178e00f78 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -3,7 +3,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; -import { normalizeChannelId } from "../channels/registry.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { diff --git a/src/config/channel-capabilities.ts b/src/config/channel-capabilities.ts index 5f08e77c9..9161301ad 100644 --- a/src/config/channel-capabilities.ts +++ b/src/config/channel-capabilities.ts @@ -1,4 +1,4 @@ -import { normalizeChannelId } from "../channels/registry.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; diff --git a/src/config/commands.ts b/src/config/commands.ts index e9a33af84..5b8b9fe65 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,5 +1,5 @@ import type { ChannelId } from "../channels/plugins/types.js"; -import { normalizeChannelId } from "../channels/registry.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; import type { NativeCommandsSetting } from "./types.js"; function resolveAutoDefault(providerId?: ChannelId): boolean { diff --git a/src/config/schema.ts b/src/config/schema.ts index a2555e4d4..76775cbf4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -33,6 +33,12 @@ export type PluginUiMetadata = { >; }; +export type ChannelUiMetadata = { + id: string; + label?: string; + description?: string; +}; + const GROUP_LABELS: Record = { wizard: "Wizard", logging: "Logging", @@ -413,6 +419,24 @@ function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): Co return next; } +function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): ConfigUiHints { + const next: ConfigUiHints = { ...hints }; + for (const channel of channels) { + const id = channel.id.trim(); + if (!id) continue; + const basePath = `channels.${id}`; + const current = next[basePath] ?? {}; + const label = channel.label?.trim(); + const help = channel.description?.trim(); + next[basePath] = { + ...current, + ...(label ? { label } : {}), + ...(help ? { help } : {}), + }; + } + return next; +} + let cachedBase: ConfigSchemaResponse | null = null; function buildBaseConfigSchema(): ConfigSchemaResponse { @@ -433,11 +457,17 @@ function buildBaseConfigSchema(): ConfigSchemaResponse { return next; } -export function buildConfigSchema(params?: { plugins?: PluginUiMetadata[] }): ConfigSchemaResponse { +export function buildConfigSchema(params?: { + plugins?: PluginUiMetadata[]; + channels?: ChannelUiMetadata[]; +}): ConfigSchemaResponse { const base = buildBaseConfigSchema(); const plugins = params?.plugins ?? []; - if (plugins.length === 0) return base; - const merged = applySensitiveHints(applyPluginHints(base.uiHints, plugins)); + const channels = params?.channels ?? []; + if (plugins.length === 0 && channels.length === 0) return base; + const merged = applySensitiveHints( + applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), + ); return { ...base, uiHints: merged, diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index d00de6ece..9fd5fb327 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -1,8 +1,8 @@ import type { MsgContext } from "../../auto-reply/templating.js"; -import { CHANNEL_IDS } from "../../channels/registry.js"; +import { listDeliverableMessageChannels } from "../../utils/message-channel.js"; import type { GroupKeyResolution } from "./types.js"; -const GROUP_SURFACES = new Set([...CHANNEL_IDS, "webchat"]); +const getGroupSurfaces = () => new Set([...listDeliverableMessageChannels(), "webchat"]); function normalizeGroupLabel(raw?: string) { const trimmed = raw?.trim().toLowerCase() ?? ""; @@ -76,7 +76,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu }; const parseParts = (parts: string[]) => { - if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { + if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) { provider = parts[0]; if (parts.length >= 3) { const kindCandidate = parts[1]; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 73f3ad614..c7666a569 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -14,4 +14,5 @@ export type ChannelsConfig = { signal?: SignalConfig; imessage?: IMessageConfig; msteams?: MSTeamsConfig; + [key: string]: unknown; }; diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 0828d3bcd..f40f2b917 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -23,4 +23,5 @@ export const ChannelsSchema = z imessage: IMessageConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(), }) + .catchall(z.unknown()) .optional(); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 413b94bc6..e64f587ea 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -136,18 +136,19 @@ export type HookAgentPayload = { timeoutSeconds?: number; }; -const HOOK_CHANNEL_VALUES = ["last", ...listChannelPlugins().map((plugin) => plugin.id)]; +const listHookChannelValues = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)]; export type HookMessageChannel = ChannelId | "last"; -const hookChannelSet = new Set(HOOK_CHANNEL_VALUES); -export const HOOK_CHANNEL_ERROR = `channel must be ${HOOK_CHANNEL_VALUES.join("|")}`; +const getHookChannelSet = () => new Set(listHookChannelValues()); +export const getHookChannelError = () => + `channel must be ${listHookChannelValues().join("|")}`; export function resolveHookChannel(raw: unknown): HookMessageChannel | null { if (raw === undefined) return "last"; if (typeof raw !== "string") return null; const normalized = normalizeMessageChannel(raw); - if (!normalized || !hookChannelSet.has(normalized)) return null; + if (!normalized || !getHookChannelSet().has(normalized)) return null; return normalized as HookMessageChannel; } @@ -176,7 +177,7 @@ export function normalizeAgentPayload( ? sessionKeyRaw.trim() : `hook:${idFactory()}`; const channel = resolveHookChannel(payload.channel); - if (!channel) return { ok: false, error: HOOK_CHANNEL_ERROR }; + if (!channel) return { ok: false, error: getHookChannelError() }; const toRaw = payload.to; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; const modelRaw = payload.model; diff --git a/src/gateway/server-bridge-methods-config.ts b/src/gateway/server-bridge-methods-config.ts index 9a47b2acf..fd2a9b034 100644 --- a/src/gateway/server-bridge-methods-config.ts +++ b/src/gateway/server-bridge-methods-config.ts @@ -67,6 +67,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async ( description: plugin.description, configUiHints: plugin.configUiHints, })), + channels: pluginRegistry.channels.map((entry) => ({ + id: entry.plugin.id, + label: entry.plugin.meta.label, + description: entry.plugin.meta.blurb, + })), }); return { ok: true, payloadJSON: JSON.stringify(schema) }; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 33bdb795d..8e8111fa2 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -11,7 +11,7 @@ import type { createSubsystemLogger } from "../logging.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, - HOOK_CHANNEL_ERROR, + getHookChannelError, type HookMessageChannel, type HooksConfigResolved, normalizeAgentPayload, @@ -152,7 +152,7 @@ export function createHooksRequestHandler( } const channel = resolveHookChannel(mapped.action.channel); if (!channel) { - sendJson(res, 400, { ok: false, error: HOOK_CHANNEL_ERROR }); + sendJson(res, 400, { ok: false, error: getHookChannelError() }); return true; } const runId = dispatchAgentHook({ diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index a700c0754..b74404ca9 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -59,9 +59,10 @@ const BASE_METHODS = [ "chat.send", ]; -const CHANNEL_METHODS = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); - -export const GATEWAY_METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS])); +export function listGatewayMethods(): string[] { + const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); + return Array.from(new Set([...BASE_METHODS, ...channelMethods])); +} export const GATEWAY_EVENTS = [ "agent", diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 919d23e4d..9c3af70e6 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -73,6 +73,11 @@ export const configHandlers: GatewayRequestHandlers = { description: plugin.description, configUiHints: plugin.configUiHints, })), + channels: pluginRegistry.channels.map((entry) => ({ + id: entry.plugin.id, + label: entry.plugin.meta.label, + description: entry.plugin.meta.blurb, + })), }); respond(true, schema, undefined); }, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 1b043a8f2..5524a54bd 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -38,7 +38,7 @@ import { buildGatewayCronService } from "./server-cron.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; import { coreGatewayHandlers } from "./server-methods.js"; -import { GATEWAY_EVENTS, GATEWAY_METHODS } from "./server-methods-list.js"; +import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js"; import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { loadGatewayPlugins } from "./server-plugins.js"; @@ -69,14 +69,6 @@ const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logWsControl = log.child("ws"); const canvasRuntime = runtimeForLogger(logCanvas); -const channelLogs = Object.fromEntries( - listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), -) as Record>; -const channelRuntimeEnvs = Object.fromEntries( - Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]), -) as Record; - -const METHODS = GATEWAY_METHODS; export type GatewayServer = { close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise; @@ -163,13 +155,22 @@ export async function startGatewayServer( await autoMigrateLegacyState({ cfg: cfgAtStart, log }); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); - const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({ + const baseMethods = listGatewayMethods(); + const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({ cfg: cfgAtStart, workspaceDir: defaultWorkspaceDir, log, coreGatewayHandlers, - baseMethods: METHODS, + baseMethods, }); + const channelLogs = Object.fromEntries( + listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), + ) as Record>; + const channelRuntimeEnvs = Object.fromEntries( + Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]), + ) as Record; + const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); + const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods])); let pluginServices: PluginServicesHandle | null = null; const runtimeConfig = await resolveGatewayRuntimeConfig({ cfg: cfgAtStart, diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index f2c81ac1e..a619e5286 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -2,17 +2,17 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { - DELIVERABLE_MESSAGE_CHANNELS, + listDeliverableMessageChannels, type DeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; export type MessageChannelId = DeliverableMessageChannel; -const MESSAGE_CHANNELS = [...DELIVERABLE_MESSAGE_CHANNELS]; +const getMessageChannels = () => listDeliverableMessageChannels(); function isKnownChannel(value: string): value is MessageChannelId { - return (MESSAGE_CHANNELS as readonly string[]).includes(value); + return getMessageChannels().includes(value as MessageChannelId); } function isAccountEnabled(account: unknown): boolean { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index c1aab8713..881982507 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -102,4 +102,44 @@ describe("loadClawdbotPlugins", () => { expect(registry.plugins[0]?.status).toBe("error"); expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); }); + + it("registers channel plugins", () => { + const plugin = writePlugin({ + id: "channel-demo", + body: `export default function (api) { + api.registerChannel({ + plugin: { + id: "demo", + meta: { + id: "demo", + label: "Demo", + selectionLabel: "Demo", + docsPath: "/channels/demo", + blurb: "demo channel" + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }) + }, + outbound: { deliveryMode: "direct" } + } + }); +};`, + }); + + const registry = loadClawdbotPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["channel-demo"], + }, + }, + }); + + expect(registry.channels.length).toBe(1); + expect(registry.channels[0]?.plugin.id).toBe("demo"); + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 9e4f1efd9..c1df8f282 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { discoverClawdbotPlugins } from "./discovery.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; import type { ClawdbotPluginConfigSchema, ClawdbotPluginDefinition, @@ -188,6 +189,7 @@ function createPluginRecord(params: { enabled: params.enabled, status: params.enabled ? "loaded" : "disabled", toolNames: [], + channelIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -211,7 +213,10 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = registryCache.get(cacheKey); - if (cached) return cached; + if (cached) { + setActivePluginRegistry(cached, cacheKey); + return cached; + } } const { registry, createApi } = createPluginRegistry({ @@ -359,5 +364,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { registryCache.set(cacheKey, registry); } + setActivePluginRegistry(registry, cacheKey); return registry; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 06cf6b4f0..e9c040b98 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,4 +1,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { GatewayRequestHandler, GatewayRequestHandlers, @@ -6,6 +8,7 @@ import type { import { resolveUserPath } from "../utils.js"; import type { ClawdbotPluginApi, + ClawdbotPluginChannelRegistration, ClawdbotPluginCliRegistrar, ClawdbotPluginService, ClawdbotPluginToolContext, @@ -30,6 +33,13 @@ export type PluginCliRegistration = { source: string; }; +export type PluginChannelRegistration = { + pluginId: string; + plugin: ChannelPlugin; + dock?: ChannelDock; + source: string; +}; + export type PluginServiceRegistration = { pluginId: string; service: ClawdbotPluginService; @@ -48,6 +58,7 @@ export type PluginRecord = { status: "loaded" | "disabled" | "error"; error?: string; toolNames: string[]; + channelIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -58,6 +69,7 @@ export type PluginRecord = { export type PluginRegistry = { plugins: PluginRecord[]; tools: PluginToolRegistration[]; + channels: PluginChannelRegistration[]; gatewayHandlers: GatewayRequestHandlers; cliRegistrars: PluginCliRegistration[]; services: PluginServiceRegistration[]; @@ -73,6 +85,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry: PluginRegistry = { plugins: [], tools: [], + channels: [], gatewayHandlers: {}, cliRegistrars: [], services: [], @@ -129,6 +142,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.gatewayMethods.push(trimmed); }; + const registerChannel = ( + record: PluginRecord, + registration: ClawdbotPluginChannelRegistration | ChannelPlugin, + ) => { + const normalized = + typeof (registration as ClawdbotPluginChannelRegistration).plugin === "object" + ? (registration as ClawdbotPluginChannelRegistration) + : { plugin: registration as ChannelPlugin }; + const plugin = normalized.plugin; + const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "channel registration missing id", + }); + return; + } + record.channelIds.push(id); + registry.channels.push({ + pluginId: record.id, + plugin, + dock: normalized.dock, + source: record.source, + }); + }; + const registerCli = ( record: PluginRecord, registrar: ClawdbotPluginCliRegistrar, @@ -179,6 +220,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginConfig: params.pluginConfig, logger: normalizeLogger(registryParams.logger), registerTool: (tool, opts) => registerTool(record, tool, opts), + registerChannel: (registration) => registerChannel(record, registration), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), @@ -191,6 +233,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { createApi, pushDiagnostic, registerTool, + registerChannel, registerGatewayMethod, registerCli, registerService, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 5537176a5..c70863689 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,6 +1,8 @@ import type { Command } from "commander"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -78,6 +80,11 @@ export type ClawdbotPluginService = { stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise; }; +export type ClawdbotPluginChannelRegistration = { + plugin: ChannelPlugin; + dock?: ChannelDock; +}; + export type ClawdbotPluginDefinition = { id?: string; name?: string; @@ -105,6 +112,9 @@ export type ClawdbotPluginApi = { tool: AnyAgentTool | ClawdbotPluginToolFactory, opts?: { name?: string; names?: string[] }, ) => void; + registerChannel: ( + registration: ClawdbotPluginChannelRegistration | ChannelPlugin, + ) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: ClawdbotPluginService) => void; diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index 664d6a3be..bae71560a 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -3,6 +3,7 @@ import { listChatChannelAliases, normalizeChatChannelId, } from "../channels/registry.js"; +import type { ChannelId } from "../channels/plugins/types.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -11,6 +12,7 @@ import { normalizeGatewayClientMode, normalizeGatewayClientName, } from "../gateway/protocol/client-info.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; @@ -42,34 +44,56 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined const normalized = raw?.trim().toLowerCase(); if (!normalized) return undefined; if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL; - return normalizeChatChannelId(normalized) ?? normalized; + const builtIn = normalizeChatChannelId(normalized); + if (builtIn) return builtIn; + const registry = getActivePluginRegistry(); + const pluginMatch = registry?.channels.find((entry) => { + if (entry.plugin.id.toLowerCase() === normalized) return true; + return (entry.plugin.meta.aliases ?? []).some( + (alias) => alias.trim().toLowerCase() === normalized, + ); + }); + return pluginMatch?.plugin.id ?? normalized; } -export const DELIVERABLE_MESSAGE_CHANNELS = CHANNEL_IDS; +const listPluginChannelIds = (): string[] => { + const registry = getActivePluginRegistry(); + if (!registry) return []; + return registry.channels.map((entry) => entry.plugin.id); +}; -export type DeliverableMessageChannel = (typeof DELIVERABLE_MESSAGE_CHANNELS)[number]; +const listPluginChannelAliases = (): string[] => { + const registry = getActivePluginRegistry(); + if (!registry) return []; + return registry.channels.flatMap((entry) => entry.plugin.meta.aliases ?? []); +}; + +export const listDeliverableMessageChannels = (): ChannelId[] => + Array.from(new Set([...CHANNEL_IDS, ...listPluginChannelIds()])); + +export type DeliverableMessageChannel = ChannelId; export type GatewayMessageChannel = DeliverableMessageChannel | InternalMessageChannel; -export const GATEWAY_MESSAGE_CHANNELS = [ - ...DELIVERABLE_MESSAGE_CHANNELS, +export const listGatewayMessageChannels = (): GatewayMessageChannel[] => [ + ...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL, -] as const; +]; -export const GATEWAY_AGENT_CHANNEL_ALIASES = listChatChannelAliases(); +export const listGatewayAgentChannelAliases = (): string[] => + Array.from(new Set([...listChatChannelAliases(), ...listPluginChannelAliases()])); export type GatewayAgentChannelHint = GatewayMessageChannel | "last"; -export const GATEWAY_AGENT_CHANNEL_VALUES = Array.from( - new Set([...GATEWAY_MESSAGE_CHANNELS, "last", ...GATEWAY_AGENT_CHANNEL_ALIASES]), -); +export const listGatewayAgentChannelValues = (): string[] => + Array.from(new Set([...listGatewayMessageChannels(), "last", ...listGatewayAgentChannelAliases()])); export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel { - return (GATEWAY_MESSAGE_CHANNELS as readonly string[]).includes(value); + return listGatewayMessageChannels().includes(value as GatewayMessageChannel); } export function isDeliverableMessageChannel(value: string): value is DeliverableMessageChannel { - return (DELIVERABLE_MESSAGE_CHANNELS as readonly string[]).includes(value); + return listDeliverableMessageChannels().includes(value as DeliverableMessageChannel); } export function resolveGatewayMessageChannel(