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:
parent
3b09d8d44d
commit
5d63727a04
3 changed files with 187 additions and 0 deletions
30
src/channels/config.ts
Normal file
30
src/channels/config.ts
Normal 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
31
src/channels/registry.ts
Normal 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
126
src/channels/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue