解构 OpenClaw:高度解耦的渠道层架构与 Telegram 插件实现

0 阅读18分钟

1. Channel渠道层

OpenClaw can talk to you on any chat app you already use. Each channel connects via the Gateway. Text is supported everywhere; media and reactions vary by channel.

1.1 什么是Channel?

Channel 是 OpenClaw 面向外部聊天平台的接入与适配层。它的核心职责不是简单的消息收发,它的工作是将所有渠道(如Telegram、微信、Discord等)的消息,统一翻译成网关层能处理的标准格式。同时再将 Gateway/Runtime 产出的统一动作,按目标平台的能力约束重新编码并投递回去。Channel 是一个功能组件(Functional Component),它负责执行具体的消息转换逻辑(即“怎么翻译”)。

1.2 渠道层的核心价值

渠道层的存在,不只是为了接收消息,它还有三个非常重要的功能:

  1. 协议适配器:这是它的本职工作。无论消息来自Telegram的 grammY 框架、Discord的 discord.js,还是其他任何渠道,交互层都会将它们“翻译”成网关能统一理解的语言,让网关层无需关心消息的具体来源。
  2. 回复路由:当OpenClaw的智能体层处理完任务,生成了回复内容后,交互层会根据消息的来源,将回复内容原路送回。也就是说,从Telegram来的消息,回复会通过Telegram Bot API发回给你;从微信来的,回复就发回微信。这个路由是确定性的,由网关配置控制,而不是由AI模型决定。
  3. 消息输出与流式传输:在处理复杂任务时,为了让你不必干等,OpenClaw支持分块流式传输。智能体层会一边思考、一边行动,交互层则将这些进展以“草稿消息”或“分块消息”的形式实时推送给你。对于Telegram渠道,它甚至支持在生成最终消息前,用部分文本更新对话界面的“草稿气泡”,让交互体验更像真人对话。

1.3 过程

接收:通过长连接接收Telegram服务器发来的消息。
解析:提取消息内容、发送者等关键信息。
转换:封装成内部统一的标准格式。
派发:传递给网关做进一步处理。
路由:将最终的回复内容原路送回给用户。

1.4 标准化消息信封(Envelope)

1.4.1 定义

Envelope(信封)在 OpenClaw 中并非一个独立的 DTO 对象,而是一种文本格式的约定。它的核心表现是入站消息的 Body 字符串——通过 formatInboundEnvelope 将渠道原始消息格式化为统一文本前缀(如 [Telegram from ...] body),然后嵌入到 MsgContextctxPayload)中。它的设计目标是:无论消息来自哪个渠道,Agent 看到的都是一段统一前缀格式的纯文本,而不是结构化的 JSON 对象。

  • 源码 依据formatInboundEnvelope 返回的是字符串,最终被赋值给 MsgContext.Body,在层间传递的是 ctxPayload 对象。

核心价值:上层(网关层、智能体层)不需要知道消息是从哪个渠道来的,只需要处理 MsgContext.Body 中这个带有一致前缀格式的文本。

1.4.2 为什么需要 Envelope?

在没有 Envelope 的情况下,各渠道的消息格式差异巨大:

渠道消息格式差异
TelegramJSON 中包含 message.chat.id, message.from.id, message.text
微信XML 或 JSON,字段名完全不同(FromUserName, Content)
飞书事件结构中包含 event.message.chat_id, event.message.content
WhatsApp又有自己的一套字段命名

Envelope 的作用:将不同的格式抹平,统一成内部标准。

1.4.3 数据流转过程

Telegram原始JSON                MsgContext(标准化后)
┌─────────────────┐             ┌─────────────────────────────┐
│ {               │             │ {                           │
│   message: {    │             │   Body: "[Telegram from     │
│     chat: {     │   →         │           User id:123]      │
│       id: -100  │             │           查一下天气",       │
│     },          │             │   From: "telegram:123",     │
│     from: {     │             │   To: "telegram:123",       │
│       id: 7333  │             │   SessionKey: "telegram:    │
│     },          │             │       direct:123",          │
│     text: "查   │             │   Provider: "telegram",     │
│      一下天气"   │             │   Timestamp: 1744567890,    │
│   }             │             │   ...                       │
│ }               │             │ }                           │
└─────────────────┘             └─────────────────────────────┘

1.4.4 Envelope 的完整结构

根据 OpenClaw 源码,入站消息的 Envelope 结构如下:

interface Envelope {
  // ========== 基础信息 ==========
  id: string;                    // 消息唯一 ID
  channel: string;               // 渠道标识 (telegram/whatsapp/slack 等)
  timestamp: string;             // ISO 8601 时间戳
  
  // ========== 发送者信息 ==========
  from: {
    id: string;                  // 发送者 ID (渠道特定)
    name?: string;               // 发送者名称/用户名
    type?: "user" | "bot" | "system";  // 发送者类型
  };
  
  // ========== 接收者信息 ==========
  to: {
    id: string;                  // 接收者 ID (Bot ID 或会话 ID)
    type?: "bot" | "channel" | "group";
  };
  
  // ========== 消息内容 ==========
  content: ContentItem[];        // 消息内容数组 (支持多模态)
  
  // ========== 元数据 ==========
  metadata: {
    // 渠道特定信息
    chatId?: string;             // 渠道聊天 ID
    messageId?: string | number; // 渠道消息 ID
    threadId?: string | number;  // 主题/线程 ID (论坛/话题)
    
    // 会话信息
    sessionKey?: string;         // OpenClaw 会话标识
    runId?: string;              // Agent 运行 ID
    
    // 回复信息
    replyToMessageId?: string | number;  // 回复的目标消息 ID
    replyToUserId?: string;              // 回复的目标用户 ID
    
    // 群组信息
    isGroup?: boolean;           // 是否是群组消息
    groupId?: string;            // 群组 ID
    mentionPatterns?: string[];  // @提及模式
    
    // 媒体信息
    hasMedia?: boolean;          // 是否包含媒体
    mediaCount?: number;         // 媒体数量
    
    // 其他
    [key: string]: unknown;      // 允许扩展字段
  };
  
  // ========== 可选字段 ==========
  context?: {                    // 上下文信息 (用于多轮对话)
    conversationId?: string;
    parentId?: string;
  };
  
  delivery?: {                   // 投递配置
    mode?: "immediate" | "scheduled" | "batch";
    scheduledAt?: string;
  };
}
interface InboundMessageEnvelope {
  // === 核心字段(所有消息必有)===
  event: "message" | "system" | "broadcast" | "heartbeat";
  from: "user" | "system" | "scheduled" | "subagent";
  conversationId: string;        // 会话标识,如 "telegram:direct:7333732220"
  text: string;                  // 标准化后的消息文本
  timestamp: number;             // Unix 毫秒时间戳
  
  // === 路由相关 ===
  channel: string;               // 来源渠道(telegram/wechat/feishu...)
  agentId?: string;              // 目标 Agent(如已确定)
  peer?: {                       // 对端信息
    kind: "direct" | "group" | "channel";
    id: string;                  // 用户ID或群组ID
  };
  
  // === 渠道特定元数据(可选)===
  channel_context?: {
    // 各渠道自由定义,上层可选择性使用
    expectsReply?: boolean;      // 是否期望回复
    silentToken?: string;        // 静默标记
    rawEvent?: any;              // 原始事件(用于调试或特殊处理)
    replyToMessageId?: string;   // 回复的目标消息ID
  };
  
  // === 会话控制 ===
  sessionKey?: string;           // 显式指定 SessionKey(覆盖默认计算)
  
  // === 调试与追踪 ===
  traceId?: string;              // 全链路追踪ID
}

当智能体层生成回复后,同样使用 Envelope 传回交互层:

interface OutboundMessageEnvelope {
  conversationId: string;        // 目标会话(原路返回)
  text: string;                  // 回复内容
  format?: "text" | "markdown" | "html";  // 格式标记
  
  // 可选:渠道特定渲染
  channel_overrides?: {
    telegram?: { parse_mode: "MarkdownV2" };
    wechat?: { safe_mode: true };
  };
  
  // 流式传输相关
  streamId?: string;             // 流式会话ID
  isFinal?: boolean;             // 是否为最终消息
}

1.5 Monitor

1.5.1 什么是Monitor

Monitor 是 Channel 渠道层的连接与入口组件,负责与外部平台建立持久连接、接收原始消息、执行基础安全验证与过滤,然后将原始事件交给 Channel Plugin 的 Context Builder处理。

  • 以 Telegram 为例(extensions/telegram/src/monitor.ts:396-544),monitorTelegramProvider 只负责:

    • 加载配置、解析 token
    • 启动 grammY 的 polling runner 或 webhook server
    • 处理网络错误、持久化 update offset
    • 维持连接生命周期

源码 依据monitorTelegramProviderextensions/telegram/src/monitor.ts)和 monitorWeixinProvider(本地的 monitor.ts)中都没有 Envelope 格式化逻辑,格式化发生在 bot-message-context.session.tsbuildTelegramInboundContextPayload 中。

Monitor并不是每个Channel只有一个单一的组件,它是一组Monitor分工来完成不同的事情,核心的Monitor有:

Monitor 组件核心职责
ChannelInboundNormalizer将不同平台的原始消息格式(如 Telegram 的 Update、Discord 的 Message)解析为统一的内部事件负载。它相当于一个"翻译器",负责抽象身份标识,生成全局 SessionKey,确保 Gateway 和 Agent 能识别会话上下文。
ChannelSecurityEnforcer在消息进入 Gateway 前强制执行访问控制策略。它负责验证 Webhook 签名、检查发送者是否在白名单、执行速率限制,并将违规流量直接阻断,确保恶意请求不会进入核心处理流程。
ChannelHealthMonitor定期"探活"渠道的连接状态。它通过发送心跳、检查 WebSocket 连接、监听平台断开事件等方式,上报 Channel 的 Status(connected, reconnecting, failed),并在连接异常时触发告警或自动重连。
ChannelLifecycleManager管理 Channel 插件从加载到卸载的全过程。它确保 start、stop、restart 等状态变迁正确执行,并负责在应用启动时自动恢复之前的会话连接。
ChannelAuthProvider专门处理与平台的认证握手。它根据不同的平台要求,管理 Token 刷新、二维码扫码认证、OAuth 流程,为通信提供合法的身份凭证。
ChannelEventParser负责处理复杂的平台特有事件。比如处理 Telegram 的回调查询、Discord 的表情回应、WhatsApp 的已读回执等,将这些事件转化为 AgentEvent 进入事件总线。

1.5.2 Monitor的核心职责

  • 建立连接:与目标平台(如Telegram、Discord)建立持久的连接。这通常通过两种方式实现:

    • Webhook:提供一个回调URL,当有消息时,平台主动向该URL发送数据。这种方式实时性高,但需要公网地址。
    • 长轮询:Client端定期向Server发送请求,询问是否有新消息。这种方式实现简单,但可能存在延迟。
  • 接收原始数据:接收来自平台的、格式各异的原始数据(通常是 JSON),并执行基础过滤后,将原始事件传递给 Channel Plugin 的 Context Builder 进行字段提取和解析。

  • 安全与过滤:执行基础的安全验证(如验证请求签名,确保消息来源可靠),并根据配置进行消息过滤(如忽略机器人自己的消息)。

  • 事件派发:Monitor 将过滤后的原始事件交给 Channel Plugin 的 bot handlers 和 Context Builder。Context Builder 负责提取消息内容、元数据、媒体附件,并调用 formatInboundEnvelope 生成标准化的 Body 字符串,最终组装成 MsgContext(ctxPayload)提交给 Gateway。

1.5.3 内部协同工作流程

  • Monitor:就是连接层本身
  • Handlersbot-handlers.runtime.ts(做过滤策略)
  • Context Builderbot-message-context.session.ts(做格式化和上下文组装)
  • Dispatcherbot-message-dispatch.ts(做 Gateway 派发)

1.5.4 工作流程

┌─────────────────────────────────────────────────────────┐
│ 1. 渠道推送新消息                                        │
│    Telegram Bot API → Webhook 或 Long Polling           │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 2. Monitor 接收原始消息                                  │
│    { message: { chat: { id: "123" }, from: { id: "456" },│
│       text: "你好" } }                                   │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 3. 权限验证                                              │
│    - 检查 dmPolicy (pairing/allowlist/open)             │
│    - 检查 allowFrom 白名单                               │
│    - 检查群组 requireMention                            │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 4. 事件传递                                              |
|   Monitor 将过滤后的原始事件交给 Context Builder          |                |                                                         │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 5. Context Builder 构建 MsgContext                      │
│   - 提取消息内容、元数据、媒体附件                         │
│   - 调用 formatInboundEnvelope() 生成 Body 字符串        │ 
│   - 组装成 ctxPayload                                   │ 
│   { Body: "[Telegram from ...] 你好",                   │
│    SessionKey: "...", ... }                             │                │                                                         │
│                                                         │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 6. 发送到 Gateway 处理                                   │
│    Gateway → Agent Runtime → 生成回复                    │
└─────────────────────────────────────────────────────────┘

1.5.5 Monitor源码

import type { RunOptions } from "@grammyjs/runner";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { resolveAgentMaxConcurrent } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { isTelegramExecApprovalHandlerConfigured } from "./exec-approvals.js";
import { resolveTelegramTransport } from "./fetch.js";
import type { MonitorTelegramOpts } from "./monitor.types.js";
import {
  isRecoverableTelegramNetworkError,
  isTelegramPollingNetworkError,
} from "./network-errors.js";
import { makeProxyFetch } from "./proxy.js";

export type { MonitorTelegramOpts } from "./monitor.types.js";

export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions<unknown> {
  return {
    sink: {
      concurrency: resolveAgentMaxConcurrent(cfg),
    },
    runner: {
      fetch: {
        // Match grammY defaults
        timeout: 30,
        // Request reactions without dropping default update types.
        allowed_updates: resolveTelegramAllowedUpdates(),
      },
      // Suppress grammY getUpdates stack traces; we log concise errors ourselves.
      silent: true,
      // Keep grammY retrying for a long outage window. If polling still
      // stops, the outer monitor loop restarts it with backoff.
      maxRetryTime: 60 * 60 * 1000,
      retryInterval: "exponential",
    },
  };
}

function normalizePersistedUpdateId(value: number | null): number | null {
  if (value === null) {
    return null;
  }
  if (!Number.isSafeInteger(value) || value < 0) {
    return null;
  }
  return value;
}

/** Check if error is a Grammy HttpError (used to scope unhandled rejection handling) */
const isGrammyHttpError = (err: unknown): boolean => {
  if (!err || typeof err !== "object") {
    return false;
  }
  return (err as { name?: string }).name === "HttpError";
};

type TelegramMonitorPollingRuntime = typeof import("./monitor-polling.runtime.js");
type TelegramPollingSessionInstance = InstanceType<
  TelegramMonitorPollingRuntime["TelegramPollingSession"]
>;

let telegramMonitorPollingRuntimePromise:
  | Promise<typeof import("./monitor-polling.runtime.js")>
  | undefined;

async function loadTelegramMonitorPollingRuntime() {
  telegramMonitorPollingRuntimePromise ??= import("./monitor-polling.runtime.js");
  return await telegramMonitorPollingRuntimePromise;
}

let telegramMonitorWebhookRuntimePromise:
  | Promise<typeof import("./monitor-webhook.runtime.js")>
  | undefined;

async function loadTelegramMonitorWebhookRuntime() {
  telegramMonitorWebhookRuntimePromise ??= import("./monitor-webhook.runtime.js");
  return await telegramMonitorWebhookRuntimePromise;
}

export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
  const log = opts.runtime?.error ?? console.error;
  let pollingSession: TelegramPollingSessionInstance | undefined;

  const unregisterHandler = registerUnhandledRejectionHandler((err) => {
    const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" });
    const isTelegramPollingError = isTelegramPollingNetworkError(err);
    if (isGrammyHttpError(err) && isNetworkError && isTelegramPollingError) {
      log(`[telegram] Suppressed network error: ${formatErrorMessage(err)}`);
      return true;
    }

    const activeRunner = pollingSession?.activeRunner;
    if (isNetworkError && isTelegramPollingError && activeRunner && activeRunner.isRunning()) {
      pollingSession?.markForceRestarted();
      pollingSession?.markTransportDirty();
      pollingSession?.abortActiveFetch();
      void activeRunner.stop().catch(() => {});
      log("[telegram][diag] marking transport dirty after polling network failure");
      log(
        `[telegram] Restarting polling after unhandled network error: ${formatErrorMessage(err)}`,
      );
      return true;
    }

    return false;
  });

  try {
    const cfg = opts.config ?? loadConfig();
    const account = resolveTelegramAccount({
      cfg,
      accountId: opts.accountId,
    });
    const token = opts.token?.trim() || account.token;
    if (!token) {
      throw new Error(
        `Telegram bot token missing for account "${account.accountId}" (set channels.telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
      );
    }

    const proxyFetch =
      opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined);

    if (opts.useWebhook) {
      const { startTelegramWebhook } = await loadTelegramMonitorWebhookRuntime();
      if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
        registerChannelRuntimeContext({
          channelRuntime: opts.channelRuntime,
          channelId: "telegram",
          accountId: account.accountId,
          capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
          context: { token },
          abortSignal: opts.abortSignal,
        });
      }
      await startTelegramWebhook({
        token,
        accountId: account.accountId,
        config: cfg,
        path: opts.webhookPath,
        port: opts.webhookPort,
        secret: opts.webhookSecret ?? account.config.webhookSecret,
        host: opts.webhookHost ?? account.config.webhookHost,
        runtime: opts.runtime as RuntimeEnv,
        fetch: proxyFetch,
        abortSignal: opts.abortSignal,
        publicUrl: opts.webhookUrl,
        webhookCertPath: opts.webhookCertPath,
      });
      await waitForAbortSignal(opts.abortSignal);
      return;
    }

    const { TelegramPollingSession, readTelegramUpdateOffset, writeTelegramUpdateOffset } =
      await loadTelegramMonitorPollingRuntime();

    if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
      registerChannelRuntimeContext({
        channelRuntime: opts.channelRuntime,
        channelId: "telegram",
        accountId: account.accountId,
        capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
        context: { token },
        abortSignal: opts.abortSignal,
      });
    }

    const persistedOffsetRaw = await readTelegramUpdateOffset({
      accountId: account.accountId,
      botToken: token,
    });
    let lastUpdateId = normalizePersistedUpdateId(persistedOffsetRaw);
    if (persistedOffsetRaw !== null && lastUpdateId === null) {
      log(
        `[telegram] Ignoring invalid persisted update offset (${String(persistedOffsetRaw)}); starting without offset confirmation.`,
      );
    }

    const persistUpdateId = async (updateId: number) => {
      const normalizedUpdateId = normalizePersistedUpdateId(updateId);
      if (normalizedUpdateId === null) {
        log(`[telegram] Ignoring invalid update_id value: ${String(updateId)}`);
        return;
      }
      if (lastUpdateId !== null && normalizedUpdateId <= lastUpdateId) {
        return;
      }
      lastUpdateId = normalizedUpdateId;
      try {
        await writeTelegramUpdateOffset({
          accountId: account.accountId,
          updateId: normalizedUpdateId,
          botToken: token,
        });
      } catch (err) {
        (opts.runtime?.error ?? console.error)(
          `telegram: failed to persist update offset: ${String(err)}`,
        );
      }
    };

    // Preserve sticky IPv4 fallback state across clean/conflict restarts.
    // Dirty polling cycles rebuild transport inside TelegramPollingSession.
    const createTelegramTransportForPolling = () =>
      resolveTelegramTransport(proxyFetch, {
        network: account.config.network,
      });
    const telegramTransport = createTelegramTransportForPolling();

    pollingSession = new TelegramPollingSession({
      token,
      config: cfg,
      accountId: account.accountId,
      runtime: opts.runtime,
      proxyFetch,
      abortSignal: opts.abortSignal,
      runnerOptions: createTelegramRunnerOptions(cfg),
      getLastUpdateId: () => lastUpdateId,
      persistUpdateId,
      log,
      telegramTransport,
      createTelegramTransport: createTelegramTransportForPolling,
    });
    await pollingSession.runUntilAbort();
  } finally {
    unregisterHandler();
  }
}

1.6 Adaptor

1.6.1 什么是Adaptor

Adaptor是channel中另一个组件,与Monitor相反,Adapter专注于将内部的标准化指令,转化为外部平台的特定API调用。

1.6.2 核心职责

  • 接收内部指令:接收来自Agent或其他模块的、结构化的OutboundPayload(出站负载)对象。
  • 格式转换与适配:将OutboundPayload中的通用字段(如textattachments),转换为目标平台API所能理解的特定格式。例如,将Markdown格式的文本,转换为Discord或Telegram支持的特定标记语法。
  • 执行发送:通过调用平台的API,将转换后的消息发送出去,并处理发送过程中的业务适配异常(如消息过长分块、按钮格式错误降级)。底层网络错误和频率限制的重试逻辑通常由 HTTP transport 层(如 grammY 内部)处理。
  • 通道特定逻辑:处理特定于该消息通道的逻辑,例如消息分块(超过长度限制时自动拆分)、附件上传、消息编辑或删除等。

1.6.3 内部协同工作流程

Outbound Adapter 直接实现 ChannelOutboundAdapter 接口,将内部 ReplyPayload 转换为平台特定的 API 调用:

  • sendText / sendMedia / sendPayload:作为入口点,接收来自 Gateway/Runtime 的发送请求,将 ReplyPayload 映射为平台 API 参数
  • 格式转换与分块:负责将内部的 ReplyPayload 对象,转换为目标平台 API 所需的特定数据结构。例如将 Markdown 转为 Telegram HTML,并按长度限制分块
  • 底层发送函数(如 sendMessageTelegram):负责与外部平台的 API 进行实际的 HTTP 通信
  • 结果附加(attachChannelToResult):处理平台 API 返回的响应,将渠道特定的结果附加到统一的发送回执中
┌─────────────────────────────────────────────────────────┐
│ 1. Agent 生成回复内容                                    │
│    [{ type: "text", text: "你好" }]                     │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 2. Gateway 转发ReplyPayload                             │
│    { text: "你好", channelData: { telegram: {...} } }   │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 3. Adaptor 接收 ReplyPayload                             │
│    - 解析目标渠道 (telegram)                             │
│    - 解析目标用户 (123456)                               │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 4. Adaptor 执行协议转换                                  │
│    - 文本格式化:Markdown → HTML                         │
│    - 构建 API 请求:{ chat_id: "123456", text: "...",    │
│                      parse_mode: "HTML" }                │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 5. 调用渠道 API 发送消息                                  │
│    POST https://api.telegram.org/bot<token>/sendMessage  │
└─────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────┐
│ 6. 处理响应和错误                                        │
│    - 成功:记录 message_id 用于后续编辑                   │
│    - 失败:重试或返回错误信息                            │
└─────────────────────────────────────────────────────────┘

1.6.4 Adaptor源码

import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import {
  attachChannelToResult,
  createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime";
import {
  resolveOutboundSendDep,
  sanitizeForPlainText,
  type OutboundSendDeps,
} from "openclaw/plugin-sdk/outbound-runtime";
import {
  resolvePayloadMediaUrls,
  sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";

export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000;

type TelegramSendFn = typeof import("./send.js").sendMessageTelegram;
type TelegramSendOpts = Parameters<TelegramSendFn>[2];

let telegramSendModulePromise: Promise<typeof import("./send.js")> | undefined;

async function loadTelegramSendModule() {
  telegramSendModulePromise ??= import("./send.js");
  return await telegramSendModulePromise;
}

async function resolveTelegramSendContext(params: {
  cfg: NonNullable<TelegramSendOpts>["cfg"];
  deps?: OutboundSendDeps;
  accountId?: string | null;
  replyToId?: string | null;
  threadId?: string | number | null;
  gatewayClientScopes?: readonly string[];
}): Promise<{
  send: TelegramSendFn;
  baseOpts: {
    cfg: NonNullable<TelegramSendOpts>["cfg"];
    verbose: false;
    textMode: "html";
    messageThreadId?: number;
    replyToMessageId?: number;
    accountId?: string;
    gatewayClientScopes?: readonly string[];
  };
}> {
  const send =
    resolveOutboundSendDep<TelegramSendFn>(params.deps, "telegram") ??
    (await loadTelegramSendModule()).sendMessageTelegram;
  return {
    send,
    baseOpts: {
      verbose: false,
      textMode: "html",
      cfg: params.cfg,
      messageThreadId: parseTelegramThreadId(params.threadId),
      replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
      accountId: params.accountId ?? undefined,
      gatewayClientScopes: params.gatewayClientScopes,
    },
  };
}

export async function sendTelegramPayloadMessages(params: {
  send: TelegramSendFn;
  to: string;
  payload: ReplyPayload;
  baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
  const telegramData = params.payload.channelData?.telegram as
    | { buttons?: TelegramInlineButtons; quoteText?: string }
    | undefined;
  const quoteText =
    typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
  const text =
    resolveInteractiveTextFallback({
      text: params.payload.text,
      interactive: params.payload.interactive,
    }) ?? "";
  const mediaUrls = resolvePayloadMediaUrls(params.payload);
  const buttons = resolveTelegramInlineButtons({
    buttons: telegramData?.buttons,
    interactive: params.payload.interactive,
  });
  const payloadOpts = {
    ...params.baseOpts,
    quoteText,
  };

  // Telegram allows reply_markup on media; attach buttons only to the first send.
  return await sendPayloadMediaSequenceOrFallback({
    text,
    mediaUrls,
    fallbackResult: { messageId: "unknown", chatId: params.to },
    sendNoMedia: async () =>
      await params.send(params.to, text, {
        ...payloadOpts,
        buttons,
      }),
    send: async ({ text, mediaUrl, isFirst }) =>
      await params.send(params.to, text, {
        ...payloadOpts,
        mediaUrl,
        ...(isFirst ? { buttons } : {}),
      }),
  });
}

export const telegramOutbound: ChannelOutboundAdapter = {
  deliveryMode: "direct",
  chunker: markdownToTelegramHtmlChunks,
  chunkerMode: "markdown",
  textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
  sanitizeText: ({ text }) => sanitizeForPlainText(text),
  shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
  resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
    typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
  ...createAttachedChannelResultAdapter({
    channel: "telegram",
    sendText: async ({
      cfg,
      to,
      text,
      accountId,
      deps,
      replyToId,
      threadId,
      gatewayClientScopes,
    }) => {
      const { send, baseOpts } = await resolveTelegramSendContext({
        cfg,
        deps,
        accountId,
        replyToId,
        threadId,
        gatewayClientScopes,
      });
      return await send(to, text, {
        ...baseOpts,
      });
    },
    sendMedia: async ({
      cfg,
      to,
      text,
      mediaUrl,
      mediaLocalRoots,
      mediaReadFile,
      accountId,
      deps,
      replyToId,
      threadId,
      forceDocument,
      gatewayClientScopes,
    }) => {
      const { send, baseOpts } = await resolveTelegramSendContext({
        cfg,
        deps,
        accountId,
        replyToId,
        threadId,
        gatewayClientScopes,
      });
      return await send(to, text, {
        ...baseOpts,
        mediaUrl,
        mediaLocalRoots,
        mediaReadFile,
        forceDocument: forceDocument ?? false,
      });
    },
  }),
  sendPayload: async ({
    cfg,
    to,
    payload,
    mediaLocalRoots,
    mediaReadFile,
    accountId,
    deps,
    replyToId,
    threadId,
    forceDocument,
    gatewayClientScopes,
  }) => {
    const { send, baseOpts } = await resolveTelegramSendContext({
      cfg,
      deps,
      accountId,
      replyToId,
      threadId,
      gatewayClientScopes,
    });
    const result = await sendTelegramPayloadMessages({
      send,
      to,
      payload,
      baseOpts: {
        ...baseOpts,
        mediaLocalRoots,
        mediaReadFile,
        forceDocument: forceDocument ?? false,
      },
    });
    return attachChannelToResult("telegram", result);
  },
};

1.7 Monitor与Adapter:一体两面的协同

Monitor和Adapter并非孤立存在,它们通过紧密协作,形成了一个完整的消息处理闭环。

  1. 从消息入站到出站

    1. 入站:外部消息 MonitorContext BuilderMsgContext Gateway Agent
    2. 出站:Agent回复OutboundPayloadAdapter外部消息
  2. 共享上下文与状态 一个Channel实例中,Monitor和Adapter通常会共享一些关键的上下文信息,例如:

    1. 认证凭据:共享访问外部平台API所需的Token或密钥。
    2. 配置信息:共享该Channel的通用配置,如机器人名称、命令前缀等。
    3. 会话状态:共享与平台的连接状态,以便在断开时统一重连。
    4. 用户/群组缓存:共享用户、群组信息的缓存,避免重复请求,提高性能。

💎 总结:标准化的“翻译官”与“本地化专员”

Monitor和Adapter的设计,体现了典型的关注点分离和单一职责原则。Monitor 负责连接接收 ,Context Builder 负责入站标准化 ,Outbound Adapter 负责出站发送" 的三元分工。

1.8 Overview

OpenClaw 渠道层( Channel Layer)Overview


1.8.1 定位与核心价值

渠道层是 OpenClaw 与外部世界的唯一接触面。它的核心目标不是"抽象所有差异",而是完成两件事:

  • 入站:把 Telegram、微信、WhatsApp、Discord 等平台的原始协议事件,转化为 Agent Runtime 能理解的 MsgContext
  • 出站:把 Agent Runtime 产出的 ReplyPayload,按目标平台的 API 约束重新编码并投递出去。

核心价值在于 解耦:Agent Runtime 和 Gateway 不需要知道消息来自 Telegram 还是微信,它们只处理统一的 MsgContext.BodyReplyPayload


1.8.2 真实的三元结构(不是 Monitor ↔ Adapter 二元对立)

在源码中,一个 Channel Plugin 的内部由三个职责边界清晰的模块协同完成消息闭环:

模块源码中的典型位置职责
Monitorextensions/telegram/src/monitor.ts
本地的 openclaw-weixin/src/monitor/monitor.ts
连接守门人。负责建立并维持与外部平台的连接(Long Polling / Webhook)、接收原始事件、执行基础安全过滤(allowlist / dmPolicy / debounce)、处理网络恢复与退避。
Context Builderextensions/telegram/src/bot-message-context.session.ts真正的翻译官。从原始事件中提取 body、media、metadata、路由信息;调用 formatInboundEnvelope 生成标准化的 Body 字符串;加载 sessionStore 读取历史;最终组装成 MsgContext(ctxPayload)。
Outbound Adapterextensions/telegram/src/outbound-adapter.ts本地化的发送专员。接收 ReplyPayload,执行格式转换(如 Markdown → Telegram HTML)、媒体处理、消息分块(4096 字符限制)、按钮解析,最后直接调用平台 API(如 sendMessage)发出。

关键认知:Monitor 不生成 Envelope,Adapter 也不处理入站标准化。Context Builder 是连接 Monitor 与 Gateway 的必经桥梁。


1.8.3 Envelope 的本质:格式约定,不是 DTO

OpenClaw 源码中不存在一个名为 Envelope 的独立接口或 DTO 在层间传递。

  • formatInboundEnvelopesrc/auto-reply/envelope.ts)的返回值是 string
  • 这个字符串被赋值给 MsgContext.Body,格式如:
[Telegram from Joshua id:123 Wed 14 Apr 10:27] 今天天气如何?
  • Agent Runtime 实际阅读的就是这段带前缀的纯文本,而不是一个结构化的 JSON 对象。

结论:Envelope 是一种文本格式的约定,具体表现为 MsgContext.Body 的统一前缀。真正在 Monitor → Gateway → Agent 之间流转的是 MsgContext(扁平的 ctxPayload 对象)。


1.8.4 数据流转路径

入站(用户 → Agent)

用户 → Telegram API
        ↓
      Monitor(接收原始 Update,过滤)
        ↓
      Context Builder(提取 body → formatInboundEnvelope → 组装 ctxPayload)
        ↓
      Dispatcher(bot-message-dispatch.ts,申请 typing/ack)
        ↓
      Gateway(路由分发,按 session queue 策略排队)
        ↓
      Agent Runtime

出站(Agent → 用户)

Agent Runtime → ReplyPayload
        ↓
      Gateway(更新 session store,转发)
        ↓
      Outbound Adapter(Markdown→HTML、分块、组装 API 参数)
        ↓
      Telegram API → 用户

1.8.5 Plugin 系统:一切皆插件

OpenClaw 的所有渠道都以 Channel Plugin 的形式存在,位于 extensions/ 目录下(如 extensions/telegram/extensions/whatsapp/)。

  • 注册入口:通过 defineBundledChannelEntryextensions/telegram/index.ts)向 Gateway 注册。
  • 加载机制:Gateway 启动时扫描 extensions/~/.openclaw/workspace/skills/,动态 import() 加载。
  • 本地扩展:用户可以在 ~/.openclaw/workspace/skills/<skill>/SKILL.md 下编写自定义 skill,与 bundled skill 一起被 Agent 调用。

这意味着新增一个聊天平台,不需要改动 Gateway 或 Agent Runtime 的核心代码,只需实现一个新的 Channel Plugin(Monitor + Context Builder + Outbound Adapter)即可。


1.8.6 与 Gateway 的边界

渠道层与 Gateway 层的边界非常清晰:

  • 渠道层负责

    • 平台协议的收发(Telegram Bot API、微信长轮询等)
    • 平台特定的安全策略(allowlist、mention gating、dmPolicy)
    • 原始消息 → MsgContext 的转换
    • ReplyPayload → 平台 API 的转换
  • Gateway 负责

    • WebSocket 控制平面的生命周期(ws://127.0.0.1:18789
    • Session 路由与 queue 策略(sequential / concurrent / sliding)
    • Agent 绑定与设备节点(Node)管理
    • 不触碰任何平台特定的 API 细节

一句话总结:渠道层知道 Telegram 和微信的区别,Gateway 不知道;Gateway 知道哪个 Agent 应该处理哪条会话,渠道层不知道。


1.8.7 常见误区纠正

误区事实
Monitor 把原始消息转成标准 Envelope❌ Monitor 只做连接和过滤,标准化在 Context Builder
Envelope 是一个独立的 JSON 对象/接口❌ Envelope 是 MsgContext.Body 里的一段字符串
Adapter 处理入站消息的标准化❌ Adapter 只处理出站,入站标准化与 Adapter 无关
存在 SendService / MessageBuilder / APIClient 等子组件❌ Outbound Adapter 直接实现 ChannelOutboundAdapter 接口,是一组函数(sendText / sendMedia / sendPayload)
Monitor ↔ Adapter 是二元对立❌ 实际是 Monitor → Context Builder → Outbound Adapter 的三元协同

1.8.8 关键源码速查

功能文件路径
Envelope 格式化src/auto-reply/envelope.ts
Telegram Monitorextensions/telegram/src/monitor.ts
Telegram Context Builderextensions/telegram/src/bot-message-context.session.ts
Telegram Dispatcherextensions/telegram/src/bot-message-dispatch.ts
Telegram Outbound Adapterextensions/telegram/src/outbound-adapter.ts
微信 Monitor(本地示例)~/.openclaw/extensions/openclaw-weixin/src/monitor/monitor.ts
Plugin 注册入口extensions/telegram/index.ts