Channel 플러그인 작성하기
이 가이드는 가상의 Webhook 플랫폼을 예시로 삼아 Channel 플러그인을 처음부터 완성까지 작성하는 과정을 안내합니다.
개요
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를 시작합니다
- 설정 → Channels → 채널 추가를 엽니다
- 목록에 Webhook이 표시되는지 확인합니다
- 인스턴스를 생성하고, 자격 증명을 입력한 후 연결을 테스트합니다
개발 모드
개발 중에는 npm run dev (watch 모드)를 사용합니다. 코드 변경 후 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의 버전이 올바른지 확인합니다- zip 파일의 SHA-256 체크섬을 계산합니다
- zip 파일과 체크섬을 Marketplace 저장소에 제출합니다
- 검토 승인 후 플러그인이 CDN의
channel-manifest.json에 표시됩니다
개발 참고 사항
엔트리 파일 형식
플러그인 엔트리는 반드시 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 시스템 개요 — 시스템 아키텍처 개요