feat(channels): add channel plugin system with types, registry, and config

Introduces the extensible channel plugin architecture for messaging platform integrations.
- ChannelPlugin interface with config, gateway, and outbound adapters
- Plugin registry with register/get/list operations
- Config loader for ~/.super-multica/channels.json5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-06 15:47:36 +08:00
parent 3b09d8d44d
commit 5d63727a04
3 changed files with 187 additions and 0 deletions

30
src/channels/config.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* Channel configuration loader.
*
* Reads ~/.super-multica/channels.json5 for channel credentials and settings.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import JSON5 from "json5";
import { DATA_DIR } from "../shared/paths.js";
import type { ChannelsConfig } from "./types.js";
export const CHANNELS_CONFIG_PATH = join(DATA_DIR, "channels.json5");
/** Load channels config from ~/.super-multica/channels.json5 */
export function loadChannelsConfig(): ChannelsConfig {
if (!existsSync(CHANNELS_CONFIG_PATH)) {
console.log(`[Channels] No channels.json5 found, skipping`);
return {};
}
try {
const raw = readFileSync(CHANNELS_CONFIG_PATH, "utf8");
const config = JSON5.parse(raw) as ChannelsConfig;
console.log(`[Channels] Loaded config from ${CHANNELS_CONFIG_PATH}`);
return config;
} catch (err) {
console.warn(`[Channels] Failed to parse ${CHANNELS_CONFIG_PATH}: ${err}`);
return {};
}
}

31
src/channels/registry.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Channel plugin registry.
*
* Simple array + Map registry. Plugins are registered at startup
* via registerChannel() and looked up by ID.
*/
import type { ChannelPlugin } from "./types.js";
const plugins: ChannelPlugin[] = [];
const pluginMap = new Map<string, ChannelPlugin>();
/** Register a channel plugin. Throws if ID is already registered. */
export function registerChannel(plugin: ChannelPlugin): void {
if (pluginMap.has(plugin.id)) {
throw new Error(`Channel plugin "${plugin.id}" is already registered`);
}
plugins.push(plugin);
pluginMap.set(plugin.id, plugin);
console.log(`[Channels] Registered plugin: ${plugin.id}`);
}
/** Get a registered channel plugin by ID */
export function getChannel(id: string): ChannelPlugin | undefined {
return pluginMap.get(id);
}
/** List all registered channel plugins */
export function listChannels(): readonly ChannelPlugin[] {
return plugins;
}

126
src/channels/types.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Channel plugin system types.
*
* Each messaging platform (Telegram, Discord, Feishu, etc.) implements the
* ChannelPlugin interface with three adapters: config, gateway, outbound.
*/
import type { BlockChunkerConfig } from "../hub/block-chunker.js";
// ─── Normalized Incoming Message ───
/** Platform-agnostic incoming message */
export interface ChannelMessage {
/** Unique message ID from the platform */
messageId: string;
/** Conversation ID (group ID or DM chat ID) */
conversationId: string;
/** Sender identifier on the platform */
senderId: string;
/** Plain text content */
text: string;
/** Chat type: "direct" (1:1) or "group" */
chatType: "direct" | "group";
}
// ─── Delivery Context ───
/** Context for sending a reply back to a specific conversation */
export interface DeliveryContext {
/** Channel plugin ID (e.g. "telegram", "discord") */
channel: string;
/** Account identifier (supports multi-account per channel) */
accountId: string;
/** Target conversation ID */
conversationId: string;
/** Original message ID (for reply-style responses) */
replyToMessageId?: string | undefined;
}
// ─── Config Adapter ───
/** Resolves and validates channel credentials from the config file */
export interface ChannelConfigAdapter<TAccount = Record<string, unknown>> {
/** List all configured account IDs for this channel */
listAccountIds(config: ChannelsConfig): string[];
/** Resolve a specific account's config */
resolveAccount(config: ChannelsConfig, accountId: string): TAccount | undefined;
/** Check if a given account config has all required credentials */
isConfigured(account: TAccount): boolean;
}
// ─── Gateway Adapter ───
/** Manages the lifecycle of a channel account connection (receiving messages) */
export interface ChannelGatewayAdapter {
/**
* Start receiving messages for an account.
* Must respect the AbortSignal for graceful shutdown.
*/
start(
accountId: string,
config: Record<string, unknown>,
onMessage: (message: ChannelMessage) => void,
signal: AbortSignal,
): Promise<void>;
}
// ─── Outbound Adapter ───
/** Sends messages back to the platform */
export interface ChannelOutboundAdapter {
/** Send a text message to a conversation */
sendText(ctx: DeliveryContext, text: string): Promise<void>;
/** Reply to a specific message */
replyText(ctx: DeliveryContext, text: string): Promise<void>;
}
// ─── Channel Plugin ───
/** The main plugin interface. Each channel implements this. */
export interface ChannelPlugin {
/** Unique channel identifier (e.g. "telegram", "discord", "feishu") */
readonly id: string;
/** Display metadata */
readonly meta: {
name: string;
description: string;
};
/** Optional chunker config override per channel */
readonly chunkerConfig?: BlockChunkerConfig | undefined;
/** Config resolution adapter */
readonly config: ChannelConfigAdapter;
/** Connection lifecycle adapter (receive messages) */
readonly gateway: ChannelGatewayAdapter;
/** Message sending adapter */
readonly outbound: ChannelOutboundAdapter;
}
// ─── Channels Config File Shape ───
/**
* Shape of ~/.super-multica/channels.json5
*
* Each top-level key is a channel ID. Under it, each key is an account ID.
* Example:
* {
* telegram: { default: { botToken: "xxx" } },
* discord: { default: { botToken: "xxx" } },
* }
*/
export interface ChannelsConfig {
[channelId: string]: {
[accountId: string]: Record<string, unknown>;
} | undefined;
}
// ─── Account State ───
export type ChannelAccountStatus = "stopped" | "starting" | "running" | "error";
export interface ChannelAccountState {
channelId: string;
accountId: string;
status: ChannelAccountStatus;
error?: string | undefined;
}