Openclaw · EN · Part 2
Ch 06 Channel System And Messaging Integration
06-ch-06-Channel-System-and-Messaging-Integration.md
第6章 Channel System and Messaging Integration
The Channel Contract
The channel plugin interface defines how messaging platforms integrate with OpenClaw. It's not a simple "send and receive" contract—it's a comprehensive system covering inbound/outbound handling, pairing, policy enforcement, authentication, lifecycle management, and rich media support.
ChannelConfigRuntimeSchema
Every channel plugin defines its configuration schema using a runtime validator:
export type ChannelConfigRuntimeSchema = {
safeParse: (value: unknown) => ChannelConfigRuntimeParseResult;
};
export type ChannelConfigRuntimeParseResult =
| { success: true; data: unknown }
| { success: false; issues: ChannelConfigRuntimeIssue[] };
This schema drives config validation, UI generation, and setup wizards. The safeParse method accepts unknown input and returns either success with parsed data or failure with structured issues that guide users toward correct configuration.
// Example from Telegram channel
const telegramConfigSchema: ChannelConfigSchema = {
schema: {
type: "object",
properties: {
botToken: { type: "string" },
allowFrom: { type: "array", items: { type: "string" } },
dmPolicy: { type: "string", enum: ["ask", "pair", "all"] },
},
required: ["botToken"],
},
uiHints: {
botToken: {
label: "Bot Token",
help: "From @BotFather on Telegram",
sensitive: true,
},
dmPolicy: {
label: "DM Access Policy",
help: "Who can send you DMs",
},
},
runtime: zodSchema, // Zod schema for runtime validation
};
🤿 深入底层: The schema serves three distinct purposes: (1) static validation in
openclaw doctor, (2) dynamic UI generation in the web/Gateway setup wizards, and (3) runtime parsing in hot paths. Theruntimefield uses Zod for performant validation during message processing.
Inbound and Outbound Methods
The core of the channel contract splits into two directions:
Inbound adapters handle messages arriving from the platform:
ChannelConfigAdapter: List accounts, resolve configuration, check enablementChannelGroupAdapter: Resolve mention requirements, tool policies for groupsChannelSecurityAdapter: Evaluate DM policy, collect warningsChannelDirectoryAdapter: Self discovery, peer/group listingChannelResolverAdapter: Resolve target IDs to human-readable names
Outbound adapters handle sending replies back to the platform:
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
textChunkLimit?: number;
sanitizeText?: (params: { text: string; payload: ReplyPayload }) => string;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise<OutboundDeliveryResult[]>;
};
The deliveryMode determines how messages reach the platform:
"direct": Channel sends directly (e.g., Telegram bot API)"gateway": Messages route through the Gateway WebSocket protocol"hybrid": Smart routing based on context and capabilities
Pairing Mechanisms
Channel pairing controls who can interact with your assistant. The ChannelPairingAdapter defines how new connections are established:
export type ChannelPairingAdapter = {
idLabel: string; // What to call the user ID (e.g., "Telegram user ID")
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: {
cfg: OpenClawConfig;
id: string;
accountId?: string;
runtime?: RuntimeEnv;
}) => Promise<void>;
};
Pairing works through the DM allowlist system. When dmPolicy is set to "pair" or "ask", unknown senders trigger a pairing request that must be approved before their messages reach the agent. The notifyApproval callback sends this notification through the configured channel.
Policy Enforcement
Channels enforce access control at multiple levels:
- DM Policy: Controlled by
dmPolicyconfig ("ask","pair","all") - Group Policy: Controlled by
groupPolicyconfig ("sender","route","all") - Allowlists:
allowFromfor DMs,groupAllowFromfor groups - Group Overrides: Per-group allowlists override account-level settings
The ChannelSecurityAdapter evaluates these policies and can return warnings or critical findings:
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ChannelSecurityContext<ResolvedAccount>,
) => ChannelSecurityDmPolicy | null;
collectWarnings?: (ctx: ChannelSecurityContext<ResolvedAccount>) => Promise<string[]> | string[];
collectAuditFindings?: (ctx: ChannelSecurityContext<ResolvedAccount> & {
sourceConfig: OpenClawConfig;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) => Promise<Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}>>;
};
☕ 茶水间时间: Why the complexity? Because OpenClaw runs on your own devices with your credentials. If someone discovers your bot token, they could spam your assistant. The default DM
"ask"policy and allowlists ensure that even with compromised credentials, attackers can't interact without approval.
Inbound Message Processing
When a message arrives from a platform, it follows a structured pipeline before reaching the agent. Let's trace this flow through the Telegram channel implementation.
Channel Architecture Flow
Rendering diagram...
Envelope Creation
The envelope wraps inbound messages with metadata that gives the agent context about who sent the message, when it was sent, and what channel it came from:
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const channel = sanitizeEnvelopeHeaderPart(params.channel?.trim() || "Channel");
const parts: string[] = [channel];
const resolved = normalizeEnvelopeOptions(params.envelope);
let elapsed: string | undefined;
if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) {
const currentMs = params.timestamp instanceof Date
? params.timestamp.getTime()
: params.timestamp;
const previousMs = params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp;
const elapsedMs = currentMs - previousMs;
elapsed = Number.isFinite(elapsedMs) && elapsedMs >= 0
? formatTimeAgo(elapsedMs, { suffix: false })
: undefined;
}
if (params.from?.trim()) {
const from = sanitizeEnvelopeHeaderPart(params.from.trim());
parts.push(elapsed ? `${from} +${elapsed}` : from);
} else if (elapsed) {
parts.push(`+${elapsed}`);
}
const ts = formatEnvelopeTimestamp(params.timestamp, resolved);
if (ts) parts.push(ts);
const header = `[${parts.join(" ")}]`;
return `${header} ${params.body}`;
}
🤿 深入底层: Envelope headers are sanitized to prevent message injection. All brackets become parentheses, newlines collapse to spaces, and excess whitespace is trimmed. This ensures user messages cannot forge envelope metadata or break the agent's context parsing.
Metadata Extraction
Each platform message contains platform-specific metadata that must be extracted into a normalized form:
type TelegramMessageContext = {
ctxPayload: TelegramMessageContextPayload["ctxPayload"];
primaryCtx: BuildTelegramMessageContextParams["primaryCtx"];
msg: BuildTelegramMessageContextParams["primaryCtx"]["message"];
chatId: number;
isGroup: boolean;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
resolvedThreadId?: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
replyThreadId?: number;
isForum: boolean;
historyKey?: string;
historyLimit: number;
groupHistories: GroupHistories;
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
skillFilter: TelegramMessageContextPayload["skillFilter"];
sendTyping: () => Promise<void>;
sendRecordVoice: () => Promise<void>;
ackReactionPromise: Promise<boolean> | null;
reactionApi: TelegramReactionApi | null;
removeAckAfterReply: boolean;
statusReactionController: StatusReactionController | null;
accountId: string;
};
The extraction process handles:
- Chat type detection: DM vs group vs channel
- Thread resolution: Forum topics, message threads, or flat conversations
- Sender identification: User ID, username, display name
- Media handling: Photos, documents, voice messages, stickers
- Reply chains:
reply_to_messagefields for threading context
Routing to Sessions
Messages must be routed to the correct agent session. OpenClaw uses a hierarchical session key system:
telegram:<accountId>:<chatId> # DM or group
telegram:<accountId>:<chatId>:<thread> # Threaded conversation
The routing resolver considers:
- Explicit bindings: User-configured agent assignments
- Agent per topic: Forum topics can have dedicated agents
- Default fallback: The main agent when no binding exists
- Named account isolation: Non-default accounts require explicit bindings
const { route, configuredBinding, configuredBindingSessionKey } =
resolveTelegramConversationRoute({
cfg: freshCfg,
accountId: account.accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId,
senderId,
topicAgentId: topicConfig?.agentId,
});
const requiresExplicitAccountBinding = (candidate): boolean =>
candidate.accountId !== DEFAULT_ACCOUNT_ID &&
candidate.matchedBy === "default";
if (requiresExplicitAccountBinding(route) && isGroup) {
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "non-default account requires explicit binding",
target: route.accountId,
});
return null; // Drop the message
}
Context Visibility Handling
Not all message content should be visible to the agent. OpenClaw applies context visibility filters:
- Mention stripping: Bot mentions removed in group chats
- Reply context: Original message content when replying
- Thread context: Parent messages in threaded conversations
- Quoted content: Forwarded message content when relevant
- Control commands: Commands like
/agentsfiltered from agent input
The ChannelMentionAdapter defines how mentions are stripped:
export type ChannelMentionAdapter = {
stripRegexes?: (params: {
ctx: MsgContext;
cfg: OpenClawConfig | undefined;
agentId?: string;
}) => RegExp[];
stripPatterns?: (params: {
ctx: MsgContext;
cfg: OpenClawConfig | undefined;
agentId?: string;
}) => string[];
stripMentions?: (params: {
text: string;
ctx: MsgContext;
cfg: OpenClawConfig | undefined;
agentId?: string;
}) => string;
};
Reply Dispatch and Formatting
After the agent processes a message, responses must be dispatched back through the appropriate channel with proper formatting.
Reply Dispatch Logic
The reply dispatcher handles three distinct streams:
- Tool events: Real-time updates when tools execute
- Block streaming: Incremental text generation during inference
- Final replies: Complete agent responses
Each stream has different formatting and delivery requirements:
type DispatchOptions = {
deliver: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
formatText?: (text: string) => string;
chunkLimit?: number;
abortSignal?: AbortSignal;
};
type ReplyPayload = {
text: string;
format?: "plain" | "markdown" | "html";
components?: ChannelStructuredComponents;
media?: { url: string; filename?: string };
channelData?: Record<string, unknown>;
};
The dispatcher coordinates these streams to ensure:
- Streaming responses appear incrementally
- Tool events don't interrupt the final reply
- Platform-specific formatting is applied
- Rate limits are respected
Message Formatting
Channels define how agent output is formatted for their platform:
export type ChannelOutboundAdapter = {
chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sanitizeText?: (params: {
text: string;
payload: ReplyPayload
}) => string;
normalizePayload?: (params: {
payload: ReplyPayload
}) => ReplyPayload | null;
};
Text chunking splits long messages into platform-appropriate parts:
function chunkMarkdown(text: string, limit: number): string[] {
// Preserve code blocks, headers, and list integrity
const chunks: string[] = [];
let current = "";
let inCodeBlock = false;
let inList = false;
for (const line of text.split("\n")) {
if (line.startsWith("```")) {
inCodeBlock = !inCodeBlock;
}
if (line.match(/^[\s]*[-*+]\s/)) {
inList = true;
} else if (line.trim() === "") {
inList = false;
}
const wouldExceed = current.length + line.length > limit;
const shouldSplit = wouldExceed && !inCodeBlock && !inList;
if (shouldSplit && current) {
chunks.push(current.trim());
current = line + "\n";
} else {
current += line + "\n";
}
}
if (current) chunks.push(current.trim());
return chunks;
}
Platform-Specific Constraints
Each platform has unique constraints that the formatter must respect:
| Platform | Text Limit | Formatting | Media | Threads |
|---|---|---|---|---|
| Telegram | 4096 chars | Markdown V2 | Yes | Forum topics |
| Discord | 2000 chars | Markdown | Yes | Threaded channels |
| Slack | 40000 chars | Markdown-ish | Yes | Parent/child messages |
| 4096 chars | None | Yes | None | |
| Signal | 2000 chars | None | Limited | None |
The ChannelCapabilities type declares what a channel supports:
export type ChannelCapabilities = {
chatTypes: Array<ChatType | "thread">;
polls?: boolean;
reactions?: boolean;
edit?: boolean;
unsend?: boolean;
reply?: boolean;
effects?: boolean;
groupManagement?: boolean;
threads?: boolean;
media?: boolean;
nativeCommands?: boolean;
blockStreaming?: boolean;
};
Rich Media Handling
Rich media (images, audio, video, polls) requires special handling:
export type ChannelPollContext = {
cfg: OpenClawConfig;
to: string;
poll: PollInput;
accountId?: string | null;
threadId?: string | null;
silent?: boolean;
isAnonymous?: boolean;
gatewayClientScopes?: readonly string[];
};
export type ChannelPollResult = {
messageId: string;
toJid?: string;
channelId?: string;
conversationId?: string;
pollId?: string;
};
Channels that support polls implement sendPoll with platform-specific parameters. For example, Telegram supports:
- Duration limits (1-600 seconds)
- Anonymous vs non-anonymous polls
- Multiple correct answers for quizzes
Pairing and Security
Channel pairing and security form the foundation of safe multi-platform messaging integration.
Pairing Mechanisms
The pairing flow varies by platform but follows a common pattern:
- Unknown sender: User not in
allowFromsends a message - Pairing request: OpenClaw creates a pending approval
- Notification: Sent through configured channel
- User approval:
/approvecommand or native approval button - Allowlist update: Sender added to
allowFrom
export type ChannelPairingAdapter = {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: {
cfg: OpenClawConfig;
id: string;
accountId?: string;
runtime?: RuntimeEnv;
}) => Promise<void>;
};
The normalizeAllowEntry function handles platform-specific ID normalization:
// Telegram: numeric IDs vs usernames
function normalizeTelegramAllowEntry(entry: string): string {
const numeric = entry.trim().replace(/^@/, "");
if (/^\d+$/.test(numeric)) {
return numeric; // User ID
}
return `@${numeric}`; // Username
}
// Discord: user#discriminator vs user ID
function normalizeDiscordAllowEntry(entry: string): string {
if (entry.match(/^\d{17,20}$/)) {
return entry; // Snowflake ID
}
if (entry.includes("#")) {
return entry; // user#discriminator
}
return entry; // Username (may be ambiguous)
}
DM Allowlists
The DM allowlist system provides fine-grained control over who can send you direct messages:
export type ChannelSecurityDmPolicy = {
policy: string; // "ask", "pair", "all"
allowFrom?: Array<string | number> | null;
policyPath?: string;
allowFromPath: string;
approveHint: string;
normalizeEntry?: (raw: string) => string;
};
Policy modes:
"ask": Unknown senders trigger approval requests"pair": Like"ask"but approvals persist toallowFrom"all": Anyone can message (not recommended for public bots)
The allowFromPath indicates where the allowlist is stored, enabling per-account, per-topic, or per-group overrides:
telegram.allowFrom # Account-level
telegram.groups.<id>.allowFrom # Group-level
telegram.topics.<id>.allowFrom # Topic-level
Access Control
Group access control operates differently from DM access:
- Sender policy: Check if sender is in
groupAllowFrom - Route policy: Check if group route is configured
- Mention requirement: Some platforms require bot mentions
- Group overrides: Specific groups can override account settings
export type ChannelGroupAdapter = {
resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;
resolveToolPolicy?: (params: ChannelGroupContext) => GroupToolPolicyConfig | undefined;
};
The tool policy controls which agent tools are available in group contexts:
type GroupToolPolicyConfig = {
allowed?: string[]; // Allowed tool names
blocked?: string[]; // Blocked tool names
requireMention?: boolean; // Tools require explicit mention
};
☕ 茶水间时间: Why different policies for DMs vs groups? In DMs, you want explicit control over who messages you. In groups, the attack surface is different—anyone can add your bot to a group, so the policy focuses on route configuration and mention requirements rather than individual sender approval.
Security Model and Trust Boundaries
The channel security model builds on three trust boundaries:
- Credential boundary: Platform tokens/keys stored securely
- Network boundary: Webhooks verified, rate limits enforced
- Content boundary: Messages sanitized, metadata validated
export type ChannelSecurityContext<ResolvedAccount = unknown> = {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedAccount;
};
Security checks occur at multiple layers:
┌─────────────────────────────────────────────────────────┐
│ Platform Layer │
│ - Webhook signature verification │
│ - TLS encryption │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Channel Layer │
│ - Account enablement checks │
│ - Allowlist validation │
│ - Rate limiting │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Routing Layer │
│ - Session binding validation │
│ - Agent policy enforcement │
│ - Tool policy checks │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Agent Layer │
│ - Prompt injection protection │
│ - Tool parameter validation │
│ - Output sanitization │
└─────────────────────────────────────────────────────────┘
Each boundary is independently enforceable—a failure at one layer doesn't compromise the others. This defense-in-depth approach ensures that even if one check fails, others provide protection.
Minimal Channel Implementation
Here's a complete minimal channel plugin implementation:
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
// Types
interface SimpleAccount {
accountId: string;
token: string;
enabled: boolean;
allowFrom: Array<string | number>;
dmPolicy: "ask" | "pair" | "all";
}
// Plugin definition
const simplePlugin: ChannelPlugin<SimpleAccount> = {
id: "simple",
meta: {
id: "simple",
label: "Simple",
selectionLabel: "Simple",
docsPath: "/channels/simple",
blurb: "A minimal channel example",
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reply: true,
},
// Config adapter
config: {
listAccountIds: (cfg) => Object.keys(cfg.simple?.accounts ?? {}),
resolveAccount: (cfg, accountId) => {
const accounts = cfg.simple?.accounts ?? {};
return accounts[accountId ?? "default"] ?? accounts["default"];
},
isEnabled: (account) => account.enabled,
resolveAllowFrom: (params) => params?.allowFrom,
},
// Config schema
configSchema: {
schema: {
type: "object",
properties: {
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
token: { type: "string" },
enabled: { type: "boolean" },
allowFrom: { type: "array", items: { type: "string" } },
dmPolicy: { type: "string", enum: ["ask", "pair", "all"] },
},
required: ["token"],
},
},
},
},
uiHints: {
token: { label: "API Token", sensitive: true },
allowFrom: { label: "Allowed Senders", help: "User IDs who can message you" },
dmPolicy: { label: "DM Policy" },
},
},
// Pairing adapter
pairing: {
idLabel: "User ID",
normalizeAllowEntry: (entry) => entry.trim(),
notifyApproval: async ({ cfg, id }) => {
// Send approval notification through your platform
console.log(`[simple] Pairing request from: ${id}`);
},
},
// Security adapter
security: {
resolveDmPolicy: (ctx) => ({
policy: ctx.account.dmPolicy || "ask",
allowFrom: ctx.account.allowFrom || [],
policyPath: `simple.accounts.${ctx.accountId}.dmPolicy`,
allowFromPath: `simple.accounts.${ctx.accountId}.allowFrom`,
approveHint: "Use /approve to accept this pairing request",
}),
},
// Outbound adapter
outbound: {
deliveryMode: "direct",
textChunkLimit: 2000,
sendText: async ({ cfg, to, text, accountId }) => {
const account = cfg.simple?.accounts?.[accountId ?? "default"];
if (!account) throw new Error("Account not found");
// Your platform's send logic here
console.log(`[simple] Sending to ${to}: ${text}`);
return { ok: true, messageId: `msg-${Date.now()}` };
},
},
};
// Entry point
export default defineChannelPluginEntry({
id: "simple",
name: "Simple Channel",
description: "A minimal channel plugin example",
plugin: simplePlugin,
});
This minimal implementation demonstrates the core contract:
- Config discovery: List and resolve accounts
- Schema definition: Drive setup wizards
- Pairing support: Handle new connections
- Security enforcement: Apply DM policies
- Message delivery: Send text back to the platform
Chapter Exercises:
- Trace a message through the Telegram channel by setting
LOG_LEVEL=debugand sending a test message. Observe the envelope creation, route resolution, and reply dispatch. - Implement a mock channel using the minimal example above, adding support for a simple
sendPollmethod. - Configure a Telegram topic with
dmPolicy: "pair"and test the pairing flow from an unknown user.
Next: Channels extend OpenClaw's capabilities across platforms, but the plugin system goes even further. Let's explore the full plugin architecture in the next chapter.