5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenClaw深掘り① インターフェース層 ―― 26+チャネルを1つに束ねるアダプターパターンの設計

5
Posted at

前回の記事では、OpenClawの全体像を4層アーキテクチャとして概観しました。

本記事はその第1層「インターフェース層」の技術的な深掘りです。Telegram、Discord、Slack、WhatsApp、Signal、iMessage――プラットフォームごとにまったく異なるAPIを、OpenClawがどうやって1つの統一インターフェースに束ねているのか。実際のソースコードを読みながら設計を解き明かします。

インターフェース層が解決する問題

Telegramにはインラインボタンがある。Discordにはスレッドとロールがある。Slackにはブロック記法がある。WhatsAppにはQR認証がある。

それぞれのプラットフォームは独自のメッセージ形式、認証方式、送信制限を持っています。これらすべてに個別対応していたら、チャネルが増えるたびにシステム全体のコードが膨らみます。

OpenClawの解法はシンプルです。

  • 各チャネルが ChannelPlugin インターフェースを実装する
  • プラットフォーム固有の差異はアダプター群で吸収する
  • 内部には統一された MsgContext として渡す

この仕組みにより、システムの内側(オーケストレーション層やインテリジェンス層)は「どのチャネルから来たメッセージか」を気にする必要がありません。

ChannelPlugin ―― チャネル抽象化の中核

すべてのチャネルが実装する型定義を見てみましょう。

src/channels/plugins/types.plugin.ts
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
  id: ChannelId;
  meta: ChannelMeta;
  capabilities: ChannelCapabilities;
  defaults?: {
    queue?: { debounceMs?: number };
  };
  reload?: { configPrefixes: string[]; noopPrefixes?: string[] };

  // オンボーディング
  onboarding?: ChannelOnboardingAdapter;
  config: ChannelConfigAdapter<ResolvedAccount>;
  configSchema?: ChannelConfigSchema;
  setup?: ChannelSetupAdapter;
  pairing?: ChannelPairingAdapter;

  // セキュリティ
  security?: ChannelSecurityAdapter<ResolvedAccount>;
  groups?: ChannelGroupAdapter;
  mentions?: ChannelMentionAdapter;

  // メッセージ配信
  outbound?: ChannelOutboundAdapter;
  streaming?: ChannelStreamingAdapter;
  threading?: ChannelThreadingAdapter;
  messaging?: ChannelMessagingAdapter;

  // ステータス・ゲートウェイ
  status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;
  gatewayMethods?: string[];
  gateway?: ChannelGatewayAdapter<ResolvedAccount>;

  // その他
  auth?: ChannelAuthAdapter;
  elevated?: ChannelElevatedAdapter;
  commands?: ChannelCommandAdapter;
  agentPrompt?: ChannelAgentPromptAdapter;
  directory?: ChannelDirectoryAdapter;
  resolver?: ChannelResolverAdapter;
  actions?: ChannelMessageActionAdapter;
  heartbeat?: ChannelHeartbeatAdapter;
  agentTools?: ChannelAgentToolFactory | ChannelAgentTool[];
};

ポイントは、必須フィールドが idmetacapabilitiesconfig の4つだけという点です。残りはすべてオプショナル。チャネルは自分がサポートする機能のアダプターだけを実装すれば済みます。

この設計は「アダプター合成」と呼べるパターンです。各アダプターが独立した能力を表現し、チャネルが必要なものだけを選んで組み合わせます。

capabilities ―― 静的な能力宣言

各チャネルは起動時に「自分が何をサポートするか」を宣言します。

src/channels/plugins/types.core.ts
export type ChannelCapabilities = {
  chatTypes: Array<ChatType | "thread">;  // "direct" | "group" | "channel" | "thread"
  polls?: boolean;          // 投票機能
  reactions?: boolean;      // リアクション
  edit?: boolean;           // メッセージ編集
  unsend?: boolean;         // 送信取り消し
  reply?: boolean;          // 返信
  effects?: boolean;        // エフェクト
  groupManagement?: boolean; // グループ管理
  threads?: boolean;        // スレッド
  media?: boolean;          // メディア送受信
  nativeCommands?: boolean; // プラットフォーム固有コマンド
  blockStreaming?: boolean; // ブロックストリーミング
};

たとえばSlackは chatTypes: ["direct", "channel", "thread"]threads: truenativeCommands: true を宣言します。Discordも同様にスレッドとネイティブコマンドをサポートしますが、さらに polls: true を持ちます。WhatsAppは chatTypes: ["direct", "group"]polls: true のみ。Telegramは4種類すべてのchatTypeに加えて blockStreaming: true を宣言し、最も多機能です。

この宣言により、上位層は実行時に「このチャネルはスレッドに対応しているか」を判定でき、対応していないチャネルにスレッド操作を行う無駄を省けます。

MsgContext ―― 統一メッセージ形式

インターフェース層の最重要型が MsgContext です。Telegramの Update オブジェクトも、Discordの Message も、Slackの Event も、最終的にはこの型に変換されます。

src/auto-reply/templating.ts
export type MsgContext = {
  // ──── メッセージ本文 ────
  Body?: string;
  BodyForAgent?: string;    // プロンプト整形済みの本文
  CommandBody?: string;     // コマンド検出用の本文
  BodyForCommands?: string; // コマンドパース用(最優先)
  RawBody?: string;         // CommandBodyのレガシーエイリアス
  InboundHistory?: Array<{  // 直近のチャット履歴(未信頼)
    sender: string;
    body: string;
    timestamp?: number;
  }>;

  // ──── 識別子 ────
  From?: string;             // 送信元ID
  To?: string;               // 宛先ID
  SessionKey?: string;       // セッションキー
  AccountId?: string;        // マルチアカウント識別
  MessageSid?: string;       // メッセージID
  MessageSidFull?: string;   // プロバイダー固有のフルID

  // ──── 返信・スレッド ────
  ReplyToId?: string;
  ReplyToBody?: string;
  ReplyToSender?: string;
  ReplyToIsQuote?: boolean;
  ThreadStarterBody?: string;
  ThreadHistoryBody?: string;
  IsFirstThreadTurn?: boolean;
  MessageThreadId?: string | number;  // Telegramトピック or Matrixスレッド
  IsForum?: boolean;                  // Telegramフォーラムスーパーグループ

  // ──── メディア ────
  MediaPath?: string;
  MediaUrl?: string;
  MediaType?: string;
  MediaPaths?: string[];
  Sticker?: StickerMetadata;
  Transcript?: string;         // 音声文字起こし結果
  MediaUnderstanding?: MediaUnderstandingOutput[];

  // ──── チャットコンテキスト ────
  ChatType?: string;           // "direct" | "group" | "channel" | "thread"
  GroupSubject?: string;
  GroupChannel?: string;       // "#general" のようなチャネル名
  GroupMembers?: string;
  SenderName?: string;
  SenderId?: string;
  SenderUsername?: string;
  SenderE164?: string;         // 電話番号(E.164形式)
  Provider?: string;           // "telegram", "discord" 等
  Surface?: string;            // Providerより優先

  // ──── セキュリティ ────
  UntrustedContext?: string[]; // システム指示として扱ってはいけない情報
  OwnerAllowFrom?: Array<string | number>;
  CommandAuthorized?: boolean;
  WasMentioned?: boolean;

  // ──── ルーティング ────
  OriginatingChannel?: OriginatingChannelType;
  OriginatingTo?: string;
  HookMessages?: string[];
};

フィールド数は50を超えます。すべてオプショナルです。

Bodyの優先チェーン

メッセージ本文だけで5つのフィールドがあります。これは「どの文脈で本文を参照するか」でフォールバックチェーンを構成するためです。

用途 優先フィールド フォールバック
AIプロンプト整形 BodyForAgent Body
コマンドパース BodyForCommands CommandBodyRawBodyBody
一般参照 Body

BodyForAgent は履歴やコンテキストが付与された「プロンプト用」の本文。BodyForCommands はコマンド検出に最適化された「素の」テキスト。同じメッセージでも、用途に応じて異なるビューを提供します。

FinalizedMsgContext ―― デフォルト拒否の原則

src/auto-reply/templating.ts
export type FinalizedMsgContext = Omit<MsgContext, "CommandAuthorized"> & {
  CommandAuthorized: boolean;  // 必ずセットされる。デフォルトはfalse
};

MsgContext は入力段階では不完全な場合があります。finalizeInboundContext() が呼ばれると、必須フィールドがすべて埋められた FinalizedMsgContext に変換されます。

CommandAuthorized はデフォルト false(拒否)。未認証のユーザーからのコマンドは明示的に許可されない限り実行されません。

アダプター群の詳細

ChannelPlugin が持つアダプターの中から、特に重要なものを見ていきます。

ChannelOutboundAdapter ―― メッセージ送信

export type ChannelOutboundAdapter = {
  deliveryMode: "direct" | "gateway" | "hybrid";
  chunker?: ((text: string, limit: number) => string[]) | null;
  chunkerMode?: "text" | "markdown";
  textChunkLimit?: number;
  pollMaxOptions?: number;
  sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
  sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
  sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
  sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
};

textChunkLimit はプラットフォームごとの文字数制限を反映しています。

チャネル textChunkLimit 補足
Discord 2,000文字 Discord APIの制限
Telegram 4,000文字 Bot API制限
Slack 4,000文字 Block Kit制限
WhatsApp 4,000文字 Web API制限
IRC 350文字 IRCプロトコル制限

AIの応答が制限を超える場合、chunker が段落を意識して分割します。Markdownモードでは見出しやコードブロックの途中で切断されないよう配慮されます。

ChannelStreamingAdapter ―― ストリーミング制御

export type ChannelStreamingAdapter = {
  blockStreamingCoalesceDefaults?: {
    minChars: number;
    idleMs: number;
  };
};

AIの応答はトークン単位でストリーミングされます。しかし1トークンごとにメッセージを更新すると、APIレートリミットに引っかかります。

コアレシング(合体)がこれを解決します。minChars: 1500 なら、1,500文字分のトークンが溜まるか idleMs: 1000(1秒間)新しいトークンが来なくなるまで、メッセージ更新を遅延させます。ユーザーには「ある程度まとまった段落が一気に表示される」体験になります。

Telegramは blockStreaming: true を capabilities で宣言しつつ、コアレシングのデフォルト値は設定していません。これはTelegramが独自のストリーミング制御(メッセージの「編集」によるインプレース更新)を持っているためです。

ChannelSecurityAdapter ―― DM認可

export type ChannelSecurityDmPolicy = {
  policy: string;           // "pairing" | "open" | "allowlist"
  allowFrom?: Array<string | number> | null;
  policyPath?: string;
  allowFromPath: string;
  approveHint: string;
  normalizeEntry?: (raw: string) => string;
};

誰からのDMに応答するかを制御するポリシーです。

  • pairing: 未知のユーザーからのDMにはオーナーの承認が必要
  • allowlist: 許可リストに載っているユーザーにのみ応答
  • open: 誰にでも応答

Telegramの場合、ペアリングが承認されると pairing.notifyApproval() が呼ばれ、承認メッセージがユーザーに送信されます。

// Telegramのペアリング設定
pairing: {
  idLabel: "telegramUserId",
  normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""),
  notifyApproval: async ({ cfg, id }) => {
    const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg);
    await getTelegramRuntime().channel.telegram.sendMessageTelegram(
      id, PAIRING_APPROVED_MESSAGE, { token }
    );
  },
},

ChannelGroupAdapter ―― グループチャットポリシー

export type ChannelGroupAdapter = {
  resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
  resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;
  resolveToolPolicy?: (params: ChannelGroupContext) => GroupToolPolicyConfig | undefined;
};

グループチャットでは、すべてのメッセージに反応するとノイズになります。resolveRequireMentiontrue を返すと、ボットがメンション(@bot)されたときだけ応答します。resolveToolPolicy でグループ内でのツール使用範囲を制限することもできます。

チャネルレジストリとDock

レジストリ ―― チャネルの登録と優先順位

src/channels/registry.ts
export const CHAT_CHANNEL_ORDER = [
  "telegram",
  "whatsapp",
  "discord",
  "irc",
  "googlechat",
  "slack",
  "signal",
  "imessage",
] as const;

export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];

コアチャネルは配列の順序で優先順位が決まります。Telegramが先頭にあるのは、OpenClawで最も推奨されるチャネルだからです。

エイリアスも定義されています。

src/channels/registry.ts
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
  imsg: "imessage",
  "internet-relay-chat": "irc",
  "google-chat": "googlechat",
  gchat: "googlechat",
};

normalizeAnyChannelId() はコアチャネルだけでなく、プラグインレジストリに登録された拡張チャネルも名前解決します。入力が "imsg" でも "iMessage" でも "imessage" でも、正規化されて同じチャネルに到達します。

ChannelDock ―― 軽量ビュー

ここが設計上のポイントです。ChannelPlugin はフルスペックの型で、モニター、Webログイン、Puppeteer統合なども含みます。これらを丸ごとimportすると、関係ないコードパスまで大量のモジュールが読み込まれます。

そこで導入されたのが ChannelDock です。

src/channels/dock.ts
export type ChannelDock = {
  id: ChannelId;
  capabilities: ChannelCapabilities;
  commands?: ChannelCommandAdapter;
  outbound?: { textChunkLimit?: number };
  streaming?: ChannelDockStreaming;
  elevated?: ChannelElevatedAdapter;
  config?: Pick<
    ChannelConfigAdapter<unknown>,
    "resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo"
  >;
  groups?: ChannelGroupAdapter;
  mentions?: ChannelMentionAdapter;
  threading?: ChannelThreadingAdapter;
  agentPrompt?: ChannelAgentPromptAdapter;
};

ChannelDockChannelPlugin のサブセットです。ルーティング、送信先解決、メンション処理など「軽い操作」に必要なフィールドだけを抽出しています。

ソースコードのコメントにも設計意図が明記されています。

// Channel docks: lightweight channel metadata/behavior for shared code paths.
//
// Rules:
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
// - shared code should import from here, not from the plugins registry

共有コードは ChannelDock を参照し、フルプラグインは実際にチャネルを起動するときだけロードする。これにより起動時間とメモリ使用量を抑えています。

コアチャネルの実装

各チャネルが ChannelPlugin をどう実装しているか、具体的に見ていきます。

Slack(Bolt SDK + Socket Mode)

日本で最も利用されているビジネスチャットツールから始めます。OpenClawのSlack統合は機能が豊富です。

extensions/slack/src/channel.ts
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
  id: "slack",
  capabilities: {
    chatTypes: ["direct", "channel", "thread"],
    reactions: true,
    threads: true,
    media: true,
    nativeCommands: true,
  },
  streaming: {
    blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
  },
  // ...
};

Socket Mode接続

SlackはWebhookではなくSocket Mode(WebSocket接続)を使います。Webhookだと外部公開URLが必要ですが、Socket Modeならファイアウォール内でも動作します。セルフホストのOpenClawに適した選択です。

src/slack/monitor/provider.ts
const app = new App(
  slackMode === "socket"
    ? {
        token: botToken,
        appToken,
        socketMode: true,
        clientOptions,
      }
    : {
        token: botToken,
        receiver: receiver ?? undefined,
        clientOptions,
      }
);

if (slackMode === "socket") {
  await app.start();
  runtime.log?.("slack socket mode connected");
}

2種類のトークンが必要です。xapp-*(App Token)はSocket Mode接続用、xoxb-*(Bot Token)はAPI呼び出し用。起動時に auth.test() でトークンを検証し、ボットのユーザーIDとチームIDを取得します。

トークンの使い分け

Slackでは任意でUser Tokenも設定できます。Bot Tokenではアクセスできないリソースの読み取りに使われます。

extensions/slack/src/channel.ts
function getTokenForOperation(
  account: ResolvedSlackAccount,
  operation: "read" | "write",
): string | undefined {
  const userToken = account.config.userToken?.trim() || undefined;
  const botToken = account.botToken?.trim();
  const allowUserWrites = account.config.userTokenReadOnly === false;
  if (operation === "read") {
    return userToken ?? botToken;   // 読み取りはUser Token優先
  }
  if (!allowUserWrites) {
    return botToken;                // 書き込みはBot Token限定(デフォルト)
  }
  return botToken ?? userToken;
}

デフォルトでは User Token は読み取り専用です。userTokenReadOnly: false を明示しない限り、メッセージ送信には Bot Token が使われます。意図しない操作を防ぐ安全策です。

スレッド処理

Slackのスレッドは thread_ts(タイムスタンプ)で管理されます。OpenClawはこのタイムスタンプをセッションキーに紐づけて、スレッド内の会話を1つのセッションとして扱います。

src/slack/threading.ts
export function resolveSlackThreadContext(params: {
  message: SlackMessageEvent | SlackAppMentionEvent;
  replyToMode: ReplyToMode;
}): SlackThreadContext {
  const incomingThreadTs = params.message.thread_ts;
  const messageTs = params.message.ts ?? params.message.event_ts;
  const hasThreadTs = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0;
  const isThreadReply = hasThreadTs
    && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
  const replyToId = incomingThreadTs ?? messageTs;
  const messageThreadId = isThreadReply
    ? incomingThreadTs
    : params.replyToMode === "all"
      ? messageTs
      : undefined;
  return { incomingThreadTs, messageTs, isThreadReply, replyToId, messageThreadId };
}

replyToMode は3つのモードを持ちます。"off" はスレッド返信しない。"first" は最初のメッセージにのみ返信。"all" はすべてのメッセージにスレッド内で返信。チャットタイプ(DM / チャネル / スレッド)ごとに切り替えられます。

メンション除去

Slackはメンションを <@U12345> 形式で送信します。コマンド解析の前にこれを除去する必要があります。

src/slack/monitor/commands.ts
export function stripSlackMentionsForCommandDetection(text: string): string {
  return (text ?? "")
    .replace(/<@[^>]+>/g, " ")   // <@U123456> や <@U123456|john.doe> を除去
    .replace(/\s+/g, " ")
    .trim();
}

Dock側でもメンション除去パターンが定義されており、<@[^>]+> でユーザーメンション、ロールメンション、リンク形式の特殊トークンをすべてカバーしています。

Block Kit とリッチメッセージ

Slackの mrkdwn 形式は標準Markdownと微妙に異なります。OpenClawはこの差異を format.ts で吸収しています。

src/slack/format.ts
function isAllowedSlackAngleToken(token: string): boolean {
  if (!token.startsWith("<") || !token.endsWith(">")) return false;
  const inner = token.slice(1, -1);
  return (
    inner.startsWith("@") ||       // ユーザーメンション
    inner.startsWith("#") ||       // チャネル参照
    inner.startsWith("!") ||       // <!here>, <!channel>
    inner.startsWith("mailto:") ||
    inner.startsWith("http://") ||
    inner.startsWith("https://") ||
    inner.startsWith("slack://")
  );
}

Block Kit JSONの送信もサポートしています。ブロック数は最大50個に制限されています。

src/slack/blocks-input.ts
const SLACK_MAX_BLOCKS = 50;

export function validateSlackBlocksArray(raw: unknown): (Block | KnownBlock)[] {
  assertBlocksArray(raw);
  return raw as (Block | KnownBlock)[];
}

ストリーミング応答

Slackのストリーミングは3つのモードを持ちます。

  • Replace: メッセージ内容を上書き
  • Append: 改行を挟んで追記
  • Status Final: 「考え中...」インジケーターの表示
src/slack/stream-mode.ts
export function applyAppendOnlyStreamUpdate(params: {
  incoming: string;
  rendered: string;
  source: string;
}): { rendered: string; source: string; changed: boolean } {
  if (incoming.startsWith(params.source) || incoming.startsWith(params.rendered)) {
    return { rendered: incoming, source: incoming, changed: incoming !== params.rendered };
  }
  // 短くなった場合は無視(退行防止)
  if (params.source.startsWith(incoming)) {
    return { rendered: params.rendered, source: params.source, changed: false };
  }
  const separator = params.rendered.endsWith("\n") ? "" : "\n";
  return {
    rendered: `${params.rendered}${separator}${incoming}`,
    source: incoming,
    changed: true,
  };
}

退行防止(短いメッセージで上書きしない)が組み込まれているのが堅実です。

Discord(discord.js)

extensions/discord/src/channel.ts
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
  id: "discord",
  capabilities: {
    chatTypes: ["direct", "channel", "thread"],
    reactions: true,
    threads: true,
    media: true,
    nativeCommands: true,
  },
  streaming: {
    blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
  },
  // ...
};

discord.jsのGateway接続を使い、Intent(購読するイベント種別)を指定して帯域を節約します。

インバウンドデバウンサー

Discordでは短い間隔で複数メッセージを送る使い方が一般的です。デバウンサーがこれらを1つにまとめます。

src/discord/monitor/message-handler.ts
const debouncer = createInboundDebouncer<{
  data: DiscordMessageEvent;
  client: Client;
}>({
  debounceMs,
  buildKey: (entry) => {
    const authorId = entry.data.author?.id;
    const channelId = resolveDiscordMessageChannelId({
      message: entry.data.message,
      eventChannelId: entry.data.channel_id,
    });
    return `discord:${params.accountId}:${channelId}:${authorId}`;
  },
  shouldDebounce: (entry) => {
    const message = entry.data.message;
    if (message.attachments?.length > 0) return false;  // 添付ファイルは即処理
    if (hasDiscordMessageStickers(message)) return false; // ステッカーも即処理
    const baseText = resolveDiscordMessageText(message, { includeForwarded: false });
    return !hasControlCommand(baseText, params.cfg);
  },
});

デバウンスキーは discord:{accountId}:{channelId}:{authorId} 形式。同じチャネル・同じユーザーからの連投がまとめられます。ただし添付ファイルやコントロールコマンドは即座に処理されます。

スレッドバインディング

Discordならではの機能として、スレッドバインディングがあります。AIのサブエージェントをDiscordスレッドに紐づけて、独立した会話を持たせます。

src/discord/monitor/thread-bindings.types.ts
export type ThreadBindingRecord = {
  accountId: string;
  channelId: string;
  threadId: string;
  targetKind: ThreadBindingTargetKind;  // "subagent" | "acp"
  targetSessionKey: string;
  agentId: string;
  label?: string;
  webhookId?: string;
  webhookToken?: string;
  boundBy: string;
  boundAt: number;
  expiresAt?: number;  // デフォルトTTL: 24時間
};

Webhookを使ったペルソナ表示にも対応しており、バインドされたスレッドではサブエージェント固有のユーザー名とアバターでメッセージが送信されます。

コンポーネントシステム

Discordのボタン、セレクトメニュー、モーダルにも対応しています。

src/discord/components.ts
export type DiscordComponentButtonSpec = {
  label: string;
  style?: "primary" | "secondary" | "success" | "danger" | "link";
  url?: string;
  emoji?: { name: string; id?: string; animated?: boolean };
  disabled?: boolean;
  allowedUsers?: string[];  // 操作を許可するユーザーの制限
};

textChunkLimit: 2000 はDiscord APIの制限に対応しています。

LINE(Messaging API)

日本で圧倒的なシェアを持つLINEにも対応しています。拡張チャネルとして実装されていますが、Flex Messageやリッチメニューなど、LINE固有の機能を活用した実装になっています。

Webhook署名検証

LINE Messaging APIはWebhookでイベントを受信します。HMAC SHA256による署名検証が実装されています。

src/line/webhook.ts
export function createLineWebhookMiddleware(
  options: LineWebhookOptions,
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
  const { channelSecret, onEvents, runtime } = options;

  return async (req, res, _next) => {
    const signature = req.headers["x-line-signature"];
    const rawBody = readRawBody(req);
    const body = parseWebhookBody(req, rawBody);

    // LINE の検証リクエスト(events:[])は署名なしで届く
    if (!signature || typeof signature !== "string") {
      if (isLineWebhookVerificationRequest(body)) {
        res.status(200).json({ status: "ok" });
        return;
      }
      res.status(400).json({ error: "Missing X-Line-Signature header" });
      return;
    }

    if (!validateLineSignature(rawBody, signature, channelSecret)) {
      res.status(401).json({ error: "Invalid signature" });
      return;
    }

    // タイムアウト防止のため即座に200を返す
    res.status(200).json({ status: "ok" });

    // イベント処理は非同期で実行
    if (body.events && body.events.length > 0) {
      await onEvents(body).catch((err) => {
        runtime?.error?.(`line webhook handler failed: ${String(err)}`);
      });
    }
  };
}

注目すべきは「即座に200を返してから非同期でイベントを処理する」パターンです。LINE Platformはタイムアウトが短いため、AIの応答生成を待ってから返すと間に合いません。

Flex Message

LINEの特徴であるFlex Message(リッチなカード形式メッセージ)にネイティブ対応しています。

src/line/flex-templates/basic-cards.ts
export function createInfoCard(title: string, body: string, footer?: string): FlexBubble {
  const bubble: FlexBubble = {
    type: "bubble",
    size: "mega",
    body: {
      type: "box",
      layout: "vertical",
      contents: [
        {
          type: "box",
          layout: "horizontal",
          contents: [
            {
              type: "box",
              layout: "vertical",
              contents: [],
              width: "4px",
              backgroundColor: "#06C755",  // LINE ブランドカラー
              cornerRadius: "2px",
            },
            {
              type: "text",
              text: title,
              weight: "bold",
              size: "xl",
              color: "#111111",
              wrap: true,
              flex: 1,
              margin: "lg",
            },
          ],
        },
        // 本文テキスト
      ],
      paddingAll: "xl",
      backgroundColor: "#FFFFFF",
    },
  };
  return bubble;
}

さらに、AIが出力したMarkdownのテーブルを自動的にFlex Message(カード形式)に変換する機能があります。

src/line/markdown-to-line.ts
const MARKDOWN_TABLE_REGEX = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm;
const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;

テーブル、コードブロック、リンクを検出し、LINEのリッチ表示に変換します。通常のテキストチャネルではMarkdownがそのまま表示されますが、LINEではプラットフォームに最適化された見た目になります。

ローディングアニメーション

AIの応答生成中、ユーザーに「考え中」を伝えるローディングアニメーションを18秒間隔で送信し続けます。

src/line/monitor.ts
function startLineLoadingKeepalive(params: {
  userId: string;
  accountId?: string;
  intervalMs?: number;
  loadingSeconds?: number;
}): () => void {
  const intervalMs = params.intervalMs ?? 18_000;   // 18秒ごと
  const loadingSeconds = params.loadingSeconds ?? 20; // 20秒表示
  let stopped = false;

  const trigger = () => {
    if (stopped) return;
    // showLoadingAnimation() を呼び出し
  };

  return () => { stopped = true; };
}

Telegram(grammY)

OpenClawで最初にサポートされたチャネルであり、最も多機能です。grammYフレームワークでBot APIに接続し、ロングポーリングまたはWebhookでメッセージを受信します。

capabilities が最も豊富で、chatTypes は4種類すべて(direct, group, channel, thread)をサポート。blockStreaming により、送信済みメッセージを editMessageTelegram() で繰り返し更新して、AIの応答をリアルタイム表示します。

WhatsApp / Signal / iMessage

残りの3チャネルは簡潔にまとめます。

  • WhatsApp: Baileys(WhatsApp Webのリバースエンジニアリングライブラリ)で接続。APIキー不要、QRコードでログイン。forceAccountBinding: true により1インスタンス1アカウント。AI自身がログインフローを実行する createLoginTool() を持つ
  • Signal: signal-cli のREST API経由で接続。リンクデバイス方式でプライマリデバイス不要。E2E暗号化が常時有効
  • iMessage: BlueBubblesバックエンドまたはローカルCLI統合。macOS限定、開発途中(showConfigured: false

チャネル比較

特徴 Slack Discord LINE Telegram
接続方式 Socket Mode + HTTP Gateway (Intents) Webhook (HMAC-SHA256) Bot API (ポーリング/Webhook)
リッチメッセージ Block Kit Components Flex Message HTML + インラインボタン
スレッド ネイティブ (thread_ts) バインディング (TTL付き) 非対応 フォーラムトピック
メンション除去 <@U12345> <@!123456> なし(名前のみ) Bot API が自動除去
ストリーミング Replace/Append/Status コアレシング ローディングアニメーション メッセージ編集
textChunkLimit 4,000 2,000 5,000 4,000

拡張チャネル

コア8チャネルに加えて、extensions/ ディレクトリには多数の拡張チャネルがあります。

extensions/
├── telegram/        ← コア
├── discord/         ← コア
├── slack/           ← コア
├── whatsapp/        ← コア
├── signal/          ← コア
├── imessage/        ← コア
├── irc/             ← コア
├── googlechat/      ← コア
├── matrix/          ← 拡張(Element互換)
├── msteams/         ← 拡張(Microsoft Teams)
├── line/            ← 拡張(LINE Messaging API)
├── feishu/          ← 拡張(ByteDance Lark)
├── mattermost/      ← 拡張(セルフホスト型チャット)
├── nextcloud-talk/  ← 拡張
├── nostr/           ← 拡張(分散型プロトコル)
├── twitch/          ← 拡張(ストリーミングチャット)
├── zalo/            ← 拡張(ベトナムのメッセンジャー)
├── synology-chat/   ← 拡張(NASチャットサーバー)
├── tlon/            ← 拡張(Urbit Gall agent)
└── bluebubbles/     ← 拡張(iMessage代替)

すべて同じ ChannelPlugin 契約を実装しています。拡張チャネルの追加に必要な手順は以下の通りです。

  1. extensions/<name>/src/channel.tsChannelPlugin を実装
  2. プラグインレジストリに登録
  3. Dock(必要な場合のみ)を dock.ts に追加

コアのルーティング、ストリーミング、セキュリティのコードは一切触る必要がありません。

メッセージフロー全体図

インターフェース層を通るメッセージの流れを、インバウンド(受信)とアウトバウンド(送信)に分けて整理します。

インバウンド:プラットフォーム → MsgContext → ディスパッチ

アウトバウンド:応答 → チャンキング → 配信

Write-Aheadキューはクラッシュリカバリーの仕組みです。プロセスが送信途中でクラッシュしても、再起動時にキューから未配信のメッセージを再送できます。

設計パターンのまとめ

インターフェース層に見られる設計パターンを整理します。

1. アダプター合成

各機能(ストリーミング、スレッディング、セキュリティなど)が独立したアダプター型として定義され、チャネルが必要なものだけを組み合わせます。GoのインターフェースやRustのトレイトに近い思想です。

2. Dock による遅延ロード

ChannelPlugin(重い)と ChannelDock(軽い)を分離し、共有コードは Dock だけを参照します。フルプラグインはチャネル起動時にのみロードされます。

3. デフォルト拒否

CommandAuthorized は未設定なら false。未知のユーザーからのコマンドは暗黙的に拒否されます。セキュリティは「オプトイン」ではなく「オプトアウト」です。

4. Write-Aheadキュー

送信メッセージは配信前に永続化され、プロセスクラッシュ時にも配信が保証されます。

5. コアレシング

ストリーミング応答を一定量ためてから配信することで、APIレートリミットを回避しつつ、ユーザーにとって読みやすい単位で表示します。

まとめ

OpenClawのインターフェース層は、26以上のメッセージングプラットフォームの差異を吸収し、システムの内側には統一された MsgContext を提供します。

その設計の核心は以下の3点です。

  • ChannelPlugin のアダプター合成により、チャネルは必要な機能だけを実装すれば済む
  • ChannelDock の軽量ビューにより、不要なコードの遅延ロードを実現
  • MsgContext の50+フィールドにより、プラットフォーム固有の情報を失わずに正規化

次回はオーケストレーション層(ゲートウェイ、ルーティング、コマンドキュー)を深掘りする予定です。

前回の全体像記事: 3ヶ月で23万スターを獲得したAIアシスタント「OpenClaw」の全貌

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?