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 渠道层的核心价值
渠道层的存在,不只是为了接收消息,它还有三个非常重要的功能:
- 协议适配器:这是它的本职工作。无论消息来自Telegram的
grammY框架、Discord的discord.js,还是其他任何渠道,交互层都会将它们“翻译”成网关能统一理解的语言,让网关层无需关心消息的具体来源。 - 回复路由:当OpenClaw的智能体层处理完任务,生成了回复内容后,交互层会根据消息的来源,将回复内容原路送回。也就是说,从Telegram来的消息,回复会通过Telegram Bot API发回给你;从微信来的,回复就发回微信。这个路由是确定性的,由网关配置控制,而不是由AI模型决定。
- 消息输出与流式传输:在处理复杂任务时,为了让你不必干等,OpenClaw支持分块流式传输。智能体层会一边思考、一边行动,交互层则将这些进展以“草稿消息”或“分块消息”的形式实时推送给你。对于Telegram渠道,它甚至支持在生成最终消息前,用部分文本更新对话界面的“草稿气泡”,让交互体验更像真人对话。
1.3 过程
接收:通过长连接接收Telegram服务器发来的消息。
解析:提取消息内容、发送者等关键信息。
转换:封装成内部统一的标准格式。
派发:传递给网关做进一步处理。
路由:将最终的回复内容原路送回给用户。
1.4 标准化消息信封(Envelope)
1.4.1 定义
Envelope(信封)在 OpenClaw 中并非一个独立的 DTO 对象,而是一种文本格式的约定。它的核心表现是入站消息的 Body 字符串——通过 formatInboundEnvelope 将渠道原始消息格式化为统一文本前缀(如 [Telegram from ...] body),然后嵌入到 MsgContext(ctxPayload)中。它的设计目标是:无论消息来自哪个渠道,Agent 看到的都是一段统一前缀格式的纯文本,而不是结构化的 JSON 对象。
- 源码 依据:
formatInboundEnvelope返回的是字符串,最终被赋值给MsgContext.Body,在层间传递的是ctxPayload对象。
核心价值:上层(网关层、智能体层)不需要知道消息是从哪个渠道来的,只需要处理 MsgContext.Body 中这个带有一致前缀格式的文本。
1.4.2 为什么需要 Envelope?
在没有 Envelope 的情况下,各渠道的消息格式差异巨大:
| 渠道 | 消息格式差异 |
|---|---|
| Telegram | JSON 中包含 message.chat.id, message.from.id, message.text |
| 微信 | XML 或 JSON,字段名完全不同(FromUserName, Content) |
| 飞书 | 事件结构中包含 event.message.chat_id, event.message.content |
| 又有自己的一套字段命名 |
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
- 维持连接生命周期
源码 依据:monitorTelegramProvider(extensions/telegram/src/monitor.ts)和 monitorWeixinProvider(本地的 monitor.ts)中都没有 Envelope 格式化逻辑,格式化发生在 bot-message-context.session.ts 的 buildTelegramInboundContextPayload 中。
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:就是连接层本身
- Handlers:
bot-handlers.runtime.ts(做过滤策略) - Context Builder:
bot-message-context.session.ts(做格式化和上下文组装) - Dispatcher:
bot-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中的通用字段(如text、attachments),转换为目标平台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并非孤立存在,它们通过紧密协作,形成了一个完整的消息处理闭环。
-
从消息入站到出站
- 入站:
外部消息→Monitor→Context Builder→MsgContext→Gateway→Agent - 出站:
Agent回复→OutboundPayload→Adapter→外部消息
- 入站:
-
共享上下文与状态 一个Channel实例中,Monitor和Adapter通常会共享一些关键的上下文信息,例如:
- 认证凭据:共享访问外部平台API所需的Token或密钥。
- 配置信息:共享该Channel的通用配置,如机器人名称、命令前缀等。
- 会话状态:共享与平台的连接状态,以便在断开时统一重连。
- 用户/群组缓存:共享用户、群组信息的缓存,避免重复请求,提高性能。
💎 总结:标准化的“翻译官”与“本地化专员”
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.Body 和 ReplyPayload。
1.8.2 真实的三元结构(不是 Monitor ↔ Adapter 二元对立)
在源码中,一个 Channel Plugin 的内部由三个职责边界清晰的模块协同完成消息闭环:
| 模块 | 源码中的典型位置 | 职责 |
|---|---|---|
| Monitor | extensions/telegram/src/monitor.ts 本地的 openclaw-weixin/src/monitor/monitor.ts | 连接守门人。负责建立并维持与外部平台的连接(Long Polling / Webhook)、接收原始事件、执行基础安全过滤(allowlist / dmPolicy / debounce)、处理网络恢复与退避。 |
| Context Builder | extensions/telegram/src/bot-message-context.session.ts | 真正的翻译官。从原始事件中提取 body、media、metadata、路由信息;调用 formatInboundEnvelope 生成标准化的 Body 字符串;加载 sessionStore 读取历史;最终组装成 MsgContext(ctxPayload)。 |
| Outbound Adapter | extensions/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 在层间传递。
formatInboundEnvelope(src/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/)。
- 注册入口:通过
defineBundledChannelEntry(extensions/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 细节
- WebSocket 控制平面的生命周期(
一句话总结:渠道层知道 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 Monitor | extensions/telegram/src/monitor.ts |
| Telegram Context Builder | extensions/telegram/src/bot-message-context.session.ts |
| Telegram Dispatcher | extensions/telegram/src/bot-message-dispatch.ts |
| Telegram Outbound Adapter | extensions/telegram/src/outbound-adapter.ts |
| 微信 Monitor(本地示例) | ~/.openclaw/extensions/openclaw-weixin/src/monitor/monitor.ts |
| Plugin 注册入口 | extensions/telegram/index.ts |