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

@@ -165,6 +165,45 @@ Plugins export either:
- A function: `(api) => { ... }` - A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }` - An object: `{ id, name, configSchema, register(api) { ... } }`
### Register a messaging channel
Plugins can register **channel plugins** that behave like builtin channels
(WhatsApp, Telegram, etc.). Channel config lives under `channels.<id>` 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.<id>` (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 ### Register a tool
```ts ```ts

View File

@@ -1,10 +1,8 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.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"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
const MESSAGE_CHANNEL_OPTIONS = CHANNEL_IDS.join("|");
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -169,6 +167,7 @@ export function buildAgentSystemPrompt(params: {
.filter(Boolean); .filter(Boolean);
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
const skillsSection = skillsPrompt const skillsSection = skillsPrompt
? [ ? [
@@ -319,7 +318,7 @@ export function buildAgentSystemPrompt(params: {
"### message tool", "### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.", "- 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 inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel : runtimeChannel

View File

@@ -1,7 +1,7 @@
import type { ChannelDock } from "../channels/dock.js"; import type { ChannelDock } from "../channels/dock.js";
import { getChannelDock, listChannelDocks } from "../channels/dock.js"; import { getChannelDock, listChannelDocks } from "../channels/dock.js";
import type { ChannelId } from "../channels/plugins/types.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 { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "./templating.js"; import type { MsgContext } from "./templating.js";

View File

@@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js"; import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.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 type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";

View File

@@ -1,23 +1,24 @@
import { getChannelDock } from "../../channels/dock.js"; 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 { ClawdbotConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { normalizeAccountId } from "../../routing/session-key.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"; import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([ const getBlockChunkProviders = () =>
...CHANNEL_IDS, new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
INTERNAL_MESSAGE_CHANNEL,
]);
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined { function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
if (!provider) return undefined; if (!provider) return undefined;
const cleaned = provider.trim().toLowerCase(); const cleaned = provider.trim().toLowerCase();
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider) return getBlockChunkProviders().has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider) ? (cleaned as TextChunkProvider)
: undefined; : undefined;
} }

View File

@@ -1,5 +1,5 @@
import { getChannelDock } from "../../channels/dock.js"; 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 { ClawdbotConfig } from "../../config/config.js";
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -50,7 +50,7 @@ export function buildGroupIntro(params: {
const providerLabel = (() => { const providerLabel = (() => {
if (!providerKey) return "chat"; if (!providerKey) return "chat";
if (isInternalMessageChannel(providerKey)) return "WebChat"; 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)}`; return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
})(); })();
const subjectLine = subject const subjectLine = subject

View File

@@ -1,6 +1,6 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.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 { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";

View File

@@ -1,6 +1,7 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.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 type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";

View File

@@ -1,5 +1,5 @@
import { getChannelDock } from "../../channels/dock.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 { ClawdbotConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js"; import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js"; import type { OriginatingChannelType } from "../templating.js";

View File

@@ -9,7 +9,7 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.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 type { ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { OriginatingChannelType } from "../templating.js"; import type { OriginatingChannelType } from "../templating.js";

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.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 { ClawdbotConfig } from "../../config/config.js";
import { import {
buildGroupDisplayName, buildGroupDisplayName,

View File

@@ -7,6 +7,7 @@ import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { import {
resolveDiscordGroupRequireMention, resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention, resolveIMessageGroupRequireMention,
@@ -21,9 +22,10 @@ import type {
ChannelGroupAdapter, ChannelGroupAdapter,
ChannelId, ChannelId,
ChannelMentionAdapter, ChannelMentionAdapter,
ChannelPlugin,
ChannelThreadingAdapter, ChannelThreadingAdapter,
} from "./plugins/types.js"; } from "./plugins/types.js";
import { CHAT_CHANNEL_ORDER } from "./registry.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
export type ChannelDock = { export type ChannelDock = {
id: ChannelId; id: ChannelId;
@@ -75,7 +77,7 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\
// Adding a channel: // Adding a channel:
// - add a new entry to `DOCKS` // - add a new entry to `DOCKS`
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules // - 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: { telegram: {
id: "telegram", id: "telegram",
capabilities: { 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[] { 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 { 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);
} }

View File

@@ -3,12 +3,10 @@ import { CHANNEL_IDS } from "../registry.js";
import { listChannelPlugins } from "./index.js"; import { listChannelPlugins } from "./index.js";
describe("channel plugin registry", () => { describe("channel plugin registry", () => {
it("stays in sync with channel ids", () => { it("includes the built-in channel ids", () => {
const pluginIds = listChannelPlugins() const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
.map((plugin) => plugin.id) for (const id of CHANNEL_IDS) {
.slice() expect(pluginIds).toContain(id);
.sort(); }
const channelIds = [...CHANNEL_IDS].slice().sort();
expect(pluginIds).toEqual(channelIds);
}); });
}); });

View File

@@ -7,6 +7,7 @@ import { slackPlugin } from "./slack.js";
import { telegramPlugin } from "./telegram.js"; import { telegramPlugin } from "./telegram.js";
import type { ChannelId, ChannelPlugin } from "./types.js"; import type { ChannelId, ChannelPlugin } from "./types.js";
import { whatsappPlugin } from "./whatsapp.js"; import { whatsappPlugin } from "./whatsapp.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
// Channel plugins registry (runtime). // Channel plugins registry (runtime).
// //
@@ -18,7 +19,7 @@ import { whatsappPlugin } from "./whatsapp.js";
// - add `<id>Plugin` import + entry in `resolveChannels()` // - add `<id>Plugin` import + entry in `resolveChannels()`
// - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …) // - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
// - add ids/aliases in `src/channels/registry.ts` // - add ids/aliases in `src/channels/registry.ts`
function resolveChannels(): ChannelPlugin[] { function resolveCoreChannels(): ChannelPlugin[] {
return [ return [
telegramPlugin, telegramPlugin,
whatsappPlugin, 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[] { 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 indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
@@ -42,13 +62,24 @@ export function listChannelPlugins(): ChannelPlugin[] {
} }
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { 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 { export function normalizeChannelId(raw?: string | null): ChannelId | null {
// Channel docking: keep input normalization centralized in src/channels/registry.ts // 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. // 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 { export {

View File

@@ -1,4 +1,6 @@
import type { ChannelId, ChannelPlugin } from "./types.js"; import type { ChannelId, ChannelPlugin } from "./types.js";
import type { ChatChannelId } from "../registry.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
type PluginLoader = () => Promise<ChannelPlugin>; type PluginLoader = () => Promise<ChannelPlugin>;
@@ -6,7 +8,7 @@ type PluginLoader = () => Promise<ChannelPlugin>;
// //
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy) // This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing. // 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, telegram: async () => (await import("./telegram.js")).telegramPlugin,
whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin, whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin,
discord: async () => (await import("./discord.js")).discordPlugin, 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> { export async function loadChannelPlugin(id: ChannelId): Promise<ChannelPlugin | undefined> {
const cached = cache.get(id); const cached = cache.get(id);
if (cached) return cached; 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; if (!loader) return undefined;
const plugin = await loader(); const plugin = await loader();
cache.set(id, plugin); 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 { DmPolicy } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js"; import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChatChannelId } from "../registry.js"; import type { ChannelId } from "./types.js";
export type SetupChannelsOptions = { export type SetupChannelsOptions = {
allowDisable?: boolean; allowDisable?: boolean;
allowSignalInstall?: boolean; allowSignalInstall?: boolean;
onSelection?: (selection: ChatChannelId[]) => void; onSelection?: (selection: ChannelId[]) => void;
accountIds?: Partial<Record<ChatChannelId, string>>; accountIds?: Partial<Record<ChannelId, string>>;
onAccountId?: (channel: ChatChannelId, accountId: string) => void; onAccountId?: (channel: ChannelId, accountId: string) => void;
promptAccountIds?: boolean; promptAccountIds?: boolean;
whatsappAccountId?: string; whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean; promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void; onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromChannels?: ChatChannelId[]; forceAllowFromChannels?: ChannelId[];
skipDmPolicyPrompt?: boolean; skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean; skipConfirm?: boolean;
quickstartDefaults?: boolean; quickstartDefaults?: boolean;
initialSelection?: ChatChannelId[]; initialSelection?: ChannelId[];
}; };
export type PromptAccountIdParams = { export type PromptAccountIdParams = {
@@ -33,7 +33,7 @@ export type PromptAccountIdParams = {
export type PromptAccountId = (params: PromptAccountIdParams) => Promise<string>; export type PromptAccountId = (params: PromptAccountIdParams) => Promise<string>;
export type ChannelOnboardingStatus = { export type ChannelOnboardingStatus = {
channel: ChatChannelId; channel: ChannelId;
configured: boolean; configured: boolean;
statusLines: string[]; statusLines: string[];
selectionHint?: string; selectionHint?: string;
@@ -43,7 +43,7 @@ export type ChannelOnboardingStatus = {
export type ChannelOnboardingStatusContext = { export type ChannelOnboardingStatusContext = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
options?: SetupChannelsOptions; options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>; accountOverrides: Partial<Record<ChannelId, string>>;
}; };
export type ChannelOnboardingConfigureContext = { export type ChannelOnboardingConfigureContext = {
@@ -51,7 +51,7 @@ export type ChannelOnboardingConfigureContext = {
runtime: RuntimeEnv; runtime: RuntimeEnv;
prompter: WizardPrompter; prompter: WizardPrompter;
options?: SetupChannelsOptions; options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>; accountOverrides: Partial<Record<ChannelId, string>>;
shouldPromptAccountIds: boolean; shouldPromptAccountIds: boolean;
forceAllowFrom: boolean; forceAllowFrom: boolean;
}; };
@@ -63,7 +63,7 @@ export type ChannelOnboardingResult = {
export type ChannelOnboardingDmPolicy = { export type ChannelOnboardingDmPolicy = {
label: string; label: string;
channel: ChatChannelId; channel: ChannelId;
policyKey: string; policyKey: string;
allowFromKey: string; allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy; getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
@@ -71,7 +71,7 @@ export type ChannelOnboardingDmPolicy = {
}; };
export type ChannelOnboardingAdapter = { export type ChannelOnboardingAdapter = {
channel: ChatChannelId; channel: ChannelId;
getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>; getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>;
configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>; configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>;
dmPolicy?: ChannelOnboardingDmPolicy; dmPolicy?: ChannelOnboardingDmPolicy;

View File

@@ -1,4 +1,6 @@
import type { ChannelId, ChannelOutboundAdapter } from "../types.js"; import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
import type { ChatChannelId } from "../../registry.js";
import { getActivePluginRegistry } from "../../../plugins/runtime.js";
type OutboundLoader = () => Promise<ChannelOutboundAdapter>; type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
@@ -7,7 +9,7 @@ type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// The full channel plugins (src/channels/plugins/*.ts) pull in status, // The full channel plugins (src/channels/plugins/*.ts) pull in status,
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking + // onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
// send primitives, so we keep a dedicated, lightweight loader here. // 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, telegram: async () => (await import("./telegram.js")).telegramOutbound,
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound, whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
discord: async () => (await import("./discord.js")).discordOutbound, discord: async () => (await import("./discord.js")).discordOutbound,
@@ -24,9 +26,16 @@ export async function loadChannelOutboundAdapter(
): Promise<ChannelOutboundAdapter | undefined> { ): Promise<ChannelOutboundAdapter | undefined> {
const cached = cache.get(id); const cached = cache.get(id);
if (cached) return cached; 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; if (!loader) return undefined;
const outbound = await loader(); const loaded = await loader();
cache.set(id, outbound); cache.set(id, loaded);
return outbound; return loaded;
} }

View File

@@ -7,7 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c
import type { ChatChannelId } from "../registry.js"; import type { ChatChannelId } from "../registry.js";
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.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"; export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
@@ -62,6 +62,10 @@ export type ChannelMeta = {
docsLabel?: string; docsLabel?: string;
blurb: string; blurb: string;
order?: number; order?: number;
aliases?: string[];
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
showConfigured?: boolean; showConfigured?: boolean;
quickstartAllowFrom?: boolean; quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean; forceAccountBinding?: boolean;

View File

@@ -1,3 +1,5 @@
import type { ChannelMeta } from "./plugins/types.js";
// Channel docking: add new channels here (order + meta + aliases), then // 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. // register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync.
export const CHAT_CHANNEL_ORDER = [ 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 const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp";
export type ChatChannelMeta = { export type ChatChannelMeta = ChannelMeta;
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[];
};
const WEBSITE_URL = "https://clawd.bot"; const WEBSITE_URL = "https://clawd.bot";
const CHAT_CHANNEL_META: Record<ChatChannelId, ChatChannelMeta> = { const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
telegram: { telegram: {
id: "telegram", id: "telegram",
label: "Telegram", label: "Telegram",

View File

@@ -1,5 +1,5 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { listChatChannels } from "../channels/registry.js"; import { listChannelPlugins } from "../channels/plugins/index.js";
import { import {
channelsAddCommand, channelsAddCommand,
channelsListCommand, channelsListCommand,
@@ -36,11 +36,10 @@ const optionNamesAdd = [
const optionNamesRemove = ["channel", "account", "delete"] as const; const optionNamesRemove = ["channel", "account", "delete"] as const;
const channelNames = listChatChannels()
.map((meta) => meta.id)
.join("|");
export function registerChannelsCli(program: Command) { export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channels = program const channels = program
.command("channels") .command("channels")
.description("Manage chat channel accounts") .description("Manage chat channel accounts")

View File

@@ -7,7 +7,7 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { import {
CRON_CHANNEL_OPTIONS, getCronChannelOptions,
parseAtMs, parseAtMs,
parseDurationMs, parseDurationMs,
printCronList, printCronList,
@@ -81,7 +81,7 @@ export function registerCronAddCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs (provider/model or alias)") .option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`, "last") .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",

View File

@@ -4,7 +4,7 @@ import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { import {
CRON_CHANNEL_OPTIONS, getCronChannelOptions,
parseAtMs, parseAtMs,
parseDurationMs, parseDurationMs,
warnIfCronSchedulerDisabled, warnIfCronSchedulerDisabled,
@@ -36,7 +36,7 @@ export function registerCronEditCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs") .option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`) .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",

View File

@@ -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 { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.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 type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } 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) { export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try { try {

View File

@@ -1,4 +1,8 @@
import { listChannelPlugins } from "../../channels/plugins/index.js"; 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"; import { VERSION } from "../../version.js";
export type ProgramContext = { export type ProgramContext = {
@@ -8,7 +12,25 @@ export type ProgramContext = {
agentChannelOptions: string; 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 { export function createProgramContext(): ProgramContext {
primePluginRegistry();
const channelOptions = listChannelPlugins().map((plugin) => plugin.id); const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
return { return {
programVersion: VERSION, programVersion: VERSION,

View File

@@ -1,7 +1,6 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin } from "../channels/plugins/index.js"; import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChatChannelId } from "../channels/registry.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js"; import type { AgentBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.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); const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID; if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg }); return resolveChannelDefaultAccountId({ plugin, cfg });
@@ -125,7 +124,7 @@ export function parseBindingSpecs(params: {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) continue; if (!trimmed) continue;
const [channelRaw, accountRaw] = trimmed.split(":", 2); const [channelRaw, accountRaw] = trimmed.split(":", 2);
const channel = normalizeChatChannelId(channelRaw); const channel = normalizeChannelId(channelRaw);
if (!channel) { if (!channel) {
errors.push(`Unknown channel "${channelRaw}".`); errors.push(`Unknown channel "${channelRaw}".`);
continue; continue;

View File

@@ -1,13 +1,12 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { getChannelPlugin, listChannelPlugins, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChatChannelId } from "../channels/registry.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js"; import type { AgentBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
type ProviderAccountStatus = { type ProviderAccountStatus = {
provider: ChatChannelId; provider: ChannelId;
accountId: string; accountId: string;
name?: string; name?: string;
state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled"; state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled";
@@ -15,16 +14,16 @@ type ProviderAccountStatus = {
configured?: boolean; configured?: boolean;
}; };
function providerAccountKey(provider: ChatChannelId, accountId?: string) { function providerAccountKey(provider: ChannelId, accountId?: string) {
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`; return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
} }
function formatChannelAccountLabel(params: { function formatChannelAccountLabel(params: {
provider: ChatChannelId; provider: ChannelId;
accountId: string; accountId: string;
name?: string; name?: string;
}): string { }): string {
const label = getChatChannelMeta(params.provider).label; const label = getChannelPlugin(params.provider)?.meta.label ?? params.provider;
const account = params.name?.trim() const account = params.name?.trim()
? `${params.accountId} (${params.name.trim()})` ? `${params.accountId} (${params.name.trim()})`
: params.accountId; : params.accountId;
@@ -88,7 +87,7 @@ export async function buildProviderStatusIndex(
return map; return map;
} }
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string { function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider); const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID; if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg }); return resolveChannelDefaultAccountId({ plugin, cfg });
@@ -117,7 +116,7 @@ export function summarizeBindings(cfg: ClawdbotConfig, bindings: AgentBinding[])
if (bindings.length === 0) return []; if (bindings.length === 0) return [];
const seen = new Map<string, string>(); const seen = new Map<string, string>();
for (const binding of bindings) { for (const binding of bindings) {
const channel = normalizeChatChannelId(binding.match.channel); const channel = normalizeChannelId(binding.match.channel);
if (!channel) continue; if (!channel) continue;
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel); const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const key = providerAccountKey(channel, accountId); const key = providerAccountKey(channel, accountId);
@@ -143,7 +142,7 @@ export function listProvidersForAgent(params: {
if (params.bindings.length > 0) { if (params.bindings.length > 0) {
const seen = new Set<string>(); const seen = new Set<string>();
for (const binding of params.bindings) { for (const binding of params.bindings) {
const channel = normalizeChatChannelId(binding.match.channel); const channel = normalizeChannelId(binding.match.channel);
if (!channel) continue; if (!channel) continue;
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel); const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
const key = providerAccountKey(channel, accountId); const key = providerAccountKey(channel, accountId);

View File

@@ -15,12 +15,14 @@ type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200; const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000; const MAX_BYTES = 1_000_000;
const CHANNELS = new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
const getChannelSet = () =>
new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
function parseChannelFilter(raw?: string) { function parseChannelFilter(raw?: string) {
const trimmed = raw?.trim().toLowerCase(); const trimmed = raw?.trim().toLowerCase();
if (!trimmed) return "all"; if (!trimmed) return "all";
return CHANNELS.has(trimmed) ? trimmed : "all"; return getChannelSet().has(trimmed) ? trimmed : "all";
} }
function matchesChannel(line: NonNullable<LogLine>, channel: string) { function matchesChannel(line: NonNullable<LogLine>, channel: string) {

View File

@@ -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 type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -13,7 +13,9 @@ export async function removeChannelConfigWizard(
let next = { ...cfg }; let next = { ...cfg };
const listConfiguredChannels = () => const listConfiguredChannels = () =>
listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined); listChannelPlugins()
.map((plugin) => plugin.meta)
.filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) { while (true) {
const configured = listConfiguredChannels(); const configured = listConfiguredChannels();
@@ -45,7 +47,7 @@ export async function removeChannelConfigWizard(
if (channel === "done") return next; 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( const confirmed = guardCancel(
await confirm({ await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`, message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,

View File

@@ -1,9 +1,5 @@
import { import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
formatChannelPrimerLine, import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
formatChannelSelectionLine,
getChatChannelMeta,
listChatChannels,
} from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.js"; import type { DmPolicy } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -17,7 +13,7 @@ import {
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js"; import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> { async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> {
const channelLines = listChatChannels().map((meta) => formatChannelPrimerLine(meta)); const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta));
await prompter.note( await prompter.note(
[ [
"DM security: default is pairing; unknown DMs get a pairing code.", "DM security: default is pairing; unknown DMs get a pairing code.",
@@ -130,11 +126,12 @@ export async function setupChannels(
await noteChannelPrimer(prompter); 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); const status = statusByChannel.get(meta.id as ChannelChoice);
return { return {
value: meta.id, value: meta.id,
label: meta.selectionLabel, label: meta.selectionLabel ?? meta.label,
...(status?.selectionHint ? { hint: status.selectionHint } : {}), ...(status?.selectionHint ? { hint: status.selectionHint } : {}),
}; };
}); });
@@ -169,7 +166,10 @@ export async function setupChannels(
options?.onSelection?.(selection); options?.onSelection?.(selection);
const selectionNotes = new Map( const selectionNotes = new Map(
listChatChannels().map((meta) => [meta.id, formatChannelSelectionLine(meta, formatDocsLink)]), listChannelPlugins().map((plugin) => [
plugin.id,
formatChannelSelectionLine(plugin.meta, formatDocsLink),
]),
); );
const selectedLines = selection const selectedLines = selection
.map((channel) => selectionNotes.get(channel)) .map((channel) => selectionNotes.get(channel))
@@ -214,9 +214,9 @@ export async function setupChannels(
if (!status.configured) continue; if (!status.configured) continue;
const adapter = getChannelOnboardingAdapter(channelId); const adapter = getChannelOnboardingAdapter(channelId);
if (!adapter?.disable) continue; if (!adapter?.disable) continue;
const meta = getChatChannelMeta(channelId); const meta = getChannelPlugin(channelId)?.meta;
const disable = await prompter.confirm({ const disable = await prompter.confirm({
message: `Disable ${meta.label} channel?`, message: `Disable ${meta?.label ?? channelId} channel?`,
initialValue: false, initialValue: false,
}); });
if (disable) { if (disable) {

View File

@@ -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"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote"; 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 GatewayBind = "loopback" | "lan" | "auto" | "custom";
export type TailscaleMode = "off" | "serve" | "funnel"; export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ChannelChoice = ChatChannelId; export type ChannelChoice = ChannelId;
// Legacy alias (pre-rename). // Legacy alias (pre-rename).
export type ProviderChoice = ChannelChoice; export type ProviderChoice = ChannelChoice;

View File

@@ -3,7 +3,7 @@ import {
resolveSandboxConfigForAgent, resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent, resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js"; } 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 type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {

View File

@@ -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 { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js"; import type { ClawdbotConfig } from "./config.js";

View File

@@ -1,5 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js"; 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"; import type { NativeCommandsSetting } from "./types.js";
function resolveAutoDefault(providerId?: ChannelId): boolean { function resolveAutoDefault(providerId?: ChannelId): boolean {

View File

@@ -33,6 +33,12 @@ export type PluginUiMetadata = {
>; >;
}; };
export type ChannelUiMetadata = {
id: string;
label?: string;
description?: string;
};
const GROUP_LABELS: Record<string, string> = { const GROUP_LABELS: Record<string, string> = {
wizard: "Wizard", wizard: "Wizard",
logging: "Logging", logging: "Logging",
@@ -413,6 +419,24 @@ function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): Co
return next; 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; let cachedBase: ConfigSchemaResponse | null = null;
function buildBaseConfigSchema(): ConfigSchemaResponse { function buildBaseConfigSchema(): ConfigSchemaResponse {
@@ -433,11 +457,17 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
return next; return next;
} }
export function buildConfigSchema(params?: { plugins?: PluginUiMetadata[] }): ConfigSchemaResponse { export function buildConfigSchema(params?: {
plugins?: PluginUiMetadata[];
channels?: ChannelUiMetadata[];
}): ConfigSchemaResponse {
const base = buildBaseConfigSchema(); const base = buildBaseConfigSchema();
const plugins = params?.plugins ?? []; const plugins = params?.plugins ?? [];
if (plugins.length === 0) return base; const channels = params?.channels ?? [];
const merged = applySensitiveHints(applyPluginHints(base.uiHints, plugins)); if (plugins.length === 0 && channels.length === 0) return base;
const merged = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
);
return { return {
...base, ...base,
uiHints: merged, uiHints: merged,

View File

@@ -1,8 +1,8 @@
import type { MsgContext } from "../../auto-reply/templating.js"; 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"; import type { GroupKeyResolution } from "./types.js";
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]); const getGroupSurfaces = () => new Set<string>([...listDeliverableMessageChannels(), "webchat"]);
function normalizeGroupLabel(raw?: string) { function normalizeGroupLabel(raw?: string) {
const trimmed = raw?.trim().toLowerCase() ?? ""; const trimmed = raw?.trim().toLowerCase() ?? "";
@@ -76,7 +76,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
}; };
const parseParts = (parts: string[]) => { const parseParts = (parts: string[]) => {
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) {
provider = parts[0]; provider = parts[0];
if (parts.length >= 3) { if (parts.length >= 3) {
const kindCandidate = parts[1]; const kindCandidate = parts[1];

View File

@@ -14,4 +14,5 @@ export type ChannelsConfig = {
signal?: SignalConfig; signal?: SignalConfig;
imessage?: IMessageConfig; imessage?: IMessageConfig;
msteams?: MSTeamsConfig; msteams?: MSTeamsConfig;
[key: string]: unknown;
}; };

View File

@@ -23,4 +23,5 @@ export const ChannelsSchema = z
imessage: IMessageConfigSchema.optional(), imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.catchall(z.unknown())
.optional(); .optional();

View File

@@ -136,18 +136,19 @@ export type HookAgentPayload = {
timeoutSeconds?: number; 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"; export type HookMessageChannel = ChannelId | "last";
const hookChannelSet = new Set<string>(HOOK_CHANNEL_VALUES); const getHookChannelSet = () => new Set<string>(listHookChannelValues());
export const HOOK_CHANNEL_ERROR = `channel must be ${HOOK_CHANNEL_VALUES.join("|")}`; export const getHookChannelError = () =>
`channel must be ${listHookChannelValues().join("|")}`;
export function resolveHookChannel(raw: unknown): HookMessageChannel | null { export function resolveHookChannel(raw: unknown): HookMessageChannel | null {
if (raw === undefined) return "last"; if (raw === undefined) return "last";
if (typeof raw !== "string") return null; if (typeof raw !== "string") return null;
const normalized = normalizeMessageChannel(raw); const normalized = normalizeMessageChannel(raw);
if (!normalized || !hookChannelSet.has(normalized)) return null; if (!normalized || !getHookChannelSet().has(normalized)) return null;
return normalized as HookMessageChannel; return normalized as HookMessageChannel;
} }
@@ -176,7 +177,7 @@ export function normalizeAgentPayload(
? sessionKeyRaw.trim() ? sessionKeyRaw.trim()
: `hook:${idFactory()}`; : `hook:${idFactory()}`;
const channel = resolveHookChannel(payload.channel); 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 toRaw = payload.to;
const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
const modelRaw = payload.model; const modelRaw = payload.model;

View File

@@ -67,6 +67,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, 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) }; return { ok: true, payloadJSON: JSON.stringify(schema) };
} }

View File

@@ -11,7 +11,7 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
HOOK_CHANNEL_ERROR, getHookChannelError,
type HookMessageChannel, type HookMessageChannel,
type HooksConfigResolved, type HooksConfigResolved,
normalizeAgentPayload, normalizeAgentPayload,
@@ -152,7 +152,7 @@ export function createHooksRequestHandler(
} }
const channel = resolveHookChannel(mapped.action.channel); const channel = resolveHookChannel(mapped.action.channel);
if (!channel) { if (!channel) {
sendJson(res, 400, { ok: false, error: HOOK_CHANNEL_ERROR }); sendJson(res, 400, { ok: false, error: getHookChannelError() });
return true; return true;
} }
const runId = dispatchAgentHook({ const runId = dispatchAgentHook({

View File

@@ -59,9 +59,10 @@ const BASE_METHODS = [
"chat.send", "chat.send",
]; ];
const CHANNEL_METHODS = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); export function listGatewayMethods(): string[] {
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
export const GATEWAY_METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS])); return Array.from(new Set([...BASE_METHODS, ...channelMethods]));
}
export const GATEWAY_EVENTS = [ export const GATEWAY_EVENTS = [
"agent", "agent",

View File

@@ -73,6 +73,11 @@ export const configHandlers: GatewayRequestHandlers = {
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, 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); respond(true, schema, undefined);
}, },

View File

@@ -38,7 +38,7 @@ import { buildGatewayCronService } from "./server-cron.js";
import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
import { coreGatewayHandlers } from "./server-methods.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 { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { loadGatewayPlugins } from "./server-plugins.js"; import { loadGatewayPlugins } from "./server-plugins.js";
@@ -69,14 +69,6 @@ const logReload = log.child("reload");
const logHooks = log.child("hooks"); const logHooks = log.child("hooks");
const logWsControl = log.child("ws"); const logWsControl = log.child("ws");
const canvasRuntime = runtimeForLogger(logCanvas); const canvasRuntime = runtimeForLogger(logCanvas);
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
) as Record<ChannelId, RuntimeEnv>;
const METHODS = GATEWAY_METHODS;
export type GatewayServer = { export type GatewayServer = {
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>; close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
@@ -163,13 +155,22 @@ export async function startGatewayServer(
await autoMigrateLegacyState({ cfg: cfgAtStart, log }); await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({ const baseMethods = listGatewayMethods();
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({
cfg: cfgAtStart, cfg: cfgAtStart,
workspaceDir: defaultWorkspaceDir, workspaceDir: defaultWorkspaceDir,
log, log,
coreGatewayHandlers, coreGatewayHandlers,
baseMethods: METHODS, baseMethods,
}); });
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
) as Record<ChannelId, RuntimeEnv>;
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods]));
let pluginServices: PluginServicesHandle | null = null; let pluginServices: PluginServicesHandle | null = null;
const runtimeConfig = await resolveGatewayRuntimeConfig({ const runtimeConfig = await resolveGatewayRuntimeConfig({
cfg: cfgAtStart, cfg: cfgAtStart,

View File

@@ -2,17 +2,17 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
DELIVERABLE_MESSAGE_CHANNELS, listDeliverableMessageChannels,
type DeliverableMessageChannel, type DeliverableMessageChannel,
normalizeMessageChannel, normalizeMessageChannel,
} from "../../utils/message-channel.js"; } from "../../utils/message-channel.js";
export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelId = DeliverableMessageChannel;
const MESSAGE_CHANNELS = [...DELIVERABLE_MESSAGE_CHANNELS]; const getMessageChannels = () => listDeliverableMessageChannels();
function isKnownChannel(value: string): value is MessageChannelId { 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 { function isAccountEnabled(account: unknown): boolean {

View File

@@ -102,4 +102,44 @@ describe("loadClawdbotPlugins", () => {
expect(registry.plugins[0]?.status).toBe("error"); expect(registry.plugins[0]?.status).toBe("error");
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); 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");
});
}); });

View File

@@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js"; import { discoverClawdbotPlugins } from "./discovery.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
import type { import type {
ClawdbotPluginConfigSchema, ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition, ClawdbotPluginDefinition,
@@ -188,6 +189,7 @@ function createPluginRecord(params: {
enabled: params.enabled, enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled", status: params.enabled ? "loaded" : "disabled",
toolNames: [], toolNames: [],
channelIds: [],
gatewayMethods: [], gatewayMethods: [],
cliCommands: [], cliCommands: [],
services: [], services: [],
@@ -211,7 +213,10 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
const cacheEnabled = options.cache !== false; const cacheEnabled = options.cache !== false;
if (cacheEnabled) { if (cacheEnabled) {
const cached = registryCache.get(cacheKey); const cached = registryCache.get(cacheKey);
if (cached) return cached; if (cached) {
setActivePluginRegistry(cached, cacheKey);
return cached;
}
} }
const { registry, createApi } = createPluginRegistry({ const { registry, createApi } = createPluginRegistry({
@@ -359,5 +364,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) { if (cacheEnabled) {
registryCache.set(cacheKey, registry); registryCache.set(cacheKey, registry);
} }
setActivePluginRegistry(registry, cacheKey);
return registry; return registry;
} }

View File

@@ -1,4 +1,6 @@
import type { AnyAgentTool } from "../agents/tools/common.js"; 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 { import type {
GatewayRequestHandler, GatewayRequestHandler,
GatewayRequestHandlers, GatewayRequestHandlers,
@@ -6,6 +8,7 @@ import type {
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import type { import type {
ClawdbotPluginApi, ClawdbotPluginApi,
ClawdbotPluginChannelRegistration,
ClawdbotPluginCliRegistrar, ClawdbotPluginCliRegistrar,
ClawdbotPluginService, ClawdbotPluginService,
ClawdbotPluginToolContext, ClawdbotPluginToolContext,
@@ -30,6 +33,13 @@ export type PluginCliRegistration = {
source: string; source: string;
}; };
export type PluginChannelRegistration = {
pluginId: string;
plugin: ChannelPlugin;
dock?: ChannelDock;
source: string;
};
export type PluginServiceRegistration = { export type PluginServiceRegistration = {
pluginId: string; pluginId: string;
service: ClawdbotPluginService; service: ClawdbotPluginService;
@@ -48,6 +58,7 @@ export type PluginRecord = {
status: "loaded" | "disabled" | "error"; status: "loaded" | "disabled" | "error";
error?: string; error?: string;
toolNames: string[]; toolNames: string[];
channelIds: string[];
gatewayMethods: string[]; gatewayMethods: string[];
cliCommands: string[]; cliCommands: string[];
services: string[]; services: string[];
@@ -58,6 +69,7 @@ export type PluginRecord = {
export type PluginRegistry = { export type PluginRegistry = {
plugins: PluginRecord[]; plugins: PluginRecord[];
tools: PluginToolRegistration[]; tools: PluginToolRegistration[];
channels: PluginChannelRegistration[];
gatewayHandlers: GatewayRequestHandlers; gatewayHandlers: GatewayRequestHandlers;
cliRegistrars: PluginCliRegistration[]; cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[]; services: PluginServiceRegistration[];
@@ -73,6 +85,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry: PluginRegistry = { const registry: PluginRegistry = {
plugins: [], plugins: [],
tools: [], tools: [],
channels: [],
gatewayHandlers: {}, gatewayHandlers: {},
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
@@ -129,6 +142,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.gatewayMethods.push(trimmed); 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 = ( const registerCli = (
record: PluginRecord, record: PluginRecord,
registrar: ClawdbotPluginCliRegistrar, registrar: ClawdbotPluginCliRegistrar,
@@ -179,6 +220,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
pluginConfig: params.pluginConfig, pluginConfig: params.pluginConfig,
logger: normalizeLogger(registryParams.logger), logger: normalizeLogger(registryParams.logger),
registerTool: (tool, opts) => registerTool(record, tool, opts), registerTool: (tool, opts) => registerTool(record, tool, opts),
registerChannel: (registration) => registerChannel(record, registration),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service), registerService: (service) => registerService(record, service),
@@ -191,6 +233,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
createApi, createApi,
pushDiagnostic, pushDiagnostic,
registerTool, registerTool,
registerChannel,
registerGatewayMethod, registerGatewayMethod,
registerCli, registerCli,
registerService, registerService,

View File

@@ -1,6 +1,8 @@
import type { Command } from "commander"; import type { Command } from "commander";
import type { AnyAgentTool } from "../agents/tools/common.js"; 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 { ClawdbotConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
@@ -78,6 +80,11 @@ export type ClawdbotPluginService = {
stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>; stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>;
}; };
export type ClawdbotPluginChannelRegistration = {
plugin: ChannelPlugin;
dock?: ChannelDock;
};
export type ClawdbotPluginDefinition = { export type ClawdbotPluginDefinition = {
id?: string; id?: string;
name?: string; name?: string;
@@ -105,6 +112,9 @@ export type ClawdbotPluginApi = {
tool: AnyAgentTool | ClawdbotPluginToolFactory, tool: AnyAgentTool | ClawdbotPluginToolFactory,
opts?: { name?: string; names?: string[] }, opts?: { name?: string; names?: string[] },
) => void; ) => void;
registerChannel: (
registration: ClawdbotPluginChannelRegistration | ChannelPlugin,
) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: ClawdbotPluginService) => void; registerService: (service: ClawdbotPluginService) => void;

View File

@@ -3,6 +3,7 @@ import {
listChatChannelAliases, listChatChannelAliases,
normalizeChatChannelId, normalizeChatChannelId,
} from "../channels/registry.js"; } from "../channels/registry.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { import {
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_NAMES,
@@ -11,6 +12,7 @@ import {
normalizeGatewayClientMode, normalizeGatewayClientMode,
normalizeGatewayClientName, normalizeGatewayClientName,
} from "../gateway/protocol/client-info.js"; } from "../gateway/protocol/client-info.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const;
export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL;
@@ -42,34 +44,56 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined
const normalized = raw?.trim().toLowerCase(); const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined; if (!normalized) return undefined;
if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL; 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 type GatewayMessageChannel = DeliverableMessageChannel | InternalMessageChannel;
export const GATEWAY_MESSAGE_CHANNELS = [ export const listGatewayMessageChannels = (): GatewayMessageChannel[] => [
...DELIVERABLE_MESSAGE_CHANNELS, ...listDeliverableMessageChannels(),
INTERNAL_MESSAGE_CHANNEL, 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 type GatewayAgentChannelHint = GatewayMessageChannel | "last";
export const GATEWAY_AGENT_CHANNEL_VALUES = Array.from( export const listGatewayAgentChannelValues = (): string[] =>
new Set([...GATEWAY_MESSAGE_CHANNELS, "last", ...GATEWAY_AGENT_CHANNEL_ALIASES]), Array.from(new Set([...listGatewayMessageChannels(), "last", ...listGatewayAgentChannelAliases()]));
);
export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel { 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 { 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( export function resolveGatewayMessageChannel(