feat: load channel plugins
This commit is contained in:
@@ -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/<id>.ts` or channel modules
|
||||
const DOCKS: Record<ChannelId, ChannelDock> = {
|
||||
const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
capabilities: {
|
||||
@@ -311,10 +313,71 @@ const DOCKS: Record<ChannelId, ChannelDock> = {
|
||||
},
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ChatChannelId, ChatChannelMeta> = {
|
||||
const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
|
||||
Reference in New Issue
Block a user