
【#5】 OpenClaw 읽기 — 전달만 담당하는 transport-only 채널
요약
OpenClaw의 채널 플러그인 설계 원칙인 'transport-only' 구조를 분석합니다. 채널은 메시지 전달과 묘사에만 집중하고, 상품 로직은 코어에서 관리함으로써 다수의 메시징 서비스에 대응하는 추상화 방식을 설명합니다.
핵심 포인트
- 채널은 순수 전달 계층(transport-only)으로서 포터블한 표현과 트랜스포트 제한만 담당함
- 상품 커맨드 트리나 정책 등 비즈니스 로직은 채널이 아닌 코어(core)가 소유함
- Telegram 구현 예시를 통해 외부 메시지를 정규화하는 과정을 설명함
- 에이전트용 텍스트와 커맨드 판정용 텍스트를 분리하여 처리함
본 기사의 코드 참조는 OpenClaw main의 cee2aca409 (version 2026.6.10) 시점입니다. 행 번호는 업데이트에 따라 어긋날 수 있습니다.
연재 「OpenClaw 읽기」
OpenClaw가 자랑하는 「20 이상의 메시징 서비스 대응」. WhatsApp도 Telegram도 Slack도 Discord도, 모두 **채널 플러그인 (channel plugin)**으로서 구현됩니다. 이번 주인공은 src/channels/ (채널 추상화)와 extensions/telegram/ (구체적 구현)입니다. 핵심이 되는 사상은 **transport-only (순수 전달 계층)**입니다.
루트 AGENTS.md (L95)가 경계를 명확히 정의합니다. 채널이 소유하는 것과 소유하지 않는 것은 엄격하게 나뉘어 있습니다.
- 채널이 소유하는 것: 포터블(portable)한 표현/액션의 묘사, 트랜스포트(transport) 제한의 적용, 네이티브 콜백 엔벨로프(callback envelope)의 매핑. 그 전달 기반으로서 송신 트랜스포트, 페어링/DM 보안, 세션 문법 (프로바이더의 대화 ID를 base chat과 thread에 대응시킴), 타이핑 표시를 가집니다.
- 채널이 소유하지 않는 것: 상품 커맨드 트리(product command tree), 플러그인/프로바이더의 정책, 기능 특화 메뉴.
(참고로 src/channels/AGENTS.md는 import 경계·지연 로딩 규약을 정하는 별도 문서이며, 소유 경계 그 자체는 루트 AGENTS.md가 정본입니다.)
루트 AGENTS.md는 다음과 같이 기술하고 있습니다.
Message/channel plugins stay transport-only. They render portable presentation/actions, enforce transport limits, and map native callback envelopes. They do not own product command trees, plugin/provider policy, or feature-specific menus.
즉, 채널은 「포터블한 표현·액션을 묘사하고, 트랜스포트 제한을 준수하며, 네이티브 콜백 엔벨로프를 매핑할」 뿐입니다. /status가 무엇을 하는지, 어떤 메뉴를 내보내는지와 같은 상품 로직은 코어(core)가 가집니다. 채널은 전달과 묘사에 철저합니다. 이것이 다수의 채널을 지원하는 추상의 핵심입니다.
외부 서비스로부터 도착한 가공되지 않은 메시지는 runChannelInboundEvent()를 통해 정규화됩니다. Telegram 구현 (extensions/telegram/src/bot-message-dispatch.ts:1908 부근)이 이해하기 쉬운 예시입니다.
const turnResult = await runChannelInboundEvent({
channel: "telegram",
accountId: route.accountId,
...
주목할 점은 textForAgent와 textForCommands를 분리하여 가진다는 것입니다. 에이전트에게 전달할 본문과, 커맨드 판정에 사용하는 본문을 구별하고 있습니다. 정규화 이후에는 분류 단계로 진행됩니다.
src/channels/inbound-event/classification.ts:26의 classifyChannelInboundEvent()는 그룹/채널의 발언이 에이전트를 깨우는 요청(user_request)인지, 수동적인 방 이벤트(room_event)인지를 판정합니다.
export function classifyChannelInboundEvent(params): InboundEventKind {
if (params.unmentionedGroupPolicy !== "room_event") return "user_request";
if (params.conversation.kind !== "group" && params.conversation.kind !== "channel")
...
그룹에서 모든 발언에 반응하면 노이즈가 가득해집니다. "멘션되었을 때 / 커맨드(command)였을 때 / 중단 요청이었을 때"에만 반응하고, 그 외에는 수동적으로 듣고 있습니다. 이 판정이 채널을 가로질러 공통화되어 있다는 점이 핵심입니다. 이어지는 buildChannelInboundEventContext()는 루트(route), 송신자(sender), 커맨드(command), 미디어(media)를 FinalizedMsgContext로 묶어 세션 엔벨로프(session envelope)를 해결합니다.
에이전트의 응답은 생성하면서 조금씩 메시지를 편집해 나가는 "드래프트 스트리밍 (draft streaming)" 방식으로 전달됩니다. 이를 지원하는 것은 3개의 계층입니다.
- draft stream loop (
src/channels/draft-stream-loop.ts): single-flight 방식의 편집 세맨틱스 (semantics). 스로틀링(throttle) 중에도 최신 보류 텍스트를 유지하며,sendOrEditStreamMessage()를 통해 플랫폼 측을 편집합니다. - finalizable draft controls (
src/channels/draft-stream-controls.ts): 프리뷰 업데이트, 최종 플러시(flush), 삭제를 협조시킵니다.
const stop = async (): Promise<void> => {
// stop은 최신 보류 텍스트를 프리뷰에 플러시하여 확정한다
params.markFinal();
...
- progress draft compositor (
src/channels/progress-draft-compositor.ts): 툴 진행 상황, 추론, 코멘터리(commentary)를 최종 응답이 올 때까지 계속 합성합니다. 스트리밍 모드 ("off" | "partial" | "block" | "progress")를 해결하여 렌더링합니다.
스트리밍 설정은 src/channels/streaming.ts에서 레거시한 플랫(flat) 형식과 모던한 네스티드(nested) 형식 모두를 받아들여, 정준(canonical) 형태인 ChannelStreamingConfig로 정규화합니다 (#01의 원칙에 따라 런타임은 정준 형태만 읽습니다).
루트 AGENTS.md에 있는 "External messaging: no token-delta channel messages. (토큰 차분을 그대로 채널에 흘려보내지 마라)"라는 주의 사항도 이 편집 기반의 스트리밍 설계와 일맥상통합니다.
transport-only를 가장 상징적으로 보여주는 것은 포터블(portable)한 액션(action)의 처리입니다. 루트 AGENTS.md:
Portable command UI must use typed presentation actions, not raw string inference. Do not make channels guess that value starting with / means a native command; core/owner plugins declare command actions, channels map them when supported.
문서(docs/plugins/message-presentation.md)에서도 명시하고 있습니다.
Channel plugins must not reinterpret callback data as slash commands.
액션은 타입(type)으로 구분됩니다.
// src/agents/runtime-plan/types.ts:99
type AgentRuntimeMessagePresentationAction =
| { type: "command"; command: string } // 코어의 커맨드 경로로 실행
...
버튼을 눌렀을 때, 채널이 "value가 /로 시작하니까 커맨드겠지"라고 추측해서는 안 됩니다. 코어/오너 플러그인이 action.type: "command"를 선언하고, 채널은 그것을 대응하는 네이티브 UI에 매핑할 뿐입니다. callback은 불투명 데이터(opaque data)로서 채널의 인터랙션 경로로 흘려보냅니다. 이를 통해 커맨드 라우팅(command routing, 코어의 책임)과 렌더링·전송(rendering/delivery, 채널의 책임)이 섞이지 않습니다. AGENTS.md의 "Raw callback data is transport/private"라는 문장이 이 분리를 지켜줍니다.
Telegram 플러그인의 엔트리(extensions/telegram/index.ts)는 defineBundledChannelEntry를 통해 여러 API 면(faces)을 지연 로딩(lazy load)으로 묶습니다.
export default defineBundledChannelEntry({
id: "telegram",
plugin: { specifier: "./channel-plugin-api.js", exportName: "telegramPlugin" },
...
주요 파일의 역할 분담은 다음과 같습니다.
| 파일 | 책임 |
|---|---|
src/channel.ts | 메인 모듈. send/outbound/dispatch를 지연 로딩 |
src/channel.setup.ts | 셋업 위저드(setup wizard), 인증 검증, 레거시 상태 마이그레이션 |
src/accounts.ts | 계정 해결(account resolution), 토큰 관리, 멀티 계정 |
src/session-conversation.ts | chat ID + topic ID를 base 대화 + thread ID에 매핑 |
src/normalize.ts | telegram:<chat> / telegram:<chat>:topic:<id> 타겟의 정규화(normalization) |
src/outbound-adapter.ts | 텍스트 분할, 표현 렌더링(presentation rendering), 미디어, 송신 디스패치(dispatch) |
src/bot-message-dispatch.ts | 수신 → turn 컨텍스트, 진행 중인 초안(progress draft), 응답 배송 |
src/button-types.ts / interactive-fallback.ts | MessagePresentation을 Telegram 인라인 버튼으로 변환 / 텍스트 폴백(fallback) |
세션 대화 매핑은 작고 명확합니다(src/session-conversation.ts).
export function resolveTelegramSessionConversation(params: {
kind: "group" | "channel"; rawId: string;
}) {
...
Telegram의 포럼 토픽(forum topic)이라는 고유 개념을 코어 공통 어휘인 "base 대화 + thread"로 번역하는 것—이것이 채널이 "세션 문법을 소유한다"는 책임의 구체적인 모습입니다. 명령(command)이 무엇을 하는지에는 전혀 관여하지 않습니다.
- 채널은
- transport-only: 배송·보안·세션 문법·렌더링을 가지지만, 상품 명령 로직은 가지지 않는다.
- 수신은 정규화(normalization) → 분류(user_request / room_event) 과정을 거치며, 그룹의 과잉 반응을 방지하는 판정이 채널 전반에 걸쳐 공통적으로 적용된다.
- 송신은
- 편집 기반의 초안 스트리밍(draft streaming) (loop / controls / compositor의 3계층).
- 타입화된 presentation action을 통해, 채널이 명령을 문자열로 추측하게 하지 않는다.
- Telegram은 고유 개념(topic)을 코어 어휘로 번역하면서도, 라우팅은 코어에 위임하는 좋은 사례이다.
#06은 반대편 축인 모델 프로바이더(model provider)와 라우팅입니다. Anthropic도 OpenAI도 Google도 "프로바이더 플러그인"입니다. 어떤 훅(hook)을 소유하며, 모델 참조 provider/model@profile이 어떻게 해결되고, 속도 제한(rate limit) 시 어떻게 페일오버(failover)되는지. extensions/anthropic을 주제로 따라가 보겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기