Skip to content

06.2 Channel Implementations

Nikolay Vyahhi edited this page Feb 19, 2026 · 3 revisions

Channel Implementations

Relevant source files

The following files were used as context for generating this wiki page:

This page documents all 13+ channel implementations available in ZeroClaw. Each implementation provides a complete integration with a specific messaging platform or communication method, allowing agents to receive messages and send responses through diverse channels.

For architectural details about the Channel trait interface, message dispatching, and supervised listeners, see Channel Architecture. For security controls (allowlists, pairing, mention-only modes), see Channel Security.


Implementation Overview

ZeroClaw supports 13+ channel implementations spanning messaging platforms, email protocols, and local interfaces. The table below summarizes each implementation's transport mechanism, authentication method, and key features:

Channel Transport Authentication Key Features
Telegram Long-polling (getUpdates) Bot token + optional pairing Forum topics, attachments, streaming drafts, tool call stripping
Discord WebSocket Gateway v10 Bot token Heartbeat management, reconnect handling, guild filtering, 2000-char splits
Slack REST API polling (conversations.history) Bot token Simple polling, bot user ID detection
Mattermost REST API v4 polling Bearer token Thread support, typing indicators, mention-only mode
Email IMAP IDLE + SMTP Username/password Instant push, HTML stripping, domain allowlists, RFC 2177 compliant
Lark/Feishu WebSocket long-connection App ID/secret Protobuf framing, multi-part reassembly, dual region support
iMessage AppleScript + SQLite None (local) macOS-only, Full Disk Access required, injection-safe
DingTalk Stream Mode WebSocket Client ID/secret Session webhooks, gateway registration
WhatsApp Webhook (push-based) Access token + verify token Meta Business API, E.164 phone format
Matrix matrix-sdk sync Access token E2EE support, room alias resolution, SDK-managed session
QQ WebSocket App ID/token Event-driven, guild/channel filtering
Signal REST API Service URL Local Signal CLI bridge
CLI stdin/stdout None Zero-config, /quit command

Sources: src/channels/telegram.rs:1-1676, src/channels/discord.rs:1-687, src/channels/slack.rs:1-208, src/channels/mattermost.rs:1-672, src/channels/email_channel.rs:1-908, src/channels/lark.rs:1-842, src/channels/imessage.rs:1-433, src/channels/dingtalk.rs:1-330, src/channels/whatsapp.rs:1-385, src/channels/matrix.rs:1-705, src/channels/cli.rs:1-134


Architecture Diagram: Channel Transport Mechanisms

graph TB
    subgraph "Polling-Based Channels"
        Slack["SlackChannel<br/>conversations.history<br/>3s poll interval"]
        Mattermost["MattermostChannel<br/>GET /api/v4/channels/{id}/posts<br/>3s poll interval"]
        IMessage["IMessageChannel<br/>SQLite chat.db query<br/>3s poll interval"]
    end
    
    subgraph "WebSocket-Based Channels"
        Discord["DiscordChannel<br/>Gateway v10<br/>Heartbeat + sequence tracking"]
        Telegram["TelegramChannel<br/>getUpdates long-polling<br/>Timeout=30s"]
        Lark["LarkChannel<br/>pbbp2.proto frames<br/>Ping/pong heartbeat"]
        DingTalk["DingTalkChannel<br/>Stream Mode<br/>Gateway registration"]
    end
    
    subgraph "Push-Based Channels"
        Email["EmailChannel<br/>IMAP IDLE<br/>RFC 2177 instant push"]
        WhatsApp["WhatsAppChannel<br/>Meta webhook<br/>Verify token"]
    end
    
    subgraph "SDK-Managed Channels"
        Matrix["MatrixChannel<br/>matrix-sdk sync<br/>E2EE + session management"]
    end
    
    subgraph "Local Channels"
        CLI["CliChannel<br/>stdin/stdout<br/>Zero-config"]
    end
    
    Slack -->|"authorized_users allowlist"| Auth[Authorization Layer]
    Mattermost -->|"allowed_users allowlist"| Auth
    Discord -->|"allowed_users allowlist"| Auth
    Telegram -->|"PairingGuard or allowed_users"| Auth
    Email -->|"allowed_senders domain match"| Auth
    WhatsApp -->|"allowed_numbers E.164"| Auth
    
    Auth --> Dispatcher["Message Dispatcher<br/>process_channel_message"]
Loading

Sources: src/channels/slack.rs:93-181, src/channels/mattermost.rs:133-204, src/channels/discord.rs:229-426, src/channels/telegram.rs:741-829, src/channels/email_channel.rs:305-413, src/channels/whatsapp.rs:48-135


Polling-Based Channels

Slack Channel

Implementation: src/channels/slack.rs:1-208

Key Characteristics:

  • Uses conversations.history API with oldest cursor tracking
  • Polls every 3 seconds with limit=10
  • Bot user ID detection via auth.test to skip own messages
  • Returns ok: false errors require JSON parsing

Configuration:

[channels_config.slack]
bot_token = "xoxb-..."
channel_id = "C01234567"
allowed_users = ["U01234567", "U98765432"]  # Slack user IDs

Allowlist Logic:

  • Empty list → deny all
  • ["*"] → allow all
  • Exact user ID match (case-sensitive)

Code Structure:

Sources: src/channels/slack.rs:1-208


Mattermost Channel

Implementation: src/channels/mattermost.rs:1-672

Key Characteristics:

  • REST API v4 with since timestamp parameter
  • Thread support: root_id for existing threads, thread_replies config for top-level posts
  • Typing indicator: POST /api/v4/users/me/typing every 4 seconds
  • Mention-only mode: checks both text @username and metadata.mentions array

Configuration:

[channels_config.mattermost]
url = "https://mm.example.com"
bot_token = "your-token"
channel_id = "7j8k9l..."
allowed_users = ["user-id-1", "user-id-2"]
thread_replies = true
mention_only = false

Threading Behavior:

  • Existing thread (has root_id) → always reply in thread
  • Top-level post + thread_replies=true → thread on original post (channel_id:post_id)
  • Top-level post + thread_replies=false → reply at channel root

Mention-Only Normalization:

flowchart LR
    Input["Incoming message:<br/>@bot_username run status"] --> Check{Contains mention?}
    Check -->|Text-based| TextSpan["find_bot_mention_spans()<br/>Locate @username"]
    Check -->|Metadata-based| MetaCheck["metadata.mentions array<br/>Contains bot_user_id"]
    TextSpan --> Strip["Strip @bot_username<br/>Result: 'run status'"]
    MetaCheck --> Strip
    Check -->|No mention| Reject[Return None]
    Strip --> Output["Cleaned content"]
Loading

Code Structure:

Sources: src/channels/mattermost.rs:1-672, docs/mattermost-setup.md:1-64


WebSocket-Based Channels

Discord Channel

Implementation: src/channels/discord.rs:1-687

Key Characteristics:

  • Gateway API v10 with WebSocket connection
  • Heartbeat (op 1) with sequence number tracking
  • Reconnect on op 7 (Reconnect) or op 9 (Invalid Session)
  • Intents: 37377 (GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT | DIRECT_MESSAGES)
  • Bot user ID extraction: base64 decode first part of token (bot_id.timestamp.hmac)

Connection Flow:

sequenceDiagram
    participant Client as DiscordChannel
    participant Gateway as Discord Gateway
    
    Client->>Gateway: Connect wss://gateway.discord.gg/?v=10
    Gateway->>Client: Op 10 Hello {heartbeat_interval}
    Client->>Gateway: Op 2 Identify {token, intents}
    Gateway->>Client: Op 0 READY
    
    loop Heartbeat Loop
        Client->>Gateway: Op 1 Heartbeat {sequence}
        Gateway->>Client: Op 11 Heartbeat ACK
    end
    
    Gateway->>Client: Op 0 MESSAGE_CREATE
    Client->>Client: Filter: skip bot's own messages
    Client->>Client: Filter: check allowed_users
    Client->>Client: Mention-only: normalize_incoming_content()
    Client->>Client: Send to dispatcher
    
    alt Server Requests Reconnect
        Gateway->>Client: Op 7 Reconnect
        Client->>Client: Close connection & restart
    end
    
    alt Invalid Session
        Gateway->>Client: Op 9 Invalid Session
        Client->>Client: Close connection & restart
    end
Loading

Message Splitting:

  • Max length: 2000 characters (Unicode-aware via char_indices())
  • Prefers newline breaks, falls back to space breaks
  • Hard split at limit if no break points

Mention Normalization:

  • Plain form: <@12345>
  • Nickname form: <@!12345>
  • Both stripped when mention_only=true

Code Structure:

Sources: src/channels/discord.rs:1-687


Telegram Channel

Implementation: src/channels/telegram.rs:1-1676

Key Characteristics:

  • Long-polling via getUpdates with timeout=30 seconds
  • Max message length: 4096 characters
  • Pairing support: 6-digit one-time code with PairingGuard
  • Forum topic support: message_thread_idchat_id:thread_id format
  • Attachment markers: [IMAGE:path], [DOCUMENT:url], etc.
  • Tool call tag stripping: removes <tool_call>, <invoke>, etc. before sending
  • Streaming: progressive draft updates via editMessageText

Pairing Flow:

sequenceDiagram
    participant User as Telegram User
    participant Bot as TelegramChannel
    participant Guard as PairingGuard
    participant Config as config.toml
    
    Bot->>Guard: new(true, allowed_users)
    Guard->>Guard: Generate 6-digit code
    Bot->>User: Print code to console
    
    User->>Bot: /bind 123456
    Bot->>Bot: extract_bind_code()
    Bot->>Guard: try_pair(code)
    
    alt Valid Code
        Guard->>Bot: Ok(Some(token))
        Bot->>Bot: add_allowed_identity_runtime()
        Bot->>Config: persist_allowed_identity()
        Bot->>User: ✅ Bound successfully
    else Invalid Code
        Guard->>Bot: Ok(None)
        Bot->>User: ❌ Invalid code
    else Rate Limited
        Guard->>Bot: Err(lockout_secs)
        Bot->>User: ⏳ Too many attempts
    end
Loading

Forum Topic Support:

  • Incoming: message_thread_id parsed into chat_id:thread_id
  • Outgoing: parse_reply_target() splits chat_id:thread_id
  • API: include message_thread_id in request body

Attachment Parsing:

  1. Check for marker syntax: [TYPE:target] (e.g., [IMAGE:/path/to/file.png])
  2. If no marker, try path-only inference: single-line, no whitespace, file exists
  3. Infer kind from extension: .png → Image, .mp4 → Video, etc.
  4. Send via URL or file upload

Tool Call Tag Stripping:

  • Strips <tool_call>, <toolcall>, <tool-call>, <tool>, <invoke> and matching close tags
  • Extracts JSON payload via serde_json::Deserializer
  • Prevents Telegram Markdown parser errors (400 status)

Code Structure:

Sources: src/channels/telegram.rs:1-1676


Lark/Feishu Channel

Implementation: src/channels/lark.rs:1-842

Key Characteristics:

  • WebSocket long-connection with protobuf framing (pbbp2.proto)
  • Dual region support: Feishu (CN) vs Lark (international) endpoints
  • Multi-part message reassembly with fragment cache
  • Deduplication cache (30-minute TTL)
  • Ping/pong heartbeat (default 120s, server-configurable)

Protobuf Frame Structure:

PbFrame {
    seq_id: u64,          // Sequence number
    log_id: u64,          // Trace ID
    service: i32,         // Service ID from wss URL
    method: i32,          // 0=CONTROL (ping/pong), 1=DATA (events)
    headers: Vec<PbHeader>,  // type, message_id, fragment info
    payload: Option<Vec<u8>>  // JSON event or config
}

Connection Lifecycle:

sequenceDiagram
    participant Client as LarkChannel
    participant Gateway as Gateway Endpoint
    participant WSS as WebSocket Server
    
    Client->>Gateway: POST /callback/ws/endpoint<br/>{AppID, AppSecret}
    Gateway->>Client: {URL, ticket, ClientConfig}
    Client->>WSS: Connect wss://...?ticket=...
    
    Client->>WSS: Ping (seq=1, method=0)
    WSS->>Client: Pong {PingInterval: 120}
    Client->>Client: Update heartbeat interval
    
    loop Every 120s
        Client->>WSS: Ping (method=0)
        WSS->>Client: Pong (optional config update)
    end
    
    loop On Message Event
        WSS->>Client: Data frame (method=1)
        Client->>Client: Parse event payload
        
        alt Multi-part message
            Client->>Client: Store fragment in cache
            Client->>Client: Check if complete
            alt All fragments received
                Client->>Client: Reassemble & dispatch
            end
        else Single-part message
            Client->>Client: Check dedup cache
            Client->>Client: Dispatch immediately
        end
    end
    
    alt Heartbeat Timeout (5 minutes)
        Client->>Client: Reconnect
    end
Loading

Multi-Part Reassembly:

  • Headers: sum (total parts), seq (part index, 0-based), message_id (grouping key)
  • Cache: HashMap<message_id, (Vec<Option<payload>>, timestamp)>
  • GC: Remove fragments older than 5 minutes
  • Completion: All slots filled → reconstruct JSON → dispatch

Deduplication:

  • Cache: HashMap<message_id, Instant>
  • GC: Remove entries older than 30 minutes (every ping interval)
  • Prevents double-dispatch when server retransmits

Code Structure:

Sources: src/channels/lark.rs:1-842


DingTalk Channel

Implementation: src/channels/dingtalk.rs:1-330

Key Characteristics:

  • Stream Mode WebSocket with gateway registration
  • Session webhooks for replies (unique URL per conversation)
  • Private vs group chat ID resolution
  • System ping/pong to keep connection alive

Gateway Registration:

sequenceDiagram
    participant Client as DingTalkChannel
    participant Gateway as api.dingtalk.com
    participant WSS as WebSocket Stream
    
    Client->>Gateway: POST /v1.0/gateway/connections/open<br/>{clientId, clientSecret, subscriptions}
    Gateway->>Client: {endpoint, ticket}
    Client->>WSS: Connect wss://...?ticket=...
    
    loop Heartbeat
        WSS->>Client: SYSTEM frame {messageId}
        Client->>WSS: Pong {code: 200, messageId}
    end
    
    WSS->>Client: EVENT/CALLBACK frame
    Client->>Client: parse_stream_data()
    Client->>Client: Extract sessionWebhook
    Client->>Client: Store webhook for chat_id
    Client->>Client: Check allowed_users
    Client->>Client: Dispatch message
    
    Note over Client: Later, when replying...
    Client->>Client: Lookup sessionWebhook by chat_id
    Client->>WSS: POST {webhook} {msgtype: markdown}
Loading

Chat ID Resolution:

  • conversationType == "1" → private chat, use senderStaffId
  • conversationType != "1" → group chat, use conversationId

Session Webhook Storage:

  • Key: chat_id (private: sender ID, group: conversation ID)
  • Value: sessionWebhook URL from incoming message
  • Required for all replies (DingTalk doesn't provide a global send API)

Code Structure:

Sources: src/channels/dingtalk.rs:1-330


Webhook-Based Channels

WhatsApp Channel

Implementation: src/channels/whatsapp.rs:1-385

Key Characteristics:

  • Push-based: Meta sends webhooks, no active polling
  • listen() is a no-op placeholder (keeps task alive)
  • Verify token for webhook challenge/response
  • E.164 phone number format (+1234567890)
  • Messages received via gateway's /whatsapp endpoint

Webhook Verification Flow:

sequenceDiagram
    participant Meta as Meta Webhook Server
    participant Gateway as ZeroClaw Gateway
    participant Channel as WhatsAppChannel
    
    Note over Meta,Gateway: Initial Setup
    Meta->>Gateway: GET /whatsapp?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...
    Gateway->>Channel: verify_token()
    Channel->>Gateway: Return verify_token
    Gateway->>Gateway: Compare tokens
    Gateway->>Meta: 200 {hub.challenge}
    
    Note over Meta,Gateway: Message Reception
    Meta->>Gateway: POST /whatsapp {entry: [...]}
    Gateway->>Channel: parse_webhook_payload(body)
    Channel->>Channel: Extract messages array
    Channel->>Channel: Check allowed_numbers
    Channel->>Channel: Normalize phone (+prefix)
    Channel->>Gateway: Vec<ChannelMessage>
    Gateway->>Gateway: Dispatch to agent
Loading

Payload Structure:

{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "changes": [
        {
          "value": {
            "messages": [
              {
                "from": "1234567890",
                "text": {"body": "Hello"},
                "timestamp": "1609459200"
              }
            ]
          }
        }
      ]
    }
  ]
}

Phone Number Normalization:

  • Incoming: add + prefix if missing
  • Outgoing: strip + prefix for API
  • Allowlist: exact match with + prefix

Code Structure:

Sources: src/channels/whatsapp.rs:1-385


Protocol-Specific Channels

Email Channel

Implementation: src/channels/email_channel.rs:1-908

Key Characteristics:

  • IMAP IDLE for instant push notifications (RFC 2177 compliant)
  • SMTP with TLS for sending
  • 29-minute IDLE timeout (per RFC recommendation)
  • HTML stripping for plain text extraction
  • Domain-based allowlists (@example.com, example.com, user@example.com)

IMAP IDLE Flow:

sequenceDiagram
    participant Client as EmailChannel
    participant IMAP as IMAP Server
    participant SMTP as SMTP Server
    
    Client->>IMAP: Connect + TLS handshake
    Client->>IMAP: LOGIN {username, password}
    Client->>IMAP: SELECT INBOX
    Client->>IMAP: SEARCH UNSEEN
    IMAP->>Client: UIDs [1, 2, 3]
    Client->>IMAP: FETCH RFC822
    Client->>Client: Parse email, extract sender, body
    Client->>Client: Check allowed_senders
    Client->>IMAP: STORE +FLAGS (\\Seen)
    
    loop IDLE Loop
        Client->>IMAP: IDLE
        IMAP->>Client: + idling
        
        alt New Mail
            IMAP->>Client: * EXISTS
            Client->>IMAP: DONE
            Client->>IMAP: SEARCH UNSEEN
            Client->>Client: Process new messages
        else Timeout (29 min)
            Client->>IMAP: DONE
            Client->>Client: Re-enter IDLE
        end
    end
    
    Note over Client,SMTP: Sending Reply
    Client->>SMTP: Connect + TLS
    Client->>SMTP: AUTH {username, password}
    Client->>SMTP: MAIL FROM, RCPT TO, DATA
    Client->>SMTP: {subject, body}
Loading

Allowlist Matching:

  1. Empty list → deny all
  2. "*" → allow all
  3. "@example.com" → domain suffix match
  4. "example.com" → domain suffix match (adds @ prefix)
  5. "user@example.com" → exact email match (case-insensitive)

HTML Stripping:

  • Remove everything between < and >
  • Collapse multiple spaces
  • Fallback hierarchy: body_text(0)body_html(0) → attachments with text/* MIME type

Connection Resilience:

  • Auto-reconnect on error with exponential backoff (1s → 2s → 4s → ... → 60s max)
  • Defensive re-check after IDLE timeout (prevents missed messages)
  • Clean logout on shutdown

Code Structure:

Sources: src/channels/email_channel.rs:1-908


Matrix Channel

Implementation: src/channels/matrix.rs:1-705

Key Characteristics:

  • Uses matrix-sdk for sync and E2EE decryption
  • Session restoration with user_id and device_id hints
  • Room alias resolution (#room:server!id:server)
  • Event deduplication via LRU cache
  • Supports encrypted rooms transparently

Session Restoration:

flowchart TD
    Start[MatrixChannel::new_with_session_hint] --> BuildClient[Client::builder]
    BuildClient --> HasHints{Has user_id<br/>and device_id?}
    
    HasHints -->|Yes| RestoreSession["Build MatrixSession<br/>{user_id, device_id,<br/>access_token, homeserver}"]
    HasHints -->|No| Whoami["GET /_matrix/client/r0/account/whoami"]
    
    Whoami --> ParseWhoami["Extract user_id<br/>and device_id"]
    ParseWhoami --> RestoreSession
    
    RestoreSession --> SetSession["client.restore_session(session)"]
    SetSession --> Sync["client.sync_once()<br/>Validate session"]
    
    Sync --> Success{Success?}
    Success -->|Yes| Listen["listen() loop<br/>sync_stream()"]
    Success -->|No| Error[Session invalid]
Loading

Room ID Resolution:

  1. Check cache: resolved_room_id_cache
  2. If starts with ! → already resolved
  3. If starts with # → call GET /_matrix/client/r0/directory/room/{alias}
  4. Cache result for future lookups

Event Deduplication:

  • LRU cache: 500 most recent event_ids
  • Prevents double-dispatch when server resends events
  • Evicts oldest on overflow

Message Filtering:

flowchart LR
    Event["Sync event"] --> TypeCheck{Is m.room.message?}
    TypeCheck -->|No| Skip1[Skip]
    TypeCheck -->|Yes| MsgtypeCheck{Is m.text or m.notice?}
    MsgtypeCheck -->|No| Skip2[Skip]
    MsgtypeCheck -->|Yes| BodyCheck{Has non-empty body?}
    BodyCheck -->|No| Skip3[Skip]
    BodyCheck -->|Yes| DedupCheck{Seen event_id?}
    DedupCheck -->|Yes| Skip4[Skip]
    DedupCheck -->|No| AllowCheck{Sender in allowed_users?}
    AllowCheck -->|No| Skip5[Skip]
    AllowCheck -->|Yes| Dispatch[Dispatch to agent]
Loading

Code Structure:

Sources: src/channels/matrix.rs:1-705


Platform-Specific Channels

iMessage Channel

Implementation: src/channels/imessage.rs:1-433

Key Characteristics:

  • macOS-only (requires AppleScript)
  • Polls SQLite chat.db at ~/Library/Messages/chat.db
  • Requires Full Disk Access permission
  • Injection-safe: escapes AppleScript and validates targets
  • Persistent read-only connection for polling

AppleScript Security:

flowchart TD
    SendRequest["send() called<br/>recipient, content"] --> ValidateTarget{is_valid_imessage_target?}
    
    ValidateTarget -->|Phone| ValidatePhone["Starts with +<br/>7-15 digits"]
    ValidateTarget -->|Email| ValidateEmail["Contains @<br/>Valid local + domain"]
    ValidateTarget -->|Invalid| Reject[Reject with error]
    
    ValidatePhone --> Escape["escape_applescript()<br/>Escape backslash, quotes, newlines"]
    ValidateEmail --> Escape
    
    Escape --> BuildScript["Build AppleScript:<br/>tell application 'Messages'<br/>  set targetBuddy to participant<br/>  send message"]
    
    BuildScript --> Execute["osascript -e '{script}'"]
    
    Execute --> Check{Success?}
    Check -->|Yes| Done[Return Ok]
    Check -->|No| Error[Return Err with stderr]
Loading

AppleScript Escaping:

  • \\\
  • "\"
  • \n\\n
  • \r\\r

Target Validation:

  • Phone: Must start with +, contain 7-15 digits (with optional spaces/dashes)
  • Email: Must have @ with valid local part and domain (contains .)

SQLite Polling:

SELECT m.ROWID, h.id, m.text
FROM message m
JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?1
  AND m.is_from_me = 0
  AND m.text IS NOT NULL
ORDER BY m.ROWID ASC
LIMIT 20

Persistent Connection:

  • Open once with SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX
  • Reuse across poll cycles (shuttle between blocking tasks)
  • Track last_rowid to avoid re-processing

Code Structure:

Sources: src/channels/imessage.rs:1-433


Local Channels

CLI Channel

Implementation: src/channels/cli.rs:1-134

Key Characteristics:

  • Zero configuration required
  • stdin/stdout for input/output
  • Special commands: /quit, /exit to stop listening
  • UUID-based message IDs
  • Trims empty lines

Implementation:

async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
    let stdin = io::stdin();
    let reader = BufReader::new(stdin);
    let mut lines = reader.lines();

    while let Ok(Some(line)) = lines.next_line().await {
        let line = line.trim().to_string();
        if line.is_empty() { continue; }
        if line == "/quit" || line == "/exit" { break; }

        let msg = ChannelMessage {
            id: Uuid::new_v4().to_string(),
            sender: "user".to_string(),
            reply_target: "user".to_string(),
            content: line,
            channel: "cli".to_string(),
            timestamp: SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
        };

        if tx.send(msg).await.is_err() { break; }
    }
    Ok(())
}

Sources: src/channels/cli.rs:1-134


Common Implementation Patterns

Allowlist-Based Access Control

All channels implement sender validation with consistent patterns:

flowchart LR
    Message["Incoming message"] --> ExtractSender["Extract sender ID"]
    ExtractSender --> CheckEmpty{allowed_users.is_empty?}
    CheckEmpty -->|Yes| Deny1[Deny: Empty list]
    CheckEmpty -->|No| CheckWildcard{Contains '*'?}
    CheckWildcard -->|Yes| Allow[Allow: Wildcard]
    CheckWildcard -->|No| CheckExact{Exact match?}
    CheckExact -->|Yes| Allow
    CheckExact -->|No| Deny2[Deny: Not in list]
Loading

Channel-Specific Variations:

Channel Identifier Format Special Rules
Telegram Username or numeric user ID Pairing bypass, normalization strips @
Discord Numeric user ID Case-sensitive
Slack User ID (e.g., U01234567) Case-sensitive
Mattermost User ID Case-sensitive
Email Email address Domain matching, case-insensitive
WhatsApp E.164 phone number Must include + prefix
Matrix Matrix ID (e.g., @user:server) Case-insensitive
Lark open_id Case-sensitive
DingTalk senderStaffId Case-sensitive

Sources: src/channels/telegram.rs:572-578, src/channels/discord.rs:44-46, src/channels/email_channel.rs:122-142, src/channels/whatsapp.rs:38-40


Message Normalization and Mention Handling

Channels that support group/channel conversations implement mention-only mode:

Telegram Mention Normalization:

fn normalize_incoming_content(text: &str, bot_username: &str) -> Option<String> {
    let spans = find_bot_mention_spans(text, bot_username);
    if spans.is_empty() {
        // No mention found
        return Some(text.trim().to_string());
    }

    // Strip all @bot mentions
    let mut normalized = String::with_capacity(text.len());
    let mut cursor = 0;
    for (start, end) in spans {
        normalized.push_str(&text[cursor..start]);
        cursor = end;
    }
    normalized.push_str(&text[cursor..]);
    
    let normalized = normalized.trim().to_string();
    if normalized.is_empty() { None } else { Some(normalized) }
}

Mattermost Mention Detection:

  • Text-based: find_bot_mention_spans() for @username
  • Metadata-based: post.metadata.mentions array check
  • Returns None if no mention found (filtered out)

Discord Mention Stripping:

  • Plain: <@12345>
  • Nickname: <@!12345>
  • Both replaced with space

Sources: src/channels/telegram.rs:544-561, src/channels/mattermost.rs:411-448, src/channels/discord.rs:119-145


Typing Indicators

Channels that support typing indicators implement a repeating loop:

sequenceDiagram
    participant Agent as Agent Loop
    participant Channel as Channel Implementation
    participant API as Platform API
    
    Agent->>Channel: start_typing(recipient)
    Channel->>Channel: Spawn background task
    
    loop Every N seconds
        Channel->>API: POST typing endpoint
        API->>Channel: 200 OK
    end
    
    Note over Agent: Processing complete
    Agent->>Channel: stop_typing(recipient)
    Channel->>Channel: Abort background task
Loading

Platform-Specific Intervals:

  • Telegram: sendChatAction every 5 seconds (expires after ~6s)
  • Discord: POST /channels/{id}/typing every 8 seconds (expires after ~10s)
  • Mattermost: POST /users/me/typing every 4 seconds (expires after ~6s)

Sources: src/channels/telegram.rs:1606-1649, src/channels/discord.rs:438-469, src/channels/mattermost.rs:216-269


Streaming Support (Progressive Drafts)

Telegram implements progressive draft updates for streaming responses:

sequenceDiagram
    participant Agent as Agent Turn Loop
    participant Channel as TelegramChannel
    participant API as Telegram API
    
    Agent->>Channel: start_typing(recipient)
    Agent->>Channel: send_draft(recipient, "Initial text")
    Channel->>API: sendMessage
    API->>Channel: {message_id}
    Channel->>Agent: Some(message_id)
    
    loop Stream Chunks
        Agent->>Agent: Accumulate text
        Agent->>Channel: update_draft(recipient, msg_id, accumulated)
        
        Note over Channel: Rate limit check (min 1s interval)
        alt Interval elapsed
            Channel->>API: editMessageText {message_id, text}
            API->>Channel: 200 OK
        else Too soon
            Channel->>Channel: Skip update
        end
    end
    
    Agent->>Channel: finalize_draft(recipient, msg_id, final_text)
    Channel->>API: editMessageText {message_id, text, parse_mode: Markdown}
    API->>Channel: 200 OK
    Agent->>Channel: stop_typing(recipient)
Loading

Rate Limiting:

  • Track last edit time per message ID
  • Enforce minimum interval (default 1000ms)
  • Skip updates if called too frequently

Fallback Behavior:

  • If editMessageText fails, send as new message
  • Prevents user-visible errors

Sources: src/channels/telegram.rs:1471-1592


Scheduler Integration

Cron jobs can deliver output to any configured channel via the DeliveryConfig:

pub struct DeliveryConfig {
    pub mode: String,        // "none" or "announce"
    pub channel: Option<String>,  // "telegram", "discord", "slack", "mattermost"
    pub to: Option<String>,       // Recipient ID (chat_id, channel_id, etc.)
    pub best_effort: bool,   // If true, log errors but don't fail job
}

Supported Channels:

  • Telegram: to = chat_id or chat_id:thread_id
  • Discord: to = channel_id
  • Slack: to = channel_id
  • Mattermost: to = channel_id or channel_id:root_id

Example:

async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> Result<()> {
    if !job.delivery.mode.eq_ignore_ascii_case("announce") {
        return Ok(());
    }

    let channel_name = job.delivery.channel.as_deref().ok_or(...)?;
    let target = job.delivery.to.as_deref().ok_or(...)?;

    match channel_name.to_ascii_lowercase().as_str() {
        "telegram" => {
            let tg = config.channels_config.telegram.as_ref()?;
            let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone(), tg.mention_only);
            channel.send(&SendMessage::new(output, target)).await?;
        }
        // ... other channels
    }
    Ok(())
}

Sources: src/cron/scheduler.rs:240-317


Clone this wiki locally