소개
OpenClaw의 아키텍처는 "모든 것이 플러그인"이라는 철학을 따릅니다. Telegram, Discord, Slack 등 채팅 플랫폼에 대한 지원은 모두 Channel 플러그인을 통해 구현됩니다. OpenClaw가 아직 지원하지 않는 채팅 플랫폼에 연결하거나 완전히 사용자 정의된 메시징 채널을 만들어야 할 때, 직접 Channel 플러그인을 개발해야 합니다.
이 문서에서는 처음부터 Channel 플러그인의 개발부터 배포까지 전체 과정을 안내합니다.
플러그인 아키텍처 개요
Channel 플러그인의 역할
Channel 플러그인은 다음을 담당합니다:
외부 플랫폼 ←→ Channel 플러그인 ←→ OpenClaw 코어 ←→ AI 에이전트
│
├── 외부 메시지를 수신하여 OpenClaw 내부 형식으로 변환
├── AI 응답을 외부 플랫폼의 메시지 형식으로 변환
├── 외부 플랫폼과의 연결 관리 (WebSocket / HTTP / 폴링)
└── 플랫폼별 기능 처리 (버튼, 카드, 미디어 등)
플러그인 디렉터리 구조
my-channel-plugin/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # 플러그인 진입점
│ ├── channel.ts # 채널 구현
│ ├── message.ts # 메시지 변환
│ ├── connection.ts # 연결 관리
│ └── types.ts # 타입 정의
├── test/
│ ├── channel.test.ts
│ └── message.test.ts
└── README.md
개발 환경 설정
프로젝트 초기화
mkdir openclaw-channel-myplatform
cd openclaw-channel-myplatform
npm init -y
npm install @openclaw/plugin-sdk typescript
npm install -D @types/node vitest
TypeScript 설정
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src/**/*"]
}
package.json 플러그인 메타데이터
{
"name": "openclaw-channel-myplatform",
"version": "1.0.0",
"description": "OpenClaw Channel plugin for MyPlatform",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"openclaw": {
"type": "channel",
"name": "myplatform",
"displayName": "MyPlatform",
"version": "1.0.0",
"minGatewayVersion": "0.8.0"
}
}
채널 인터페이스 구현
핵심 인터페이스 정의
OpenClaw Plugin SDK는 Channel 플러그인이 구현해야 하는 인터페이스를 정의합니다:
// src/types.ts
import {
ChannelPlugin,
ChannelConfig,
IncomingMessage,
OutgoingMessage,
ChannelConnection,
ChannelCapabilities
} from "@openclaw/plugin-sdk";
export interface MyPlatformConfig extends ChannelConfig {
apiToken: string;
apiEndpoint?: string;
webhookPort?: number;
}
플러그인 진입점 구현
// src/index.ts
import { PluginDefinition } from "@openclaw/plugin-sdk";
import { MyPlatformChannel } from "./channel";
const plugin: PluginDefinition = {
name: "myplatform",
displayName: "MyPlatform",
type: "channel",
// 설정 스키마 선언 (Dashboard UI 생성에 사용)
configSchema: {
apiToken: {
type: "string",
required: true,
secret: true,
label: "API Token",
description: "Bot API Token for MyPlatform"
},
apiEndpoint: {
type: "string",
required: false,
default: "https://api.myplatform.com",
label: "API Endpoint"
}
},
// Channel 인스턴스 생성
createChannel(config: MyPlatformConfig) {
return new MyPlatformChannel(config);
}
};
export default plugin;
채널 클래스 구현
// src/channel.ts
import {
ChannelPlugin,
IncomingMessage,
OutgoingMessage,
ChannelCapabilities,
MessageHandler
} from "@openclaw/plugin-sdk";
import { MyPlatformConfig } from "./types";
import { MyPlatformConnection } from "./connection";
import { convertIncoming, convertOutgoing } from "./message";
export class MyPlatformChannel implements ChannelPlugin {
private config: MyPlatformConfig;
private connection: MyPlatformConnection;
private messageHandler: MessageHandler | null = null;
constructor(config: MyPlatformConfig) {
this.config = config;
this.connection = new MyPlatformConnection(config);
}
// 플러그인 기능 선언
getCapabilities(): ChannelCapabilities {
return {
supportsDM: true,
supportsGroup: true,
supportsMedia: true,
supportsButtons: true,
supportsEditing: true,
supportsReactions: false,
supportsThreads: false,
supportsVoice: false,
maxMessageLength: 4096,
supportedMediaTypes: ["image", "file", "video"]
};
}
// 연결 시작
async start(handler: MessageHandler): Promise<void> {
this.messageHandler = handler;
// 외부 플랫폼에 연결
await this.connection.connect();
// 수신 메시지 리스닝
this.connection.on("message", async (rawMessage) => {
const message = convertIncoming(rawMessage);
if (this.messageHandler) {
await this.messageHandler(message);
}
});
console.log("[MyPlatform] Channel started successfully");
}
// 메시지 전송
async sendMessage(message: OutgoingMessage): Promise<string> {
const platformMessage = convertOutgoing(message);
const result = await this.connection.send(platformMessage);
return result.messageId; // 플랫폼 메시지 ID 반환
}
// 전송된 메시지 편집
async editMessage(messageId: string, message: OutgoingMessage): Promise<void> {
const platformMessage = convertOutgoing(message);
await this.connection.edit(messageId, platformMessage);
}
// 연결 중지
async stop(): Promise<void> {
await this.connection.disconnect();
console.log("[MyPlatform] Channel stopped");
}
// 헬스 체크
async healthCheck(): Promise<boolean> {
return this.connection.isConnected();
}
}
메시지 변환 구현
// src/message.ts
import { IncomingMessage, OutgoingMessage } from "@openclaw/plugin-sdk";
// 플랫폼 메시지를 OpenClaw 내부 형식으로 변환
export function convertIncoming(raw: any): IncomingMessage {
return {
id: raw.id,
userId: raw.sender.id,
userName: raw.sender.name,
channelId: raw.chat.id,
channelType: raw.chat.type === "private" ? "dm" : "group",
content: raw.text || "",
// 미디어 첨부 파일
attachments: (raw.attachments || []).map((a: any) => ({
type: a.type,
url: a.url,
filename: a.name,
mimeType: a.mimeType,
size: a.size
})),
// 원본 데이터 (고급 사용용)
rawData: raw,
timestamp: new Date(raw.timestamp)
};
}
// OpenClaw 응답을 플랫폼 메시지 형식으로 변환
export function convertOutgoing(message: OutgoingMessage): any {
const result: any = {
chat_id: message.channelId,
text: message.content
};
// 리치 텍스트 포맷팅 처리
if (message.format === "markdown") {
result.parse_mode = "Markdown";
}
// 미디어 처리
if (message.attachments?.length) {
result.attachments = message.attachments.map(a => ({
type: a.type,
url: a.url,
caption: a.caption
}));
}
// 버튼 처리
if (message.buttons?.length) {
result.keyboard = message.buttons.map(b => ({
text: b.label,
callback_data: b.action
}));
}
return result;
}
연결 관리
// src/connection.ts
import { EventEmitter } from "events";
import { MyPlatformConfig } from "./types";
export class MyPlatformConnection extends EventEmitter {
private config: MyPlatformConfig;
private ws: WebSocket | null = null;
private connected = false;
constructor(config: MyPlatformConfig) {
super();
this.config = config;
}
async connect(): Promise<void> {
const endpoint = this.config.apiEndpoint || "https://api.myplatform.com";
// WebSocket 연결 예시
this.ws = new WebSocket(`${endpoint}/ws?token=${this.config.apiToken}`);
this.ws.onopen = () => {
this.connected = true;
console.log("[MyPlatform] WebSocket connected");
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "message") {
this.emit("message", data.payload);
}
};
this.ws.onclose = () => {
this.connected = false;
// 자동 재연결
setTimeout(() => this.connect(), 5000);
};
}
async send(message: any): Promise<{ messageId: string }> {
const response = await fetch(
`${this.config.apiEndpoint}/api/messages`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${this.config.apiToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(message)
}
);
return response.json();
}
async edit(messageId: string, message: any): Promise<void> {
await fetch(
`${this.config.apiEndpoint}/api/messages/${messageId}`,
{
method: "PUT",
headers: {
"Authorization": `Bearer ${this.config.apiToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(message)
}
);
}
async disconnect(): Promise<void> {
this.ws?.close();
this.connected = false;
}
isConnected(): boolean {
return this.connected;
}
}
테스트
단위 테스트
// test/message.test.ts
import { describe, it, expect } from "vitest";
import { convertIncoming, convertOutgoing } from "../src/message";
describe("Message Conversion", () => {
it("should convert incoming DM message", () => {
const raw = {
id: "msg_001",
sender: { id: "user_1", name: "John" },
chat: { id: "chat_1", type: "private" },
text: "Hello",
timestamp: "2026-03-14T10:00:00Z"
};
const result = convertIncoming(raw);
expect(result.userId).toBe("user_1");
expect(result.channelType).toBe("dm");
expect(result.content).toBe("Hello");
});
it("should convert outgoing message with buttons", () => {
const message = {
channelId: "chat_1",
content: "Please choose:",
buttons: [
{ label: "Option A", action: "option_a" },
{ label: "Option B", action: "option_b" }
]
};
const result = convertOutgoing(message);
expect(result.keyboard).toHaveLength(2);
expect(result.keyboard[0].text).toBe("Option A");
});
});
통합 테스트
# OpenClaw의 플러그인 테스트 프레임워크 사용
openclaw plugin test ./openclaw-channel-myplatform \
--mock-platform \
--scenario send-receive
로컬 디버깅
OpenClaw에서 로컬 플러그인 로드
{
plugins: {
local: [
// 로컬 경로에서 플러그인 로드
"./plugins/openclaw-channel-myplatform"
]
},
channels: {
myplatform: {
enabled: true,
apiToken: "your-test-token"
}
}
}
# 핫 리로드 지원과 함께 개발 모드로 시작
openclaw start --dev --watch-plugins
플러그인 배포
npm에 배포
# 빌드
npm run build
# 배포
npm publish --access public
OpenClaw 플러그인 마켓플레이스에 제출
- GitHub에 리포지토리를 생성하고, 완전한 README와 LICENSE를 포함하세요
- OpenClaw 플러그인 마켓플레이스에 플러그인 메타데이터와 함께 PR을 제출합니다
- 심사를 통과하면 사용자가 다음으로 설치할 수 있습니다:
openclaw plugin install openclaw-channel-myplatform
서드파티 플러그인 설치
# npm에서 설치
openclaw plugin install openclaw-channel-myplatform
# GitHub에서 설치
openclaw plugin install github:username/openclaw-channel-myplatform
# 설치된 플러그인 목록
openclaw plugin list
라이프사이클 훅
Channel 플러그인은 다음과 같은 선택적 라이프사이클 훅을 구현할 수 있습니다:
export class MyPlatformChannel implements ChannelPlugin {
// 플러그인 로드 시 호출 (리소스 초기화)
async onLoad(): Promise<void> { }
// 플러그인 언로드 시 호출 (리소스 정리)
async onUnload(): Promise<void> { }
// 설정 업데이트 시 호출 (핫 리로드 설정)
async onConfigUpdate(newConfig: MyPlatformConfig): Promise<void> { }
// Agent 온라인 시 호출
async onAgentOnline(agentId: string): Promise<void> { }
// Agent 오프라인 시 호출
async onAgentOffline(agentId: string): Promise<void> { }
}
결론
OpenClaw의 플러그인 아키텍처를 통해 모든 커뮤니케이션 플랫폼을 AI Agent 생태계에 연결할 수 있습니다. ChannelPlugin 인터페이스의 핵심 메서드인 start, sendMessage, stop만 구현하면 작동하는 Channel 플러그인이 완성됩니다. 메시지 변환 레이어가 플랫폼 형식과 OpenClaw의 내부 형식 사이를 번역하고, 연결 관리 레이어가 외부 플랫폼과의 통신을 유지합니다. 강력한 테스트 프레임워크, 로컬 디버깅 지원, 플러그인 마켓플레이스 배포 워크플로우와 결합하여 OpenClaw는 플러그인 개발을 효율적이고 체계적으로 만들어 줍니다.