diff --git a/src/channels/config.ts b/src/channels/config.ts new file mode 100644 index 00000000..0d897e68 --- /dev/null +++ b/src/channels/config.ts @@ -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 {}; + } +} diff --git a/src/channels/registry.ts b/src/channels/registry.ts new file mode 100644 index 00000000..1eb27efd --- /dev/null +++ b/src/channels/registry.ts @@ -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(); + +/** 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; +} diff --git a/src/channels/types.ts b/src/channels/types.ts new file mode 100644 index 00000000..32f165fb --- /dev/null +++ b/src/channels/types.ts @@ -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> { + /** 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, + onMessage: (message: ChannelMessage) => void, + signal: AbortSignal, + ): Promise; +} + +// ─── Outbound Adapter ─── + +/** Sends messages back to the platform */ +export interface ChannelOutboundAdapter { + /** Send a text message to a conversation */ + sendText(ctx: DeliveryContext, text: string): Promise; + /** Reply to a specific message */ + replyText(ctx: DeliveryContext, text: string): Promise; +} + +// ─── 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; + } | undefined; +} + +// ─── Account State ─── + +export type ChannelAccountStatus = "stopped" | "starting" | "running" | "error"; + +export interface ChannelAccountState { + channelId: string; + accountId: string; + status: ChannelAccountStatus; + error?: string | undefined; +}