-
Notifications
You must be signed in to change notification settings - Fork 4.4k
06.2 Channel Implementations
Relevant source files
The following files were used as context for generating this wiki page:
- docs/mattermost-setup.md
- src/agent/tests.rs
- src/channels/cli.rs
- src/channels/dingtalk.rs
- src/channels/discord.rs
- src/channels/email_channel.rs
- src/channels/imessage.rs
- src/channels/lark.rs
- src/channels/matrix.rs
- src/channels/mattermost.rs
- src/channels/slack.rs
- src/channels/telegram.rs
- src/channels/traits.rs
- src/channels/whatsapp.rs
- src/cron/scheduler.rs
- src/tools/git_operations.rs
- tests/agent_e2e.rs
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.
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 |
| 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 |
| 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 |
| 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
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"]
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
Implementation: src/channels/slack.rs:1-208
Key Characteristics:
- Uses
conversations.historyAPI witholdestcursor tracking - Polls every 3 seconds with
limit=10 - Bot user ID detection via
auth.testto skip own messages - Returns
ok: falseerrors require JSON parsing
Configuration:
[channels_config.slack]
bot_token = "xoxb-..."
channel_id = "C01234567"
allowed_users = ["U01234567", "U98765432"] # Slack user IDsAllowlist Logic:
- Empty list → deny all
-
["*"]→ allow all - Exact user ID match (case-sensitive)
Code Structure:
-
is_user_allowed()→ src/channels/slack.rs:27-29 -
get_bot_user_id()→ src/channels/slack.rs:32-47 -
listen()polling loop → src/channels/slack.rs:93-181
Sources: src/channels/slack.rs:1-208
Implementation: src/channels/mattermost.rs:1-672
Key Characteristics:
- REST API v4 with
sincetimestamp parameter - Thread support:
root_idfor existing threads,thread_repliesconfig for top-level posts - Typing indicator: POST
/api/v4/users/me/typingevery 4 seconds - Mention-only mode: checks both text
@usernameandmetadata.mentionsarray
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 = falseThreading 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"]
Code Structure:
-
parse_mattermost_post()→ src/channels/mattermost.rs:273-326 -
find_bot_mention_spans()→ src/channels/mattermost.rs:364-405 -
normalize_mattermost_content()→ src/channels/mattermost.rs:411-448 - Thread reply target → src/channels/mattermost.rs:308-314
Sources: src/channels/mattermost.rs:1-672, docs/mattermost-setup.md:1-64
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
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:
-
listen()WebSocket loop → src/channels/discord.rs:229-426 -
split_message_for_discord()→ src/channels/discord.rs:64-108 -
normalize_incoming_content()→ src/channels/discord.rs:119-145 - Bot user ID extraction → src/channels/discord.rs:48-52
Sources: src/channels/discord.rs:1-687
Implementation: src/channels/telegram.rs:1-1676
Key Characteristics:
- Long-polling via
getUpdateswithtimeout=30seconds - Max message length: 4096 characters
- Pairing support: 6-digit one-time code with
PairingGuard - Forum topic support:
message_thread_id→chat_id:thread_idformat - 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
Forum Topic Support:
- Incoming:
message_thread_idparsed intochat_id:thread_id - Outgoing:
parse_reply_target()splitschat_id:thread_id - API: include
message_thread_idin request body
Attachment Parsing:
- Check for marker syntax:
[TYPE:target](e.g.,[IMAGE:/path/to/file.png]) - If no marker, try path-only inference: single-line, no whitespace, file exists
- Infer kind from extension:
.png→ Image,.mp4→ Video, etc. - 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:
-
listen()getUpdates loop → src/channels/telegram.rs:1266-1360 -
parse_update_message()→ src/channels/telegram.rs:741-829 - Pairing:
handle_unauthorized_message()→ src/channels/telegram.rs:587-739 - Tool call stripping:
strip_tool_call_tags()→ src/channels/telegram.rs:146-250 - Attachment parsing:
parse_attachment_markers()→ src/channels/telegram.rs:252-296 - Message splitting:
split_message_for_telegram()→ src/channels/telegram.rs:20-60 - Streaming:
send_draft()/update_draft()→ src/channels/telegram.rs:1471-1592
Sources: src/channels/telegram.rs:1-1676
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
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:
-
listen_ws()WebSocket loop → src/channels/lark.rs:244-427 -
get_ws_endpoint()→ src/channels/lark.rs:215-239 - Protobuf definitions → src/channels/lark.rs:21-56
- Fragment reassembly → src/channels/lark.rs:353-397
- Deduplication → src/channels/lark.rs:404-418
Sources: src/channels/lark.rs:1-842
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}
Chat ID Resolution:
-
conversationType == "1"→ private chat, usesenderStaffId -
conversationType != "1"→ group chat, useconversationId
Session Webhook Storage:
- Key:
chat_id(private: sender ID, group: conversation ID) - Value:
sessionWebhookURL from incoming message - Required for all replies (DingTalk doesn't provide a global send API)
Code Structure:
-
register_connection()→ src/channels/dingtalk.rs:78-105 -
listen()WebSocket loop → src/channels/dingtalk.rs:149-248 -
resolve_chat_id()→ src/channels/dingtalk.rs:56-75 -
send()via webhook → src/channels/dingtalk.rs:114-146
Sources: src/channels/dingtalk.rs:1-330
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
/whatsappendpoint
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
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:
-
parse_webhook_payload()→ src/channels/whatsapp.rs:48-135 -
send()→ src/channels/whatsapp.rs:144-185 -
listen()no-op → src/channels/whatsapp.rs:187-200 -
verify_token()getter → src/channels/whatsapp.rs:43-45
Sources: src/channels/whatsapp.rs:1-385
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}
Allowlist Matching:
- Empty list → deny all
-
"*"→ allow all -
"@example.com"→ domain suffix match -
"example.com"→ domain suffix match (adds@prefix) -
"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 withtext/*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:
-
listen_with_idle()main loop → src/channels/email_channel.rs:352-373 -
wait_for_changes()IDLE handler → src/channels/email_channel.rs:307-349 -
fetch_unseen()→ src/channels/email_channel.rs:224-303 -
is_sender_allowed()→ src/channels/email_channel.rs:122-142 -
strip_html()→ src/channels/email_channel.rs:145-157 -
create_smtp_transport()→ src/channels/email_channel.rs:456-470
Sources: src/channels/email_channel.rs:1-908
Implementation: src/channels/matrix.rs:1-705
Key Characteristics:
- Uses
matrix-sdkfor sync and E2EE decryption - Session restoration with
user_idanddevice_idhints - 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]
Room ID Resolution:
- Check cache:
resolved_room_id_cache - If starts with
!→ already resolved - If starts with
#→ callGET /_matrix/client/r0/directory/room/{alias} - 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]
Code Structure:
-
new_with_session_hint()→ src/channels/matrix.rs:106-134 -
setup_sdk_client()→ src/channels/matrix.rs:240-297 -
resolve_room_id()→ src/channels/matrix.rs:299-349 -
listen()sync loop → src/channels/matrix.rs:414-517 -
cache_event_id()→ src/channels/matrix.rs:182-199
Sources: src/channels/matrix.rs:1-705
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]
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 20Persistent Connection:
- Open once with
SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX - Reuse across poll cycles (shuttle between blocking tasks)
- Track
last_rowidto avoid re-processing
Code Structure:
-
send()with validation → src/channels/imessage.rs:98-130 -
escape_applescript()→ src/channels/imessage.rs:40-45 -
is_valid_imessage_target()→ src/channels/imessage.rs:55-90 -
listen()polling loop → src/channels/imessage.rs:133-246 -
get_max_rowid()→ src/channels/imessage.rs:263-276
Sources: src/channels/imessage.rs:1-433
Implementation: src/channels/cli.rs:1-134
Key Characteristics:
- Zero configuration required
- stdin/stdout for input/output
- Special commands:
/quit,/exitto 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
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]
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 address | Domain matching, case-insensitive | |
| 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
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.mentionsarray check - Returns
Noneif 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
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
Platform-Specific Intervals:
-
Telegram:
sendChatActionevery 5 seconds (expires after ~6s) -
Discord: POST
/channels/{id}/typingevery 8 seconds (expires after ~10s) -
Mattermost: POST
/users/me/typingevery 4 seconds (expires after ~6s)
Sources: src/channels/telegram.rs:1606-1649, src/channels/discord.rs:438-469, src/channels/mattermost.rs:216-269
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)
Rate Limiting:
- Track last edit time per message ID
- Enforce minimum interval (default 1000ms)
- Skip updates if called too frequently
Fallback Behavior:
- If
editMessageTextfails, send as new message - Prevents user-visible errors
Sources: src/channels/telegram.rs:1471-1592
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_idorchat_id:thread_id -
Discord:
to = channel_id -
Slack:
to = channel_id -
Mattermost:
to = channel_idorchannel_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