feat: load channel plugins

This commit is contained in:
Peter Steinberger
2026-01-15 02:42:41 +00:00
parent b1e3d79eaa
commit 2b4a68e276
49 changed files with 494 additions and 159 deletions

View File

@@ -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);
}
});
});

View File

@@ -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 `<id>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<string>();
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 {

View File

@@ -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<ChannelPlugin>;
@@ -6,7 +8,7 @@ type PluginLoader = () => Promise<ChannelPlugin>;
//
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing.
const LOADERS: Record<ChannelId, PluginLoader> = {
const LOADERS: Record<ChatChannelId, PluginLoader> = {
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<ChannelId, ChannelPlugin>();
export async function loadChannelPlugin(id: ChannelId): Promise<ChannelPlugin | undefined> {
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);

View File

@@ -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<Record<ChatChannelId, string>>;
onAccountId?: (channel: ChatChannelId, accountId: string) => void;
onSelection?: (selection: ChannelId[]) => void;
accountIds?: Partial<Record<ChannelId, string>>;
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<string>;
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<Record<ChatChannelId, string>>;
accountOverrides: Partial<Record<ChannelId, string>>;
};
export type ChannelOnboardingConfigureContext = {
@@ -51,7 +51,7 @@ export type ChannelOnboardingConfigureContext = {
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>;
accountOverrides: Partial<Record<ChannelId, string>>;
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<ChannelOnboardingStatus>;
configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>;
dmPolicy?: ChannelOnboardingDmPolicy;

View File

@@ -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<ChannelOutboundAdapter>;
@@ -7,7 +9,7 @@ type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// 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<ChannelId, OutboundLoader> = {
const LOADERS: Record<ChatChannelId, OutboundLoader> = {
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<ChannelOutboundAdapter | undefined> {
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;
}

View File

@@ -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;