Написание плагина для канала
Это руководство шаг за шагом описывает создание полноценного плагина Channel с нуля на примере гипотетической Webhook-платформы.
Обзор
Минимальная структура плагина Channel:
my-channel-plugin/
├── elftia-channel.json # Файл манифеста (обязательно)
├── package.json # Описание npm-пакета
├── tsconfig.json # Конфигурация TypeScript
├── src/
│ └── index.ts # Точка входа (экспортирует фабричную функцию)
└── dist/
└── index.cjs # Скомпилированный выход (формат CommonJS)
Шаг 1: Инициализация проекта
mkdir elftia-channel-webhook
cd elftia-channel-webhook
npm init -y
Установите зависимости для разработки:
npm install -D typescript tsup @elftia/channel-sdk
Шаг 2: Создание файла манифеста
Создайте файл elftia-channel.json:
{
"name": "elftia-channel-webhook",
"type": "webhook",
"displayName": "Webhook",
"version": "1.0.0",
"description": "Receive and send messages via HTTP Webhook",
"author": "Your Name",
"entry": "dist/index.cjs",
"credentials": [
{
"key": "incomingUrl",
"label": "Incoming Webhook URL",
"type": "text",
"required": true,
"placeholder": "https://example.com/webhook/incoming",
"helpText": "Webhook endpoint for receiving messages"
},
{
"key": "outgoingSecret",
"label": "Outgoing Secret",
"type": "password",
"required": false,
"helpText": "Secret key for validating outgoing requests"
}
],
"capabilities": {
"typing": false,
"reactions": false,
"attachments": false,
"threads": false,
"groupChat": false
},
"maxMessageLength": 10000
}
Шаг 3: Реализация плагина
Создайте файл src/index.ts:
import type {
ChannelPlugin,
ChannelPluginContext,
ChannelPluginFactory,
} from '@elftia/channel-sdk';
/**
* Реализация плагина Webhook Channel
*/
class WebhookPlugin implements ChannelPlugin {
readonly type = 'webhook';
private connected = false;
private credentials: Record<string, string> = {};
private pollTimer: ReturnType<typeof setInterval> | null = null;
constructor(private ctx: ChannelPluginContext) {}
async connect(
credentials: Record<string, string>,
_options?: Record<string, unknown>,
): Promise<void> {
this.credentials = credentials;
if (!credentials.incomingUrl) {
throw new Error('Incoming Webhook URL is required');
}
this.ctx.log.info('Connecting to webhook endpoint', {
url: credentials.incomingUrl,
});
// Проверяем доступность конечной точки
try {
const response = await fetch(credentials.incomingUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Endpoint returned ${response.status}`);
}
} catch (err) {
throw new Error(
`Cannot reach webhook endpoint: ${(err as Error).message}`,
);
}
// Запускаем опрос новых сообщений (пример: каждые 5 секунд)
this.pollTimer = setInterval(() => {
this.pollMessages().catch((err) => {
this.ctx.log.error('Poll error', err as Error);
});
}, 5000);
this.connected = true;
this.ctx.emitStatusChange('connected');
this.ctx.log.info('Connected successfully');
}
async disconnect(): Promise<void> {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.connected = false;
this.ctx.emitStatusChange('disconnected');
this.ctx.log.info('Disconnected');
}
isConnected(): boolean {
return this.connected;
}
async sendMessage(chatId: string, text: string): Promise<void> {
const url = this.credentials.incomingUrl;
if (!url) throw new Error('Not connected');
const body = JSON.stringify({
chatId,
text,
secret: this.credentials.outgoingSecret,
});
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
throw new Error(`Send failed: HTTP ${response.status}`);
}
this.ctx.log.debug('Message sent', { chatId, length: text.length });
}
async dispose(): Promise<void> {
await this.disconnect();
}
// ─── Внутренние методы ──────────────────────────
private async pollMessages(): Promise<void> {
const lastPollTime = await this.ctx.storage.get<string>('lastPollTime');
const since = lastPollTime || new Date(0).toISOString();
const url = `${this.credentials.incomingUrl}?since=${encodeURIComponent(since)}`;
const response = await fetch(url, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return;
const data = (await response.json()) as {
messages: Array<{
id: string;
chatId: string;
senderId: string;
senderName: string;
content: string;
timestamp: string;
}>;
};
for (const msg of data.messages) {
this.ctx.emitMessage({
id: msg.id,
chatId: msg.chatId,
senderId: msg.senderId,
senderName: msg.senderName,
content: msg.content,
timestamp: msg.timestamp,
isFromMe: false,
isGroup: false,
});
}
if (data.messages.length > 0) {
const latest = data.messages[data.messages.length - 1];
await this.ctx.storage.set('lastPollTime', latest.timestamp);
}
}
}
/**
* Фабричная функция — экспорт плагина по умолчанию
*/
const factory: ChannelPluginFactory = (ctx) => new WebhookPlugin(ctx);
export default factory;
Шаг 4: Настройка сборки
Создайте файл tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"declaration": false,
"skipLibCheck": true
},
"include": ["src"]
}
Добавьте скрипты сборки в package.json:
{
"name": "elftia-channel-webhook",
"version": "1.0.0",
"main": "dist/index.cjs",
"scripts": {
"build": "tsup src/index.ts --format cjs --outDir dist --clean",
"dev": "tsup src/index.ts --format cjs --outDir dist --watch"
},
"devDependencies": {
"@elftia/channel-sdk": "^1.0.0",
"tsup": "^8.0.0",
"typescript": "^5.6.0"
}
}
Выполните сборку плагина:
npm run build
Убедитесь, что файл dist/index.cjs создан.
Шаг 5: Локальное тестирование
Создайте символическую ссылку на директорию плагина в директории плагинов Elftia:
# Windows
mklink /J "%APPDATA%\elftia\channel-plugins\webhook" "C:\path\to\elftia-channel-webhook"
# macOS / Linux
ln -s /path/to/elftia-channel-webhook ~/.config/elftia/channel-plugins/webhook
Или воспользуйтесь функцией локальной установки Elftia:
ChannelPluginLoader.installFromLocal('/path/to/elftia-channel-webhook')
Проверка загрузки
- Запустите Elftia
- Откройте Настройки → Каналы → Добавить канал
- Убедитесь, что Webhook отображается в списке
- Создайте экземпляр, заполните учётные данные, проверьте подключение
Режим разработки
Во время разработки используйте npm run dev (режим отслеживания): tsup будет автоматически перекомпилировать код после изменений. Для загрузки последнего кода перезапустите Elftia.
Шаг 6: Публикация в Marketplace
Упаковка
Упакуйте плагин в файл .zip (в корне архива должен находиться elftia-channel.json):
cd elftia-channel-webhook
zip -r elftia-channel-webhook-1.0.0.zip \
elftia-channel.json \
package.json \
dist/
Публикация
Отправьте плагин в Elftia Channel Plugin Marketplace:
- Убедитесь, что версия в
elftia-channel.jsonуказана корректно - Вычислите контрольную сумму SHA-256 zip-файла
- Отправьте zip-файл и контрольную сумму в репозиторий Marketplace
- После прохождения проверки плагин появится в
channel-manifest.jsonCDN
Примечания по разработке
Формат точки входа
Точка входа плагина должна быть в формате CommonJS (.cjs или обычный .js), так как Elftia загружает плагины через require():
const mod = require(entryPath);
const factory = mod.default || mod;
Обработка ошибок
- Ошибки в
connect()перехватываются Registry, и экземпляр переводится в состояниеerror - Ошибки в
sendMessage()перехватываются Router и записываются в журнал - Для записи внутренних ошибок используйте
ctx.log.error() - Всегда устанавливайте тайм-ауты для сетевых запросов
Отчёт о статусе
Используйте ctx.emitStatusChange() для своевременного сообщения об изменениях статуса:
| Статус | Когда сообщать |
|---|---|
connecting | Начало установки соединения |
connected | Соединение установлено успешно |
disconnected | Активное отключение |
error | Ошибка подключения или ошибка в процессе работы |
reconnecting | Автоматическое переподключение |
Registry стандартизирует обработку статусов: connected не будет понижен до connecting (предотвращает мерцание состояния при автоматическом переподключении).
Рекомендации по хранилищу
- Используйте
ctx.storageдля хранения состояний, которые должны сохраняться между сеансами (например, смещение опроса) - Значения сериализуются в JSON; убедитесь, что сохраняемые данные поддерживают сериализацию
- Фреймворк автоматически очищает все данные хранилища экземпляра при его удалении
Директория данных
ctx.dataDir предоставляет постоянную директорию файловой системы, подходящую для хранения:
- Загруженных файлов (например, полученных изображений)
- Кэш-данных
- Временных файлов
Директория создаётся фреймворком автоматически по пути: {userData}/channel-data/{channelId}/
Внедрение системного промпта
Если ваша платформа использует особые форматы сообщений или теги возможностей, реализуйте метод getSystemPrompt():
getSystemPrompt(context: SystemPromptContext): string | undefined {
if (context.isGroup) {
return `You are in a Webhook group chat. Reply with plain text format.`;
}
return `You are having a Webhook direct chat with ${context.senderName}.`;
}
Возвращаемый текст будет добавлен к базовому системному промпту Agent, позволяя AI понять текущий контекст платформы.
Дальнейшие шаги
- Channel Plugin SDK — Полный справочник интерфейсов и типов
- Маршрутизация сообщений и конвейер безопасности — Полный поток обработки сообщений, поступающих в Agent
- Обзор системы Channel — Обзор архитектуры системы