mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository.
441 lines
12 KiB
Markdown
441 lines
12 KiB
Markdown
# Building WASM Channels
|
|
|
|
This guide covers how to build WASM channel modules for IronClaw.
|
|
|
|
## Overview
|
|
|
|
Channels are WASM components that handle communication with external messaging platforms (Telegram, WhatsApp, Slack, etc.). They run in a sandboxed environment and communicate with the host via the WIT (WebAssembly Interface Types) interface.
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
channels/ # Or channels-src/
|
|
└── my-channel/
|
|
├── Cargo.toml
|
|
├── src/
|
|
│ └── lib.rs
|
|
└── my-channel.capabilities.json
|
|
```
|
|
|
|
After building, deploy to:
|
|
```
|
|
~/.ironclaw/channels/
|
|
├── my-channel.wasm
|
|
└── my-channel.capabilities.json
|
|
```
|
|
|
|
## Cargo.toml Template
|
|
|
|
```toml
|
|
[package]
|
|
name = "my-channel"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
description = "My messaging platform channel for IronClaw"
|
|
|
|
[lib]
|
|
crate-type = ["cdylib"]
|
|
|
|
[dependencies]
|
|
wit-bindgen = "0.36"
|
|
serde = { version = "1", features = ["derive"] }
|
|
serde_json = "1"
|
|
|
|
[profile.release]
|
|
opt-level = "s"
|
|
lto = true
|
|
strip = true
|
|
codegen-units = 1
|
|
```
|
|
|
|
## Channel Implementation
|
|
|
|
### Required Imports
|
|
|
|
```rust
|
|
// Generate bindings from the WIT file
|
|
wit_bindgen::generate!({
|
|
world: "sandboxed-channel",
|
|
path: "../../wit/channel.wit", // Adjust path as needed
|
|
});
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// Re-export generated types
|
|
use exports::near::agent::channel::{
|
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
|
OutgoingHttpResponse, PollConfig,
|
|
};
|
|
use near::agent::channel_host::{self, EmittedMessage};
|
|
```
|
|
|
|
### Implementing the Guest Trait
|
|
|
|
```rust
|
|
struct MyChannel;
|
|
|
|
impl Guest for MyChannel {
|
|
/// Called once when the channel starts.
|
|
/// Returns configuration for webhooks and polling.
|
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
// Parse config from capabilities file
|
|
let config: MyConfig = serde_json::from_str(&config_json)
|
|
.unwrap_or_default();
|
|
|
|
Ok(ChannelConfig {
|
|
display_name: "My Channel".to_string(),
|
|
http_endpoints: vec![
|
|
HttpEndpointConfig {
|
|
path: "/webhook/my-channel".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: true, // Validate webhook secret
|
|
},
|
|
],
|
|
poll: None, // Or Some(PollConfig { interval_ms, enabled })
|
|
})
|
|
}
|
|
|
|
/// Handle incoming HTTP requests (webhooks).
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
// Parse webhook payload
|
|
// Emit messages to agent
|
|
// Return response to webhook caller
|
|
}
|
|
|
|
/// Called periodically if polling is enabled.
|
|
fn on_poll() {
|
|
// Fetch new messages from API
|
|
// Emit any new messages
|
|
}
|
|
|
|
/// Send a response back to the messaging platform.
|
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
// Parse metadata to get routing info
|
|
// Call platform API to send message
|
|
}
|
|
|
|
/// Called when channel is shutting down.
|
|
fn on_shutdown() {
|
|
channel_host::log(channel_host::LogLevel::Info, "Channel shutting down");
|
|
}
|
|
}
|
|
|
|
// Export the channel implementation
|
|
export!(MyChannel);
|
|
```
|
|
|
|
## Critical Pattern: Metadata Flow
|
|
|
|
**The most important pattern**: Store routing info in message metadata so responses can be delivered.
|
|
|
|
```rust
|
|
// When receiving a message, store routing info:
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct MyMessageMetadata {
|
|
chat_id: String, // Where to send response
|
|
sender_id: String, // Who sent it (becomes recipient)
|
|
original_message_id: String,
|
|
}
|
|
|
|
// In on_http_request or on_poll:
|
|
let metadata = MyMessageMetadata {
|
|
chat_id: message.chat.id.clone(),
|
|
sender_id: message.from.clone(), // CRITICAL: Store sender!
|
|
original_message_id: message.id.clone(),
|
|
};
|
|
|
|
channel_host::emit_message(&EmittedMessage {
|
|
user_id: message.from.clone(),
|
|
user_name: Some(name),
|
|
content: text,
|
|
thread_id: None,
|
|
metadata_json: serde_json::to_string(&metadata).unwrap_or_default(),
|
|
});
|
|
|
|
// In on_respond, use the ORIGINAL message's metadata:
|
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
let metadata: MyMessageMetadata = serde_json::from_str(&response.metadata_json)?;
|
|
|
|
// sender_id becomes the recipient!
|
|
send_message(metadata.chat_id, metadata.sender_id, response.content);
|
|
}
|
|
```
|
|
|
|
## Credential Injection
|
|
|
|
**Never hardcode credentials!** Use placeholders that the host replaces:
|
|
|
|
### URL Placeholders (Telegram-style)
|
|
|
|
```rust
|
|
// The host replaces {TELEGRAM_BOT_TOKEN} with the actual token
|
|
let url = "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage";
|
|
channel_host::http_request("POST", url, &headers_json, Some(&body));
|
|
```
|
|
|
|
### Header Placeholders (WhatsApp-style)
|
|
|
|
```rust
|
|
// The host replaces {WHATSAPP_ACCESS_TOKEN} in headers too
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
|
});
|
|
channel_host::http_request("POST", &url, &headers.to_string(), Some(&body));
|
|
```
|
|
|
|
The placeholder format is `{SECRET_NAME}` where `SECRET_NAME` matches the credential name in uppercase with underscores (e.g., `whatsapp_access_token` → `{WHATSAPP_ACCESS_TOKEN}`).
|
|
|
|
## Capabilities File
|
|
|
|
Create `my-channel.capabilities.json`:
|
|
|
|
```json
|
|
{
|
|
"type": "channel",
|
|
"name": "my-channel",
|
|
"description": "My messaging platform channel",
|
|
"setup": {
|
|
"required_secrets": [
|
|
{
|
|
"name": "my_channel_api_token",
|
|
"prompt": "Enter your API token",
|
|
"validation": "^[A-Za-z0-9_-]+$"
|
|
},
|
|
{
|
|
"name": "my_channel_webhook_secret",
|
|
"prompt": "Webhook secret (leave empty to auto-generate)",
|
|
"optional": true,
|
|
"auto_generate": { "length": 32 }
|
|
}
|
|
],
|
|
"validation_endpoint": "https://api.my-platform.com/verify?token={my_channel_api_token}"
|
|
},
|
|
"capabilities": {
|
|
"http": {
|
|
"allowlist": [
|
|
{ "host": "api.my-platform.com", "path_prefix": "/" }
|
|
],
|
|
"rate_limit": {
|
|
"requests_per_minute": 60,
|
|
"requests_per_hour": 1000
|
|
}
|
|
},
|
|
"secrets": {
|
|
"allowed_names": ["my_channel_*"]
|
|
},
|
|
"channel": {
|
|
"allowed_paths": ["/webhook/my-channel"],
|
|
"allow_polling": false,
|
|
"workspace_prefix": "channels/my-channel/",
|
|
"emit_rate_limit": {
|
|
"messages_per_minute": 100,
|
|
"messages_per_hour": 5000
|
|
},
|
|
"webhook": {
|
|
"secret_header": "X-Webhook-Secret",
|
|
"secret_name": "my_channel_webhook_secret"
|
|
}
|
|
}
|
|
},
|
|
"config": {
|
|
"custom_option": "value"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Building and Deploying
|
|
|
|
### Supply Chain Security: No Committed Binaries
|
|
|
|
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
|
|
|
|
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
|
|
- The built binary is in `.gitignore` and is not committed
|
|
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
|
|
|
|
**Reproducible build:**
|
|
```bash
|
|
cargo build --release
|
|
```
|
|
|
|
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
|
|
|
|
### Telegram Channel (Manual Build)
|
|
|
|
```bash
|
|
# Add WASM target if needed
|
|
rustup target add wasm32-wasip2
|
|
|
|
# Build Telegram channel
|
|
./channels-src/telegram/build.sh
|
|
|
|
# Install (or use ironclaw onboard to install bundled channel)
|
|
mkdir -p ~/.ironclaw/channels
|
|
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
|
```
|
|
|
|
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
|
|
|
|
### Other Channels
|
|
|
|
```bash
|
|
# Build the WASM component
|
|
cd channels-src/my-channel
|
|
cargo build --release --target wasm32-wasip2
|
|
|
|
# Deploy to ~/.ironclaw/channels/
|
|
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
|
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
|
```
|
|
|
|
## Host Functions Available
|
|
|
|
The channel host provides these functions:
|
|
|
|
```rust
|
|
// Logging
|
|
channel_host::log(LogLevel::Info, "Message");
|
|
|
|
// Time
|
|
let now = channel_host::now_millis();
|
|
|
|
// Workspace (scoped to channel namespace)
|
|
let data = channel_host::workspace_read("state/offset");
|
|
channel_host::workspace_write("state/offset", "12345")?;
|
|
|
|
// HTTP requests (credentials auto-injected)
|
|
let response = channel_host::http_request("POST", &url, &headers, Some(&body))?;
|
|
|
|
// Emit message to agent
|
|
channel_host::emit_message(&EmittedMessage { ... });
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Webhook Secret Validation
|
|
|
|
The host validates webhook secrets automatically. Check `req.secret_validated`:
|
|
|
|
```rust
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
if !req.secret_validated {
|
|
channel_host::log(LogLevel::Warn, "Invalid webhook secret");
|
|
// Host should have already rejected, but defense in depth
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Polling with Offset Tracking
|
|
|
|
For platforms that require polling (not webhook-based):
|
|
|
|
```rust
|
|
const OFFSET_PATH: &str = "state/last_offset";
|
|
|
|
fn on_poll() {
|
|
// Read last offset
|
|
let offset = channel_host::workspace_read(OFFSET_PATH)
|
|
.and_then(|s| s.parse::<i64>().ok())
|
|
.unwrap_or(0);
|
|
|
|
// Fetch updates since offset
|
|
let updates = fetch_updates(offset);
|
|
|
|
// Process and track new offset
|
|
let mut new_offset = offset;
|
|
for update in updates {
|
|
if update.id >= new_offset {
|
|
new_offset = update.id + 1;
|
|
}
|
|
emit_message(update);
|
|
}
|
|
|
|
// Save new offset
|
|
if new_offset != offset {
|
|
let _ = channel_host::workspace_write(OFFSET_PATH, &new_offset.to_string());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Status Message Filtering
|
|
|
|
Skip status updates to prevent loops:
|
|
|
|
```rust
|
|
// Skip status updates (delivered, read, etc.)
|
|
if !payload.statuses.is_empty() && payload.messages.is_empty() {
|
|
return; // Only status updates, no actual messages
|
|
}
|
|
```
|
|
|
|
### Bot Message Filtering
|
|
|
|
Skip bot messages to prevent infinite loops:
|
|
|
|
```rust
|
|
if sender.is_bot {
|
|
return; // Don't respond to bots
|
|
}
|
|
```
|
|
|
|
## Testing
|
|
|
|
Add tests in the same file:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_webhook() {
|
|
let json = r#"{ ... }"#;
|
|
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
|
|
assert_eq!(payload.messages.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_roundtrip() {
|
|
let meta = MyMessageMetadata { ... };
|
|
let json = serde_json::to_string(&meta).unwrap();
|
|
let parsed: MyMessageMetadata = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(meta.chat_id, parsed.chat_id);
|
|
}
|
|
}
|
|
```
|
|
|
|
Run tests with:
|
|
```bash
|
|
cargo test
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### "byte index N is not a char boundary"
|
|
|
|
Never slice strings by byte index! Use character-aware truncation:
|
|
|
|
```rust
|
|
// BAD: panics on multi-byte UTF-8 (emoji, etc.)
|
|
let preview = &content[..50];
|
|
|
|
// GOOD: safe truncation
|
|
let preview: String = content.chars().take(50).collect();
|
|
```
|
|
|
|
### Credential placeholders not replaced
|
|
|
|
1. Check the secret name matches (lowercase with underscores)
|
|
2. Verify the secret is in `allowed_names` in capabilities
|
|
3. Check logs for "unresolved placeholders" warnings
|
|
|
|
### Messages not routing to responses
|
|
|
|
Ensure `on_respond` uses the ORIGINAL message's metadata, not response metadata:
|
|
```rust
|
|
// response.metadata_json comes from the ORIGINAL emit_message call
|
|
let metadata: MyMetadata = serde_json::from_str(&response.metadata_json)?;
|
|
```
|