メインコンテンツまでスキップ

Channelプラグインを書く

このガイドでは、仮想のWebhookプラットフォームを例に、完全なChannelプラグインをゼロから作成する手順を説明します。

概要

Channelプラグインの最小構成:

my-channel-plugin/
├── elftia-channel.json # Manifest file (required)
├── package.json # npm package description
├── tsconfig.json # TypeScript configuration
├── src/
│ └── index.ts # Entry file (exports factory function)
└── dist/
└── index.cjs # Compiled output (CommonJS format)

ステップ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 plugin implementation
*/
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,
});

// Verify endpoint reachability
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}`,
);
}

// Start polling for new messages (example: every 5 seconds)
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();
}

// ─── Internal methods ──────────────────────────

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);
}
}
}

/**
* Factory function — plugin's default export
*/
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')

読み込みを確認する

  1. Elftiaを起動する
  2. Settings → Channels → Add Channel を開く
  3. Webhook が一覧に表示されることを確認する
  4. インスタンスを作成し、認証情報を入力して、接続をテストする

開発モード

開発中は npm run dev(watch mode)を使用します。コード変更後、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に提出します:

  1. elftia-channel.json のバージョンが正しいことを確認する
  2. zipファイルのSHA-256チェックサムを計算する
  3. zipファイルとチェックサムをMarketplaceリポジトリに提出する
  4. レビューで承認されると、プラグインはCDNの channel-manifest.json に表示される

開発メモ

エントリーファイル形式

Elftiaは require() でプラグインを読み込むため、プラグインエントリーは CommonJS 形式(.cjs または標準の .js)である必要があります:

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はステータス処理を標準化します。connectedconnecting にダウングレードしません(自動再接続による状態のちらつきを防ぐため)。

ストレージのベストプラクティス

  • セッションをまたいで永続化する必要がある状態(例: ポーリングオフセット)の保存には 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が現在のプラットフォームコンテキストを理解できるようになります。

次のステップ