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. The runtime field 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 enablement
  • ChannelGroupAdapter: Resolve mention requirements, tool policies for groups
  • ChannelSecurityAdapter: Evaluate DM policy, collect warnings
  • ChannelDirectoryAdapter: Self discovery, peer/group listing
  • ChannelResolverAdapter: 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:

  1. DM Policy: Controlled by dmPolicy config ("ask", "pair", "all")
  2. Group Policy: Controlled by groupPolicy config ("sender", "route", "all")
  3. Allowlists: allowFrom for DMs, groupAllowFrom for groups
  4. 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_message fields 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:

  1. Explicit bindings: User-configured agent assignments
  2. Agent per topic: Forum topics can have dedicated agents
  3. Default fallback: The main agent when no binding exists
  4. 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:

  1. Mention stripping: Bot mentions removed in group chats
  2. Reply context: Original message content when replying
  3. Thread context: Parent messages in threaded conversations
  4. Quoted content: Forwarded message content when relevant
  5. Control commands: Commands like /agents filtered 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:

  1. Tool events: Real-time updates when tools execute
  2. Block streaming: Incremental text generation during inference
  3. 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:

PlatformText LimitFormattingMediaThreads
Telegram4096 charsMarkdown V2YesForum topics
Discord2000 charsMarkdownYesThreaded channels
Slack40000 charsMarkdown-ishYesParent/child messages
WhatsApp4096 charsNoneYesNone
Signal2000 charsNoneLimitedNone

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:

  1. Unknown sender: User not in allowFrom sends a message
  2. Pairing request: OpenClaw creates a pending approval
  3. Notification: Sent through configured channel
  4. User approval: /approve command or native approval button
  5. 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 to allowFrom
  • "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:

  1. Sender policy: Check if sender is in groupAllowFrom
  2. Route policy: Check if group route is configured
  3. Mention requirement: Some platforms require bot mentions
  4. 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:

  1. Credential boundary: Platform tokens/keys stored securely
  2. Network boundary: Webhooks verified, rate limits enforced
  3. 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:

  1. Config discovery: List and resolve accounts
  2. Schema definition: Drive setup wizards
  3. Pairing support: Handle new connections
  4. Security enforcement: Apply DM policies
  5. Message delivery: Send text back to the platform

Chapter Exercises:

  1. Trace a message through the Telegram channel by setting LOG_LEVEL=debug and sending a test message. Observe the envelope creation, route resolution, and reply dispatch.
  2. Implement a mock channel using the minimal example above, adding support for a simple sendPoll method.
  3. 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.