【#5】 OpenClaw を読み解く — 運ぶ transport-only のチャネル
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
OpenClaw が誇る「20 以上のメッセージングサービス対応」。WhatsApp も Telegram も Slack も Discord も、すべてチャネルプラグインとして実装されます。今回の主役は src/channels/(チャネル抽象)と extensions/telegram/(具体実装)。鍵となる思想は **transport-only(純粋な配送層)**です。
運び屋に徹するとは — 持つものと、持たぬもの
ルート AGENTS.md(L95)が境界を明確に定めます。チャネルが所有するものと所有しないものは厳格に分かれています。
- チャネルが所有する: ポータブルな表現/アクションの描画、トランスポート制限の適用、ネイティブのコールバック封筒のマッピング。その配送基盤として送信トランスポート、ペアリング/DM セキュリティ、セッション文法(プロバイダの会話 ID を base chat と thread に対応づける)、タイピング表示を持ちます。
- チャネルが所有しない: 商品コマンドツリー、プラグイン/プロバイダのポリシー、機能固有のメニュー。
(なお 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 が何をするか、どんなメニューを出すかといった商品ロジックはコアが持つ。チャネルは配送と描画に徹します。これが多数のチャネルを支える抽象の核です。
受信の道筋 — 生の声を、共通の語彙へ
外部サービスから届いた生のメッセージは、runChannelInboundEvent() を通って正規化されます。Telegram の実装(extensions/telegram/src/bot-message-dispatch.ts:1908 付近)がわかりやすい例です。
const turnResult = await runChannelInboundEvent({
channel: "telegram",
accountId: route.accountId,
raw: dispatchContext,
adapter: {
ingest: () => ({
id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`,
timestamp: /* ... */,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: dispatchContext,
}),
resolveTurn: () => ({ /* routeSessionKey, storePath, runDispatch ... */ }),
},
});
注目は 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")
return "user_request";
// メンション・コントロールコマンド・中断要求・ネイティブコマンドは
// 未メンションのグループ発言であっても明示的なユーザー意図とみなす
if (params.wasMentioned === true ||
params.hasControlCommand === true ||
params.hasAbortRequest === true ||
params.commandSource === "native") {
return "user_request";
}
return "room_event";
}
グループで全発言に反応したらノイズだらけになります。「メンションされた/コマンドだった/中断要求だった」ときだけ起き、それ以外は受動的に聞いている。この判定がチャネル横断で共通化されているのが効きどころです。続く buildChannelInboundEventContext() がルート・送信者・コマンド・メディアを FinalizedMsgContext にまとめ、セッション封筒を解決します。
送信の道筋 — 書きながら届ける、ドラフトストリーミング
エージェントの応答は、生成しながら少しずつメッセージを編集していく「ドラフトストリーミング」で届きます。これを支えるのが3層です。
-
draft stream loop(
src/channels/draft-stream-loop.ts): single-flight な編集セマンティクス。スロットル中も最新の保留テキストを保持し、sendOrEditStreamMessage()でプラットフォーム側を編集。 -
finalizable draft controls(
src/channels/draft-stream-controls.ts): プレビュー更新・最終フラッシュ・削除を協調させる。
const stop = async (): Promise<void> => {
// stop は最新の保留テキストをプレビューにフラッシュして確定する
params.markFinal();
await loop.flush();
};
const seal = async (): Promise<void> => {
// seal は最終配送/削除を呼び出し側が持つ場合にプレビュー id を残す
params.markFinal();
loop.stop();
await loop.waitForInFlight();
};
-
progress draft compositor(
src/channels/progress-draft-compositor.ts): ツール進捗・推論・コメンタリを、最終応答が来るまで合成し続ける。ストリーミングモード("off" | "partial" | "block" | "progress")を解決して描画。
ストリーミング設定は src/channels/streaming.ts でレガシーなフラット形式とモダンなネスト形式の両方を受け、正準の ChannelStreamingConfig に正規化します(ランタイムは正準形だけを読む、#01 の原則どおり)。
ルート AGENTS.md にある「External messaging: no token-delta channel messages.(トークン差分をそのままチャネルに流すな)」という注意も、この編集ベースのストリーミング設計と表裏一体です。
推測させない、という戒め — コマンドは宣言で渡す
transport-only を最も象徴するのが、ポータブルなアクションの扱いです。ルート AGENTS.md:
Portable command UI must use typed presentation actions, not raw string inference. Do not make channels guess that
valuestarting 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.
アクションは型で区別されます。
// src/agents/runtime-plan/types.ts:99
type AgentRuntimeMessagePresentationAction =
| { type: "command"; command: string } // コアのコマンド経路で実行
| { type: "callback"; value: string }; // チャネルが不透明データとして扱う
ボタンを押したとき、チャネルが「value が / で始まるからコマンドだろう」と推測してはいけない。コア/オーナープラグインが action.type: "command" を宣言し、チャネルはそれを対応するネイティブ UI にマッピングするだけ。callback は不透明データとしてチャネルのインタラクション経路に流す。これにより、コマンドルーティング(コアの責務)と描画・配送(チャネルの責務)が混ざりません。AGENTS.md の「Raw callback data is transport/private」という一文がこの分離を守っています。
実物を覗く — extensions/telegram の解剖
Telegram プラグインのエントリ(extensions/telegram/index.ts)は defineBundledChannelEntry で、複数の API 面を遅延ロード指定で束ねます。
export default defineBundledChannelEntry({
id: "telegram",
plugin: { specifier: "./channel-plugin-api.js", exportName: "telegramPlugin" },
secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets" },
runtime: { specifier: "./runtime-setter-api.js", exportName: "setTelegramRuntime" },
accountInspect:{ specifier: "./account-inspect-api.js", exportName: "inspectTelegramReadOnlyAccount" },
});
主要ファイルの分担はこうです。
| ファイル | 責務 |
|---|---|
src/channel.ts |
メインモジュール。send/outbound/dispatch を遅延ロード |
src/channel.setup.ts |
セットアップウィザード、認証検証、レガシー状態の移行 |
src/accounts.ts |
アカウント解決、トークン管理、マルチアカウント |
src/session-conversation.ts |
chat ID + topic ID を base 会話 + thread ID に対応づけ |
src/normalize.ts |
telegram:<chat> / telegram:<chat>:topic:<id> ターゲットの正規化 |
src/outbound-adapter.ts |
テキスト分割、表現描画、メディア、送信ディスパッチ |
src/bot-message-dispatch.ts |
受信→ turn コンテキスト、進捗ドラフト、応答配送 |
src/button-types.ts / interactive-fallback.ts
|
MessagePresentation を Telegram インラインボタンへ/テキスト fallback |
セッション会話のマッピングは小さく明快です(src/session-conversation.ts)。
export function resolveTelegramSessionConversation(params: {
kind: "group" | "channel"; rawId: string;
}) {
const parsed = parseTelegramTopicConversation({ conversationId: params.rawId });
if (!parsed) return null;
return {
id: parsed.chatId,
threadId: parsed.topicId,
baseConversationId: parsed.chatId,
parentConversationCandidates: [parsed.chatId],
};
}
Telegram のフォーラムトピックという固有概念を、コア共通の「base 会話 + thread」という語彙に翻訳している——これがチャネルの「セッション文法を所有する」責務の具体です。コマンドが何をするかには一切関与しません。
まとめ — 配送に徹し、判断はコアに委ねる
- チャネルは transport-only: 配送・セキュリティ・セッション文法・描画を持つが、商品コマンドロジックは持たない。
- 受信は **正規化 → 分類(user_request / room_event)**で、グループの過剰反応を防ぐ判定がチャネル横断で共通。
- 送信は 編集ベースのドラフトストリーミング(loop / controls / compositor の3層)。
- 型付き presentation action により、チャネルにコマンドを文字列推測させない。
- Telegram は固有概念(topic)をコア語彙に翻訳しつつ、ルーティングはコアに委ねる好例。
次回予告 — もう一つの軸、頭脳の選択へ
#06 は反対側の軸、モデルプロバイダとルーティングです。Anthropic も OpenAI も Google も「プロバイダプラグイン」。どんなフックを所有し、モデル参照 provider/model@profile がどう解決され、レート制限時にどうフェイルオーバーするのか。extensions/anthropic を題材に追います。
