从零构建 AI 网关(三):渠道插件系统
本系列文章带你从零构建一个类似 OpenClaw 的多渠道 AI 网关。本文是第三篇,重点讲解插件系统架构和渠道集成。
🎯 Phase 3 目标
在 Phase 1 和 Phase 2 中,我们实现了 Gateway 核心和消息系统。现在需要:
- 设计插件系统:让渠道可以动态加载
- 定义 Channel 接口:统一不同渠道的操作
- 实现 Telegram 渠道:接入 Telegram Bot API
- 实现 Discord 渠道:接入 Discord Bot API
📐 整体设计
markdown
┌─────────────────────────────────────────────────────────┐
│ Plugin Manager │
│ 加载、注册、管理所有渠道插件 │
└─────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Channel Interface │
│ 统一的渠道接口定义(发送、接收、状态等) │
└─────────────────────┬───────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│Telegram │ │ Discord │ │ ... │
│ Channel │ │ Channel │ │ │
└─────────┘ └─────────┘ └─────────┘
1. 插件系统设计
1.1 核心概念
参考 OpenClaw 的设计,每个渠道插件需要提供:
| 组件 | 作用 |
|---|---|
| meta | 渠道元信息(ID、名称、能力) |
| config | 配置管理(账号、Token 等) |
| capabilities | 能力声明(支持的消息类型) |
| outbound | 发送消息的能力 |
| inbound | 接收消息的处理 |
| status | 状态监控和诊断 |
1.2 插件接口定义
javascript
// src/plugins/types.js
/**
* 渠道元信息
*/
const ChannelMeta = {
id: String, // 渠道 ID,如 "telegram"
name: String, // 显示名称
description: String, // 描述
icon: String, // 图标 emoji
};
/**
* 渠道能力
*/
const ChannelCapabilities = {
chatTypes: Array, // 支持的聊天类型:direct, group, channel
reactions: Boolean, // 支持反应表情
threads: Boolean, // 支持线程
media: Boolean, // 支持媒体
polls: Boolean, // 支持投票
streaming: Boolean, // 支持流式输出
};
/**
* 渠道插件接口
*/
const ChannelPlugin = {
// 元信息
id: String,
meta: ChannelMeta,
// 能力声明
capabilities: ChannelCapabilities,
// 配置
config: {
schema: Object, // JSON Schema
listAccountIds: Function, // 列出所有账号
resolveAccount: Function, // 解析账号配置
},
// 发送消息
outbound: {
sendText: Function, // 发送文本
sendMedia: Function, // 发送媒体
sendReaction: Function, // 发送反应
},
// 接收消息(通过回调)
inbound: {
start: Function, // 启动监听
stop: Function, // 停止监听
},
// 状态
status: {
probe: Function, // 探测连接
getStats: Function, // 获取统计
},
};
export { ChannelMeta, ChannelCapabilities, ChannelPlugin };
1.3 插件管理器
javascript
// src/plugins/manager.js
import { promises as fs } from 'fs';
import path from 'path';
/**
* 插件管理器
* 负责加载、注册和管理所有渠道插件
*/
class PluginManager {
constructor(options = {}) {
this.pluginDirs = options.pluginDirs || ['./plugins'];
this.plugins = new Map(); // id -> plugin
this.channels = new Map(); // channelId -> channel
}
/**
* 加载所有插件
*/
async loadAll() {
for (const dir of this.pluginDirs) {
await this.loadFromDir(dir);
}
}
/**
* 从目录加载插件
*/
async loadFromDir(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pluginPath = path.join(dir, entry.name);
await this.loadPlugin(pluginPath);
}
} catch (err) {
console.error(`Failed to load plugins from ${dir}:`, err.message);
}
}
/**
* 加载单个插件
*/
async loadPlugin(pluginPath) {
try {
// 动态导入插件
const plugin = await import(path.join(pluginPath, 'index.js'));
if (!plugin.default || !plugin.default.id) {
console.error(`Invalid plugin: ${pluginPath}`);
return;
}
const pluginInstance = plugin.default;
// 注册插件
this.plugins.set(pluginInstance.id, pluginInstance);
// 如果有渠道,注册渠道
if (pluginInstance.channels) {
for (const channelId of pluginInstance.channels) {
this.channels.set(channelId, pluginInstance);
}
}
console.log(`✅ Loaded plugin: ${pluginInstance.id}`);
} catch (err) {
console.error(`Failed to load plugin ${pluginPath}:`, err.message);
}
}
/**
* 注册插件(编程方式)
*/
register(plugin) {
if (!plugin.id) {
throw new Error('Plugin must have an id');
}
this.plugins.set(plugin.id, plugin);
if (plugin.channels) {
for (const channelId of plugin.channels) {
this.channels.set(channelId, plugin);
}
}
console.log(`✅ Registered plugin: ${plugin.id}`);
}
/**
* 获取渠道
*/
getChannel(channelId) {
return this.channels.get(channelId);
}
/**
* 获取插件
*/
getPlugin(pluginId) {
return this.plugins.get(pluginId);
}
/**
* 列出所有渠道
*/
listChannels() {
return Array.from(this.channels.keys());
}
/**
* 列出所有插件
*/
listPlugins() {
return Array.from(this.plugins.values()).map(p => ({
id: p.id,
name: p.name,
channels: p.channels || [],
}));
}
}
export { PluginManager };
2. Channel 基类
2.1 基础抽象类
javascript
// src/plugins/base.js
/**
* 渠道基类
* 所有渠道插件需要继承此类
*/
class BaseChannel {
constructor(options = {}) {
this.id = options.id;
this.config = options.config || {};
this.gateway = options.gateway;
this.started = false;
}
/**
* 渠道元信息(子类必须实现)
*/
static meta = {
id: '',
name: '',
description: '',
icon: '📡',
};
/**
* 能力声明(子类可以覆盖)
*/
static capabilities = {
chatTypes: ['direct'],
reactions: false,
threads: false,
media: false,
polls: false,
streaming: false,
};
/**
* 启动渠道(子类必须实现)
*/
async start() {
throw new Error('start() must be implemented');
}
/**
* 停止渠道
*/
async stop() {
this.started = false;
}
/**
* 发送文本消息(子类必须实现)
*/
async sendText(to, text, options = {}) {
throw new Error('sendText() must be implemented');
}
/**
* 发送媒体消息
*/
async sendMedia(to, mediaUrl, caption, options = {}) {
throw new Error('sendMedia() not supported');
}
/**
* 发送反应表情
*/
async sendReaction(to, emoji, options = {}) {
throw new Error('sendReaction() not supported');
}
/**
* 处理入站消息(由子类调用)
*/
async handleInbound(message) {
if (!this.gateway) {
console.error('Gateway not set');
return;
}
// 标准化消息格式
const normalized = this.normalizeInbound(message);
// 交给 Gateway 处理
await this.gateway.handleInbound(normalized);
}
/**
* 标准化入站消息
*/
normalizeInbound(raw) {
return {
id: raw.id,
channel: this.id,
from: raw.from,
to: raw.to,
body: raw.body || raw.text,
timestamp: raw.timestamp || Date.now(),
groupId: raw.groupId,
channelId: raw.channelId,
threadId: raw.threadId,
replyTo: raw.replyTo,
media: raw.media,
raw,
};
}
/**
* 探测连接状态
*/
async probe() {
return { ok: true, message: 'Not implemented' };
}
/**
* 获取统计信息
*/
getStats() {
return {
started: this.started,
messagesReceived: 0,
messagesSent: 0,
};
}
}
export { BaseChannel };
3. Telegram 渠道实现
3.1 插件结构
bash
plugins/telegram/
├── index.js # 插件入口
├── channel.js # 渠道实现
└── package.json
3.2 完整实现
javascript
// plugins/telegram/index.js
import { TelegramChannel } from './channel.js';
const telegramPlugin = {
id: 'telegram',
name: 'Telegram',
description: 'Telegram Bot API 渠道',
channels: ['telegram'],
// 配置 Schema
configSchema: {
type: 'object',
properties: {
botToken: {
type: 'string',
description: 'Telegram Bot Token',
},
webhookUrl: {
type: 'string',
description: 'Webhook URL(可选)',
},
},
required: ['botToken'],
},
// 注册函数
register(api) {
const channel = new TelegramChannel({
config: api.config?.channels?.telegram || {},
gateway: api.gateway,
});
api.registerChannel({
id: 'telegram',
channel,
});
},
};
export default telegramPlugin;
javascript
// plugins/telegram/channel.js
import { BaseChannel } from '../../src/plugins/base.js';
/**
* Telegram 渠道
* 使用 Bot API 实现消息收发
*/
class TelegramChannel extends BaseChannel {
static meta = {
id: 'telegram',
name: 'Telegram',
description: 'Telegram Bot API',
icon: '📱',
};
static capabilities = {
chatTypes: ['direct', 'group', 'channel'],
reactions: true,
threads: true,
media: true,
polls: true,
streaming: true,
};
constructor(options) {
super(options);
this.botToken = options.config?.botToken || process.env.TELEGRAM_BOT_TOKEN;
this.apiBase = 'https://api.telegram.org/bot';
this.offset = 0;
this.pollingInterval = null;
this.stats = {
messagesReceived: 0,
messagesSent: 0,
};
}
/**
* 启动轮询
*/
async start() {
if (!this.botToken) {
throw new Error('Telegram bot token not configured');
}
// 验证 Token
const me = await this.apiCall('getMe');
if (!me.ok) {
throw new Error(`Failed to verify bot: ${me.description}`);
}
console.log(`✅ Telegram bot connected: @${me.result.username}`);
this.started = true;
// 开始长轮询
this.startPolling();
}
/**
* 停止轮询
*/
async stop() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
this.started = false;
console.log('🛑 Telegram bot stopped');
}
/**
* 开始长轮询
*/
startPolling() {
const poll = async () => {
if (!this.started) return;
try {
const updates = await this.apiCall('getUpdates', {
offset: this.offset,
timeout: 30,
allowed_updates: ['message', 'edited_message', 'callback_query'],
});
if (updates.ok && updates.result.length > 0) {
for (const update of updates.result) {
this.offset = update.update_id + 1;
await this.processUpdate(update);
}
}
} catch (err) {
console.error('Telegram polling error:', err.message);
}
};
// 立即执行一次
poll();
// 设置轮询间隔
this.pollingInterval = setInterval(poll, 1000);
}
/**
* 处理更新
*/
async processUpdate(update) {
// 处理消息
if (update.message) {
await this.handleMessage(update.message);
}
// 处理回调查询
if (update.callback_query) {
await this.handleCallbackQuery(update.callback_query);
}
}
/**
* 处理消息
*/
async handleMessage(message) {
const from = {
id: message.from.id.toString(),
username: message.from.username,
firstName: message.from.first_name,
lastName: message.from.last_name,
};
const chat = message.chat;
const isGroup = chat.type === 'group' || chat.type === 'supergroup';
// 构建标准化消息
const normalized = {
id: message.message_id.toString(),
channel: 'telegram',
from,
to: { id: chat.id.toString() },
body: message.text || '',
timestamp: message.date * 1000,
groupId: isGroup ? chat.id.toString() : undefined,
threadId: message.message_thread_id?.toString(),
replyTo: message.reply_to_message?.message_id?.toString(),
media: this.extractMedia(message),
raw: message,
};
this.stats.messagesReceived++;
await this.handleInbound(normalized);
}
/**
* 提取媒体
*/
extractMedia(message) {
if (message.photo) {
const largest = message.photo[message.photo.length - 1];
return {
type: 'photo',
fileId: largest.file_id,
};
}
if (message.video) {
return {
type: 'video',
fileId: message.video.file_id,
};
}
if (message.document) {
return {
type: 'document',
fileId: message.document.file_id,
fileName: message.document.file_name,
};
}
return null;
}
/**
* 处理回调查询
*/
async handleCallbackQuery(query) {
// 回应查询
await this.apiCall('answerCallbackQuery', {
callback_query_id: query.id,
});
// 构建消息
const normalized = {
id: query.id,
channel: 'telegram',
from: {
id: query.from.id.toString(),
username: query.from.username,
},
to: { id: query.message?.chat?.id?.toString() },
body: query.data,
timestamp: Date.now(),
type: 'callback',
raw: query,
};
this.stats.messagesReceived++;
await this.handleInbound(normalized);
}
/**
* 发送文本消息
*/
async sendText(to, text, options = {}) {
const params = {
chat_id: to,
text,
parse_mode: options.parseMode || 'Markdown',
};
if (options.replyTo) {
params.reply_to_message_id = options.replyTo;
}
if (options.threadId) {
params.message_thread_id = options.threadId;
}
const result = await this.apiCall('sendMessage', params);
this.stats.messagesSent++;
return {
ok: result.ok,
messageId: result.result?.message_id?.toString(),
error: result.description,
};
}
/**
* 发送媒体
*/
async sendMedia(to, mediaUrl, caption, options = {}) {
const method = mediaUrl.match(/\.(jpg|jpeg|png|gif)$/i)
? 'sendPhoto'
: 'sendDocument';
const params = {
chat_id: to,
[method === 'sendPhoto' ? 'photo' : 'document']: mediaUrl,
caption,
parse_mode: options.parseMode || 'Markdown',
};
const result = await this.apiCall(method, params);
this.stats.messagesSent++;
return {
ok: result.ok,
messageId: result.result?.message_id?.toString(),
};
}
/**
* 发送反应表情
*/
async sendReaction(to, emoji, options = {}) {
const result = await this.apiCall('setMessageReaction', {
chat_id: to,
message_id: options.messageId,
reaction: [{ type: 'emoji', emoji }],
});
return { ok: result.ok };
}
/**
* API 调用
*/
async apiCall(method, params = {}) {
const url = `${this.apiBase}${this.botToken}/${method}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return await response.json();
} catch (err) {
return { ok: false, description: err.message };
}
}
/**
* 探测连接
*/
async probe() {
try {
const result = await this.apiCall('getMe');
if (result.ok) {
return {
ok: true,
bot: {
id: result.result.id,
username: result.result.username,
firstName: result.result.first_name,
},
};
}
return { ok: false, error: result.description };
} catch (err) {
return { ok: false, error: err.message };
}
}
/**
* 获取统计
*/
getStats() {
return {
started: this.started,
...this.stats,
};
}
}
export { TelegramChannel };
4. Discord 渠道实现
javascript
// plugins/discord/index.js
import { DiscordChannel } from './channel.js';
const discordPlugin = {
id: 'discord',
name: 'Discord',
description: 'Discord Bot 渠道',
channels: ['discord'],
configSchema: {
type: 'object',
properties: {
botToken: {
type: 'string',
description: 'Discord Bot Token',
},
applicationId: {
type: 'string',
description: 'Discord Application ID',
},
},
required: ['botToken'],
},
register(api) {
const channel = new DiscordChannel({
config: api.config?.channels?.discord || {},
gateway: api.gateway,
});
api.registerChannel({
id: 'discord',
channel,
});
},
};
export default discordPlugin;
javascript
// plugins/discord/channel.js
import { BaseChannel } from '../../src/plugins/base.js';
/**
* Discord 渠道
* 使用 Gateway API 实现消息收发
*/
class DiscordChannel extends BaseChannel {
static meta = {
id: 'discord',
name: 'Discord',
description: 'Discord Bot',
icon: '🎮',
};
static capabilities = {
chatTypes: ['direct', 'group', 'channel'],
reactions: true,
threads: true,
media: true,
polls: false,
streaming: true,
};
constructor(options) {
super(options);
this.botToken = options.config?.botToken || process.env.DISCORD_BOT_TOKEN;
this.applicationId = options.config?.applicationId;
this.apiBase = 'https://discord.com/api/v10';
this.ws = null;
this.heartbeatInterval = null;
this.sessionId = null;
this.stats = {
messagesReceived: 0,
messagesSent: 0,
};
}
/**
* 启动 Discord Gateway 连接
*/
async start() {
if (!this.botToken) {
throw new Error('Discord bot token not configured');
}
// 获取 Gateway URL
const gateway = await this.apiCall('GET', '/gateway');
if (!gateway.url) {
throw new Error('Failed to get Discord gateway URL');
}
// 连接 WebSocket
await this.connectWebSocket(gateway.url);
}
/**
* 连接 WebSocket
*/
async connectWebSocket(url) {
return new Promise((resolve, reject) => {
const wsUrl = `${url}?v=10&encoding=json`;
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('🔌 Discord Gateway connected');
});
this.ws.on('message', (data) => {
const payload = JSON.parse(data.toString());
this.handleGatewayPayload(payload, resolve);
});
this.ws.on('error', (err) => {
console.error('Discord WebSocket error:', err.message);
reject(err);
});
this.ws.on('close', () => {
console.log('Discord WebSocket closed');
this.started = false;
});
});
}
/**
* 处理 Gateway 消息
*/
handleGatewayPayload(payload, resolve) {
const { op, d, t, s } = payload;
switch (op) {
case 10: // Hello
this.startHeartbeat(d.heartbeat_interval);
this.identify();
break;
case 11: // Heartbeat ACK
break;
case 0: // Dispatch
this.handleDispatch(t, d);
if (t === 'READY') {
this.sessionId = d.session_id;
this.started = true;
console.log(`✅ Discord bot ready: ${d.user.username}`);
resolve();
}
break;
}
}
/**
* 开始心跳
*/
startHeartbeat(interval) {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.heartbeatInterval = setInterval(() => {
if (this.ws && this.ws.readyState === 1) {
this.ws.send(JSON.stringify({ op: 1, d: null }));
}
}, interval);
}
/**
* 发送 Identify
*/
identify() {
const payload = {
op: 2,
d: {
token: this.botToken,
intents: 513, // Guilds + GuildMessages + DirectMessages
properties: {
os: 'linux',
browser: 'myclaw',
device: 'myclaw',
},
},
};
this.ws.send(JSON.stringify(payload));
}
/**
* 处理事件
*/
async handleDispatch(event, data) {
switch (event) {
case 'MESSAGE_CREATE':
await this.handleMessage(data);
break;
case 'MESSAGE_REACTION_ADD':
await this.handleReaction(data);
break;
}
}
/**
* 处理消息
*/
async handleMessage(message) {
// 忽略机器人消息
if (message.author.bot) return;
const from = {
id: message.author.id,
username: message.author.username,
discriminator: message.author.discriminator,
};
const isDM = !message.guild_id;
const isThread = message.thread?.id != null;
const normalized = {
id: message.id,
channel: 'discord',
from,
to: { id: message.channel_id },
body: message.content,
timestamp: Date.parse(message.timestamp),
groupId: message.guild_id,
channelId: message.channel_id,
threadId: message.thread?.id,
replyTo: message.message_reference?.message_id,
raw: message,
};
this.stats.messagesReceived++;
await this.handleInbound(normalized);
}
/**
* 发送文本消息
*/
async sendText(to, text, options = {}) {
const channelId = typeof to === 'string' ? to : to.id;
const body = {
content: text,
};
if (options.replyTo) {
body.message_reference = {
message_id: options.replyTo,
};
}
const result = await this.apiCall(
'POST',
`/channels/${channelId}/messages`,
body
);
this.stats.messagesSent++;
return {
ok: !!result.id,
messageId: result.id,
};
}
/**
* API 调用
*/
async apiCall(method, path, body = null) {
const url = `${this.apiBase}${path}`;
const options = {
method,
headers: {
'Authorization': `Bot ${this.botToken}`,
'Content-Type': 'application/json',
},
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
return await response.json();
} catch (err) {
return { error: err.message };
}
}
/**
* 停止
*/
async stop() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.ws) {
this.ws.close();
}
this.started = false;
}
/**
* 获取统计
*/
getStats() {
return {
started: this.started,
...this.stats,
};
}
}
export { DiscordChannel };
5. 整合到 Gateway
javascript
// src/gateway/index.js(更新)
import { PluginManager } from '../plugins/manager.js';
class Gateway {
constructor(options = {}) {
// ... 原有代码 ...
// 插件管理器
this.plugins = new PluginManager();
// 已注册的渠道
this.channels = new Map();
}
/**
* 注册渠道
*/
registerChannel({ id, channel }) {
this.channels.set(id, channel);
channel.gateway = this;
console.log(`✅ Registered channel: ${id}`);
}
/**
* 启动所有渠道
*/
async startChannels() {
for (const [id, channel] of this.channels) {
try {
await channel.start();
} catch (err) {
console.error(`Failed to start channel ${id}:`, err.message);
}
}
}
/**
* 处理入站消息
*/
async handleInbound(message) {
// 使用消息路由器
const { agentId, sessionKey } = this.router.route(message);
// ... 后续处理 ...
}
/**
* 发送出站消息
*/
async sendOutbound(message) {
const channel = this.channels.get(message.channel);
if (!channel) {
console.error(`Unknown channel: ${message.channel}`);
return;
}
return channel.sendText(message.to, message.body, {
replyTo: message.replyTo,
});
}
}
📊 代码统计
| 模块 | 文件 | 行数 |
|---|---|---|
| Plugin Types | plugins/types.js | ~50 行 |
| Plugin Manager | plugins/manager.js | ~100 行 |
| Base Channel | plugins/base.js | ~80 行 |
| Telegram Channel | plugins/telegram/ | ~250 行 |
| Discord Channel | plugins/discord/ | ~200 行 |
| Gateway 集成 | gateway/index.js | ~50 行(新增) |
| 总计 | ~730 行 |
🎯 Phase 3 完成清单
- 插件系统设计
- Plugin Manager 实现
- Channel 基类定义
- Telegram 渠道实现
- Discord 渠道实现
- Gateway 集成
🔜 Phase 4 预告
AI 智能体
- 设计 AI Provider 接口
- 实现 OpenAI Provider
- 支持流式响应
- 实现工具调用
📚 参考资源
本篇完成了渠道插件系统的设计与实现。下一篇将接入 LLM,让 MyClaw 拥有真正的智能。