DM pairing + Telegram channel improvements (#17)

* 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.
This commit is contained in:
Ilgın Kanat
2026-02-12 00:46:47 +00:00
committed by GitHub
parent bb228f6315
commit 115b7f38fe
22 changed files with 1774 additions and 129 deletions
Generated
+11
View File
@@ -1621,6 +1621,16 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "fs4"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2299,6 +2309,7 @@ dependencies = [
"deadpool-postgres", "deadpool-postgres",
"dirs 6.0.0", "dirs 6.0.0",
"dotenvy", "dotenvy",
"fs4",
"futures", "futures",
"hkdf", "hkdf",
"http-body-util", "http-body-util",
+1
View File
@@ -67,6 +67,7 @@ aho-corasick = "1"
# Filesystem paths # Filesystem paths
dirs = "6" dirs = "6"
fs4 = "0.6"
# Secrecy for sensitive values # Secrecy for sensitive values
secrecy = { version = "0.10", features = ["serde"] } secrecy = { version = "0.10", features = ["serde"] }
+16 -9
View File
@@ -59,7 +59,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing | | REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation | | WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
| Telegram | ✅ | ✅ | - | WASM tool (MTProto) | | Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
| Discord | ✅ | ❌ | P2 | discord.js | | Discord | ✅ | ❌ | P2 | discord.js |
| Signal | ✅ | ❌ | P2 | signal-cli | | Signal | ✅ | ❌ | P2 | signal-cli |
| Slack | ✅ | ✅ | - | WASM tool | | Slack | ✅ | ✅ | - | WASM tool |
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| DM pairing codes | ✅ | | Verification for unknown senders | | DM pairing codes | ✅ | | `ironclaw pairing list/approve`, host APIs |
| Allowlist/blocklist | ✅ | | Per-channel access control | | Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Self-message bypass | ✅ | ❌ | Own messages skip pairing | | Self-message bypass | ✅ | ❌ | Own messages skip pairing |
| Mention-based activation | ✅ | | Configurable patterns | | Mention-based activation | ✅ | | bot_username + respond_to_all_group_messages |
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools | | Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
| Thread isolation | ✅ | ✅ | Separate sessions per thread | | Thread isolation | ✅ | ✅ | Separate sessions per thread |
| Per-channel media limits | ✅ | | | | Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
| Typing indicators | ✅ | 🚧 | TUI shows status | | Typing indicators | ✅ | 🚧 | TUI shows status |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -109,7 +109,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `sessions` | ✅ | ❌ | P3 | Session listing | | `sessions` | ✅ | ❌ | P3 | Session listing |
| `memory` | ✅ | ✅ | - | Memory search CLI | | `memory` | ✅ | ✅ | - | Memory search CLI |
| `skills` | ✅ | ❌ | P3 | Agent skills | | `skills` | ✅ | ❌ | P3 | Agent skills |
| `pairing` | ✅ | | P3 | Node pairing | | `pairing` | ✅ | | - | list/approve for channel DM pairing |
| `nodes` | ✅ | ❌ | P3 | Device management | | `nodes` | ✅ | ❌ | P3 | Device management |
| `plugins` | ✅ | ❌ | P3 | Plugin management | | `plugins` | ✅ | ❌ | P3 | Plugin management |
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks | | `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
@@ -350,8 +350,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | | | Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| DM pairing verification | ✅ | | | | DM pairing verification | ✅ | | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | | | | Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | | | Per-group tool policies | ✅ | ❌ | |
| Exec approvals | ✅ | ✅ | TUI overlay | | Exec approvals | ✅ | ✅ | TUI overlay |
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
@@ -397,6 +397,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
### P0 - Core (Already Done) ### P0 - Core (Already Done)
- ✅ TUI channel with approval overlays - ✅ TUI channel with approval overlays
- ✅ HTTP webhook channel - ✅ HTTP webhook channel
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
- ✅ WASM tool sandbox - ✅ WASM tool sandbox
- ✅ Workspace/memory with hybrid search - ✅ Workspace/memory with hybrid search
- ✅ Prompt injection defense - ✅ Prompt injection defense
@@ -415,12 +416,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Gateway token auth - ✅ Gateway token auth
### P1 - High Priority ### P1 - High Priority
- ❌ Slack channel (real implementation)
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
- ❌ WhatsApp channel - ❌ WhatsApp channel
- ❌ Multi-provider failover - ❌ Multi-provider failover
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.) - ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
### P2 - Medium Priority ### P2 - Medium Priority
-Media handling (images, PDFs) -Cron job scheduling
- ❌ Web Control UI
- ❌ WebChat channel
- 🚧 Media handling (caption support; no image/PDF processing)
- ❌ CLI subcommands (config, status, memory, doctor)
- ❌ Ollama/local model support - ❌ Ollama/local model support
- ❌ Configuration hot-reload - ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway - ❌ Webhook trigger endpoint in web gateway
+5
View File
@@ -85,6 +85,8 @@ cargo build --release
cargo test cargo test
``` ```
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
### Database Setup ### Database Setup
```bash ```bash
@@ -228,6 +230,9 @@ cargo test
cargo test test_name cargo test test_name
``` ```
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
## OpenClaw Heritage ## OpenClaw Heritage
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
+105
View File
@@ -0,0 +1,105 @@
//! Build script: compile Telegram channel WASM from source.
//!
//! Do not commit compiled WASM binaries — they are a supply chain risk.
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
//!
//! Reproducible build:
//! cargo build --release
//! (build.rs invokes the channel build automatically)
//!
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let root = PathBuf::from(&manifest_dir);
let channel_dir = root.join("channels-src/telegram");
let wasm_out = channel_dir.join("telegram.wasm");
// Rerun when channel source or build script changes
println!("cargo:rerun-if-changed=channels-src/telegram/src");
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
println!("cargo:rerun-if-changed=wit/channel.wit");
if !channel_dir.is_dir() {
return;
}
// Build WASM module
let status = match Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--manifest-path",
channel_dir.join("Cargo.toml").to_str().unwrap(),
])
.current_dir(&root)
.status()
{
Ok(s) => s,
Err(_) => {
eprintln!(
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
);
return;
}
};
if !status.success() {
eprintln!(
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
);
return;
}
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
if !raw_wasm.exists() {
eprintln!(
"cargo:warning=Telegram WASM output not found at {:?}",
raw_wasm
);
return;
}
// Convert to component and strip (wasm-tools)
let component_ok = Command::new("wasm-tools")
.args([
"component",
"new",
raw_wasm.to_str().unwrap(),
"-o",
wasm_out.to_str().unwrap(),
])
.current_dir(&root)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !component_ok
{
// Fallback: copy raw module if wasm-tools unavailable
if std::fs::copy(&raw_wasm, &wasm_out).is_err() {
eprintln!(
"cargo:warning=wasm-tools not found. Run: cargo install wasm-tools"
);
}
} else {
// Strip debug info (use temp file to avoid clobbering)
let stripped = wasm_out.with_extension("wasm.stripped");
let strip_ok = Command::new("wasm-tools")
.args(["strip", wasm_out.to_str().unwrap(), "-o", stripped.to_str().unwrap()])
.current_dir(&root)
.status()
.map(|s| s.success())
.unwrap_or(false);
if strip_ok
{
let _ = std::fs::rename(&stripped, &wasm_out);
}
}
}
+269 -48
View File
@@ -72,6 +72,10 @@ struct TelegramMessage {
/// Message text. /// Message text.
text: Option<String>, text: Option<String>,
/// Caption for media (photo, video, document, etc.).
#[serde(default)]
caption: Option<String>,
/// Original message if this is a reply. /// Original message if this is a reply.
reply_to_message: Option<Box<TelegramMessage>>, reply_to_message: Option<Box<TelegramMessage>>,
@@ -160,6 +164,21 @@ const POLLING_STATE_PATH: &str = "state/last_update_id";
/// Workspace path for persisting owner_id across WASM callbacks. /// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id"; const OWNER_ID_PATH: &str = "state/owner_id";
/// Workspace path for persisting dm_policy across WASM callbacks.
const DM_POLICY_PATH: &str = "state/dm_policy";
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
const ALLOW_FROM_PATH: &str = "state/allow_from";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "telegram";
/// Workspace path for persisting bot_username for mention detection in groups.
const BOT_USERNAME_PATH: &str = "state/bot_username";
/// Workspace path for persisting respond_to_all_group_messages flag.
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
// ============================================================================ // ============================================================================
// Channel Metadata // Channel Metadata
// ============================================================================ // ============================================================================
@@ -196,6 +215,14 @@ struct TelegramConfig {
#[serde(default)] #[serde(default)]
owner_id: Option<i64>, owner_id: Option<i64>,
/// DM policy: "pairing" (default), "allowlist", or "open".
#[serde(default)]
dm_policy: Option<String>,
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
#[serde(default)]
allow_from: Option<Vec<String>>,
/// Whether to respond to all group messages (not just mentions). /// Whether to respond to all group messages (not just mentions).
#[serde(default)] #[serde(default)]
respond_to_all_group_messages: bool, respond_to_all_group_messages: bool,
@@ -257,6 +284,28 @@ impl Guest for TelegramChannel {
); );
} }
// Persist dm_policy and allow_from for DM pairing in handle_message
let dm_policy = config
.dm_policy
.as_deref()
.unwrap_or("pairing")
.to_string();
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist bot_username and respond_to_all_group_messages for group handling
let _ = channel_host::workspace_write(
BOT_USERNAME_PATH,
&config.bot_username.unwrap_or_default(),
);
let _ = channel_host::workspace_write(
RESPOND_TO_ALL_GROUP_PATH,
&config.respond_to_all_group_messages.to_string(),
);
// Mode is determined by whether the host injected a tunnel_url // Mode is determined by whether the host injected a tunnel_url
// If tunnel is configured, use webhooks. Otherwise, use polling. // If tunnel is configured, use webhooks. Otherwise, use polling.
let webhook_mode = config.tunnel_url.is_some(); let webhook_mode = config.tunnel_url.is_some();
@@ -780,6 +829,47 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
} }
} }
// ============================================================================
// Pairing Reply
// ============================================================================
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
let payload = serde_json::json!({
"chat_id": chat_id,
"text": format!(
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
code
),
"parse_mode": "Markdown",
});
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
&headers.to_string(),
Some(&payload_bytes),
);
match result {
Ok(response) => {
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
return Err(format!("HTTP {}: {}", response.status, body_str));
}
Ok(())
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
// ============================================================================ // ============================================================================
// Update Handling // Update Handling
// ============================================================================ // ============================================================================
@@ -799,11 +889,16 @@ fn handle_update(update: TelegramUpdate) {
/// Process a single message. /// Process a single message.
fn handle_message(message: TelegramMessage) { fn handle_message(message: TelegramMessage) {
// Skip messages without text // Use text or caption (for media messages)
let text = match message.text { let content = message
Some(t) if !t.is_empty() => t, .text
_ => return, .filter(|t| !t.is_empty())
}; .or_else(|| message.caption.filter(|c| !c.is_empty()))
.unwrap_or_default();
if content.is_empty() {
return;
}
// Skip messages without a sender (channel posts) // Skip messages without a sender (channel posts)
let from = match message.from { let from = match message.from {
@@ -816,41 +911,111 @@ fn handle_message(message: TelegramMessage) {
return; return;
} }
// Owner validation: silently drop messages from non-owner users let is_private = message.chat.chat_type == "private";
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
if !owner_id_str.is_empty() { // Owner validation: when owner_id is set, only that user can message
if let Ok(owner_id) = owner_id_str.parse::<i64>() { let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
if from.id != owner_id { .map(|s| !s.is_empty())
channel_host::log( .unwrap_or(false);
channel_host::LogLevel::Debug,
&format!( if owner_configured {
"Dropping message from non-owner user {} (owner: {})", if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
from.id, owner_id .unwrap()
), .parse::<i64>()
); {
return; if from.id != owner_id {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from non-owner user {} (owner: {})",
from.id, owner_id
),
);
return;
}
}
} else if is_private {
// No owner_id: apply dm_policy for private chats
let dm_policy = channel_host::workspace_read(DM_POLICY_PATH)
.unwrap_or_else(|| "pairing".to_string());
if dm_policy != "open" {
// Build effective allow list: config allow_from + pairing store
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
allowed.extend(store_allowed);
}
let id_str = from.id.to_string();
let username_opt = from.username.as_deref();
let is_allowed = allowed.contains(&"*".to_string())
|| allowed.contains(&id_str)
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
if !is_allowed {
if dm_policy == "pairing" {
// Upsert pairing request and send reply
let meta = serde_json::json!({
"chat_id": message.chat.id,
"user_id": from.id,
"username": username_opt,
})
.to_string();
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
Ok(result) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"Pairing request for user {} (chat {}): code {}",
from.id, message.chat.id, result.code
),
);
if result.created {
let _ = send_pairing_reply(message.chat.id, &result.code);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Pairing upsert failed: {}", e),
);
}
}
} }
return;
} }
} }
} }
let is_private = message.chat.chat_type == "private"; // For group chats, only respond if bot was mentioned or respond_to_all is enabled
// For group chats, check if the bot was mentioned
// TODO: Read bot_username from config and check mentions
// For now, process all messages in private chats and groups
if !is_private { if !is_private {
// In groups, only respond if there's a bot mention or command let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
// This is a simplified check - proper implementation would use entities .as_deref()
let has_command = text.starts_with('/'); .unwrap_or("false")
let has_mention = text.contains('@'); == "true";
if !has_command && !has_mention { if !respond_to_all {
channel_host::log( let has_command = content.starts_with('/');
channel_host::LogLevel::Debug, let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
&format!("Ignoring group message without mention: {}", text), .unwrap_or_default();
); let has_bot_mention = if bot_username.is_empty() {
return; content.contains('@')
} else {
let mention = format!("@{}", bot_username);
content.to_lowercase().contains(&mention.to_lowercase())
};
if !has_command && !has_bot_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", content),
);
return;
}
} }
} }
@@ -872,17 +1037,30 @@ fn handle_message(message: TelegramMessage) {
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
// Clean the message text (strip bot mentions and commands) // Clean the message text (strip bot mentions and commands)
let cleaned_text = clean_message_text(&text); let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
let cleaned_text = clean_message_text(
&content,
if bot_username.is_empty() {
None
} else {
Some(bot_username.as_str())
},
);
if cleaned_text.is_empty() { // For /start with no args, emit placeholder so agent can respond with welcome
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
"[User started the bot]".to_string()
} else if cleaned_text.is_empty() {
return; return;
} } else {
cleaned_text
};
// Emit the message to the agent // Emit the message to the agent
channel_host::emit_message(&EmittedMessage { channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(), user_id: from.id.to_string(),
user_name: Some(user_name), user_name: Some(user_name),
content: cleaned_text, content: content_to_emit,
thread_id: None, // Telegram doesn't have threads in the same way thread_id: None, // Telegram doesn't have threads in the same way
metadata_json, metadata_json,
}); });
@@ -897,7 +1075,8 @@ fn handle_message(message: TelegramMessage) {
} }
/// Clean message text by removing bot commands and @mentions at the start. /// Clean message text by removing bot commands and @mentions at the start.
fn clean_message_text(text: &str) -> String { /// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
let mut result = text.trim().to_string(); let mut result = text.trim().to_string();
// Remove leading /command // Remove leading /command
@@ -912,11 +1091,30 @@ fn clean_message_text(text: &str) -> String {
// Remove leading @mention // Remove leading @mention
if result.starts_with('@') { if result.starts_with('@') {
if let Some(space_idx) = result.find(' ') { if let Some(bot) = bot_username {
result = result[space_idx..].trim_start().to_string(); let mention = format!("@{}", bot);
let mention_lower = mention.to_lowercase();
let result_lower = result.to_lowercase();
if result_lower.starts_with(&mention_lower) {
let rest = result[mention.len()..].trim_start();
if rest.is_empty() {
return String::new();
}
result = rest.to_string();
} else if let Some(space_idx) = result.find(' ') {
// Different leading @mention - only strip if it's the bot
let first_word = &result[..space_idx];
if first_word.eq_ignore_ascii_case(&mention) {
result = result[space_idx..].trim_start().to_string();
}
}
} else { } else {
// Just a mention with no text // No bot_username: strip any leading @mention
return String::new(); if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
return String::new();
}
} }
} }
@@ -952,12 +1150,22 @@ mod tests {
#[test] #[test]
fn test_clean_message_text() { fn test_clean_message_text() {
assert_eq!(clean_message_text("/start hello"), "hello"); // Without bot_username: strips any leading @mention
assert_eq!(clean_message_text("@bot hello world"), "hello world"); assert_eq!(clean_message_text("/start hello", None), "hello");
assert_eq!(clean_message_text("/start"), ""); assert_eq!(clean_message_text("@bot hello world", None), "hello world");
assert_eq!(clean_message_text("@botname"), ""); assert_eq!(clean_message_text("/start", None), "");
assert_eq!(clean_message_text("just text"), "just text"); assert_eq!(clean_message_text("@botname", None), "");
assert_eq!(clean_message_text(" spaced "), "spaced"); assert_eq!(clean_message_text("just text", None), "just text");
assert_eq!(clean_message_text(" spaced ", None), "spaced");
// With bot_username: only strips @MyBot, not @alice
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
assert_eq!(
clean_message_text("@alice hello", Some("MyBot")),
"@alice hello"
);
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
} }
#[test] #[test]
@@ -1025,4 +1233,17 @@ mod tests {
assert_eq!(from.id, 789); assert_eq!(from.id, 789);
assert_eq!(from.first_name, "John"); assert_eq!(from.first_name, "John");
} }
#[test]
fn test_parse_message_with_caption() {
let json = r#"{
"message_id": 1,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"caption": "What's in this image?"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.text, None);
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
}
} }
@@ -1,44 +1 @@
{ {"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}}
"type": "channel",
"name": "telegram",
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
"capabilities": {
"http": {
"allowlist": [
{ "host": "api.telegram.org", "path_prefix": "/bot" }
],
"credentials": {
"telegram_bot": {
"secret_name": "telegram_bot_token",
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
"host_patterns": ["api.telegram.org"]
}
},
"rate_limit": {
"requests_per_minute": 30,
"requests_per_hour": 1000
}
},
"secrets": {
"allowed_names": ["telegram_*"]
},
"channel": {
"allowed_paths": ["/webhook/telegram"],
"allow_polling": true,
"min_poll_interval_ms": 30000,
"callback_timeout_secs": 45,
"workspace_prefix": "channels/telegram/",
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"bot_username": null,
"owner_id": null,
"respond_to_all_group_messages": false,
"polling_enabled": false,
"poll_interval_ms": 30000
}
}
+36 -3
View File
@@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`:
## Building and Deploying ## 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 ```bash
# Build the WASM component # Build the WASM component
cd channels/my-channel cd channels-src/my-channel
cargo component build --release cargo build --release --target wasm32-wasip2
# Deploy to ~/.ironclaw/channels/ # Deploy to ~/.ironclaw/channels/
cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
cp my-channel.capabilities.json ~/.ironclaw/channels/ cp my-channel.capabilities.json ~/.ironclaw/channels/
``` ```
+135
View File
@@ -0,0 +1,135 @@
# Telegram Channel Setup
This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control.
## Overview
The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports:
- **Webhook mode** (recommended): Instant delivery via tunnel
- **Polling mode**: No tunnel required; ~30s delay
- **DM pairing**: Approve unknown users before they can message the agent
- **Group mentions**: `@YourBot` or `/command` to trigger in groups
## Prerequisites
- IronClaw installed and configured (`ironclaw onboard`)
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
## Quick Start
### 1. Create a Bot
1. Message [@BotFather](https://t.me/BotFather) on Telegram
2. Send `/newbot` and follow the prompts
3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
### 2. Configure via Setup Wizard
```bash
ironclaw onboard
```
When prompted, enable the Telegram channel and paste your bot token. The wizard will:
- Validate the token
- Optionally configure a webhook secret
- Set up tunnel (if you want webhook mode)
### 3. (Optional) Configure Tunnel for Webhooks
For instant message delivery, expose your agent via a tunnel:
```bash
# ngrok
ngrok http 8080
# Cloudflare
cloudflared tunnel --url http://localhost:8080
```
Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay).
## DM Pairing
When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent.
### Flow
1. Unknown user sends a message to your bot
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
3. You run: `ironclaw pairing approve telegram ABC12345`
4. User is added to the allow list; future messages are delivered
### Commands
```bash
# List pending pairing requests
ironclaw pairing list telegram
# List as JSON
ironclaw pairing list telegram --json
# Approve a user by code
ironclaw pairing approve telegram ABC12345
```
### Configuration
Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host):
| Option | Values | Default | Description |
|--------|--------|---------|-------------|
| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown |
| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. |
| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) |
| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages |
| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands |
## Manual Installation
If the channel isn't installed via the wizard:
```bash
# Build the Telegram channel (requires wasm32-wasip2 target)
rustup target add wasm32-wasip2
./channels-src/telegram/build.sh
# Install
mkdir -p ~/.ironclaw/channels
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
```
## Secrets
The channel expects a secret named `telegram_bot_token`. Configure via:
- **Setup wizard**: Saves to encrypted secrets store
- **Environment**: `TELEGRAM_BOT_TOKEN=your_token`
- **Secrets store**: `ironclaw` CLI (if available)
## Webhook Secret (Optional)
For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding.
## Troubleshooting
### Messages not delivered
- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid.
- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS.
### Pairing code not received
- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`)
- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply)
### Group mentions not working
- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`)
- Ensure the message contains `@YourBot` or starts with `/`
### "Connection refused" when starting
- For webhook mode: Start your tunnel before `ironclaw run`
- For polling only: No tunnel needed; ignore tunnel-related warnings
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Build IronClaw and all bundled channels.
#
# Run this before release or when channel sources have changed.
# The main binary bundles telegram.wasm via include_bytes!; it must exist.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Building bundled channels..."
if [ -d "channels-src/telegram" ]; then
./channels-src/telegram/build.sh
fi
echo ""
echo "Building IronClaw..."
cargo build --release
echo ""
echo "Done. Binary: target/release/ironclaw"
+17 -5
View File
@@ -16,16 +16,21 @@ use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::runtime::WasmChannelRuntime; use crate::channels::wasm::runtime::WasmChannelRuntime;
use crate::channels::wasm::schema::ChannelCapabilitiesFile; use crate::channels::wasm::schema::ChannelCapabilitiesFile;
use crate::channels::wasm::wrapper::WasmChannel; use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
/// Loads WASM channels from the filesystem. /// Loads WASM channels from the filesystem.
pub struct WasmChannelLoader { pub struct WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>, runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
} }
impl WasmChannelLoader { impl WasmChannelLoader {
/// Create a new loader with the given runtime. /// Create a new loader with the given runtime and pairing store.
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self { pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
Self { runtime } Self {
runtime,
pairing_store,
}
} }
/// Load a single WASM channel from a file pair. /// Load a single WASM channel from a file pair.
@@ -114,7 +119,13 @@ impl WasmChannelLoader {
.await?; .await?;
// Create the channel // Create the channel
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json); let channel = WasmChannel::new(
self.runtime.clone(),
prepared,
capabilities,
config_json,
self.pairing_store.clone(),
);
tracing::info!( tracing::info!(
name = name, name = name,
@@ -352,6 +363,7 @@ mod tests {
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
use crate::pairing::PairingStore;
use std::sync::Arc; use std::sync::Arc;
#[tokio::test] #[tokio::test]
@@ -408,7 +420,7 @@ mod tests {
async fn test_loader_invalid_name() { async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing(); let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime); let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("test.wasm"); let wasm_path = dir.path().join("test.wasm");
+3 -1
View File
@@ -469,7 +469,7 @@ pub fn create_wasm_channel_router(
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::capabilities::ChannelCapabilities;
@@ -478,6 +478,7 @@ mod tests {
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
}; };
use crate::channels::wasm::wrapper::WasmChannel; use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits; use crate::tools::wasm::ResourceLimits;
fn create_test_channel(name: &str) -> Arc<WasmChannel> { fn create_test_channel(name: &str) -> Arc<WasmChannel> {
@@ -499,6 +500,7 @@ mod tests {
prepared, prepared,
capabilities, capabilities,
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()),
)) ))
} }
+125 -16
View File
@@ -43,6 +43,7 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
use crate::pairing::PairingStore;
use crate::channels::wasm::router::RegisteredEndpoint; use crate::channels::wasm::router::RegisteredEndpoint;
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime}; use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig; use crate::channels::wasm::schema::ChannelConfig;
@@ -73,6 +74,8 @@ struct ChannelStoreData {
/// Injected credentials for URL substitution (e.g., bot tokens). /// Injected credentials for URL substitution (e.g., bot tokens).
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
} }
impl ChannelStoreData { impl ChannelStoreData {
@@ -81,6 +84,7 @@ impl ChannelStoreData {
channel_name: &str, channel_name: &str,
capabilities: ChannelCapabilities, capabilities: ChannelCapabilities,
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Self { ) -> Self {
// Create a minimal WASI context (no filesystem, no env vars for security) // Create a minimal WASI context (no filesystem, no env vars for security)
let wasi = WasiCtxBuilder::new().build(); let wasi = WasiCtxBuilder::new().build();
@@ -91,6 +95,7 @@ impl ChannelStoreData {
wasi, wasi,
table: ResourceTable::new(), table: ResourceTable::new(),
credentials, credentials,
pairing_store,
} }
} }
@@ -403,6 +408,43 @@ impl near::agent::channel_host::Host for ChannelStoreData {
} }
} }
} }
fn pairing_upsert_request(
&mut self,
channel: String,
id: String,
meta_json: String,
) -> Result<near::agent::channel_host::PairingUpsertResult, String> {
let meta = if meta_json.is_empty() {
None
} else {
serde_json::from_str(&meta_json).ok()
};
match self.pairing_store.upsert_request(&channel, &id, meta) {
Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult {
code: r.code,
created: r.created,
}),
Err(e) => Err(e.to_string()),
}
}
fn pairing_is_allowed(
&mut self,
channel: String,
id: String,
username: Option<String>,
) -> Result<bool, String> {
self.pairing_store
.is_sender_allowed(&channel, &id, username.as_deref())
.map_err(|e| e.to_string())
}
fn pairing_read_allow_from(&mut self, channel: String) -> Result<Vec<String>, String> {
self.pairing_store
.read_allow_from(&channel)
.map_err(|e| e.to_string())
}
} }
/// A WASM-based channel implementing the Channel trait. /// A WASM-based channel implementing the Channel trait.
@@ -455,6 +497,9 @@ pub struct WasmChannel {
/// Background task that repeats typing indicators every 4 seconds. /// Background task that repeats typing indicators every 4 seconds.
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it. /// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>, typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
} }
impl WasmChannel { impl WasmChannel {
@@ -464,6 +509,7 @@ impl WasmChannel {
prepared: Arc<PreparedChannelModule>, prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities, capabilities: ChannelCapabilities,
config_json: String, config_json: String,
pairing_store: Arc<PairingStore>,
) -> Self { ) -> Self {
let name = prepared.name.clone(); let name = prepared.name.clone();
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone()); let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
@@ -483,6 +529,7 @@ impl WasmChannel {
endpoints: RwLock::new(Vec::new()), endpoints: RwLock::new(Vec::new()),
credentials: Arc::new(RwLock::new(HashMap::new())), credentials: Arc::new(RwLock::new(HashMap::new())),
typing_task: RwLock::new(None), typing_task: RwLock::new(None),
pairing_store,
} }
} }
@@ -564,6 +611,7 @@ impl WasmChannel {
prepared: &PreparedChannelModule, prepared: &PreparedChannelModule,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Result<Store<ChannelStoreData>, WasmChannelError> { ) -> Result<Store<ChannelStoreData>, WasmChannelError> {
let engine = runtime.engine(); let engine = runtime.engine();
let limits = &prepared.limits; let limits = &prepared.limits;
@@ -574,6 +622,7 @@ impl WasmChannel {
&prepared.name, &prepared.name,
capabilities.clone(), capabilities.clone(),
credentials, credentials,
pairing_store,
); );
let mut store = Store::new(engine, store_data); let mut store = Store::new(engine, store_data);
@@ -674,12 +723,18 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials)?; &runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_start using the generated typed interface // Call on_start using the generated typed interface
@@ -784,6 +839,7 @@ impl WasmChannel {
let capabilities = self.capabilities.clone(); let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare request data // Prepare request data
let method = method.to_string(); let method = method.to_string();
@@ -797,8 +853,13 @@ impl WasmChannel {
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials)?; &runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Build the WIT request type // Build the WIT request type
@@ -871,12 +932,18 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials)?; &runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface // Call on_poll using the generated typed interface
@@ -960,6 +1027,7 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare response data // Prepare response data
let message_id_str = message_id.to_string(); let message_id_str = message_id.to_string();
@@ -973,8 +1041,13 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
tracing::info!("Creating WASM store for on_respond"); tracing::info!("Creating WASM store for on_respond");
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials)?; &runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
tracing::info!("Instantiating WASM component for on_respond"); tracing::info!("Instantiating WASM component for on_respond");
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
@@ -1067,13 +1140,19 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let wit_update = status_to_wit(status, metadata); let wit_update = status_to_wit(status, metadata);
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials)?; &runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel(); let channel_iface = instance.near_agent_channel();
@@ -1117,6 +1196,7 @@ impl WasmChannel {
prepared: &Arc<PreparedChannelModule>, prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>, credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration, timeout: Duration,
wit_update: wit_channel::StatusUpdate, wit_update: wit_channel::StatusUpdate,
) -> Result<(), WasmChannelError> { ) -> Result<(), WasmChannelError> {
@@ -1132,8 +1212,13 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; &runtime,
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel(); let channel_iface = instance.near_agent_channel();
@@ -1201,6 +1286,7 @@ impl WasmChannel {
let prepared = Arc::clone(&self.prepared); let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone(); let capabilities = self.capabilities.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout; let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata); let wit_update = status_to_wit(&status, metadata);
@@ -1220,6 +1306,7 @@ impl WasmChannel {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
pairing_store.clone(),
callback_timeout, callback_timeout,
wit_update_clone, wit_update_clone,
) )
@@ -1350,6 +1437,7 @@ impl WasmChannel {
let message_tx = self.message_tx.clone(); let message_tx = self.message_tx.clone();
let rate_limiter = self.rate_limiter.clone(); let rate_limiter = self.rate_limiter.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout; let callback_timeout = self.runtime.config().callback_timeout;
tokio::spawn(async move { tokio::spawn(async move {
@@ -1371,6 +1459,7 @@ impl WasmChannel {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
pairing_store.clone(),
callback_timeout, callback_timeout,
).await; ).await;
@@ -1422,6 +1511,7 @@ impl WasmChannel {
prepared: &Arc<PreparedChannelModule>, prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>, credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration, timeout: Duration,
) -> Result<Vec<EmittedMessage>, WasmChannelError> { ) -> Result<Vec<EmittedMessage>, WasmChannelError> {
// Skip if no WASM bytes (testing mode) // Skip if no WASM bytes (testing mode)
@@ -1442,8 +1532,13 @@ impl WasmChannel {
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = let mut store = Self::create_store(
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; &runtime,
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface // Call on_poll using the generated typed interface
@@ -1978,6 +2073,7 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use crate::channels::Channel; use crate::channels::Channel;
use crate::pairing::PairingStore;
use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{ use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
@@ -1998,7 +2094,13 @@ mod tests {
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test"); let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) WasmChannel::new(
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
)
} }
#[test] #[test]
@@ -2073,6 +2175,7 @@ mod tests {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
Arc::new(PairingStore::new()),
timeout, timeout,
) )
.await; .await;
@@ -2166,7 +2269,13 @@ mod tests {
.with_path("/webhook/poll") .with_path("/webhook/poll")
.with_polling(1000); .with_polling(1000);
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()); let channel = WasmChannel::new(
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
);
// Start the channel // Start the channel
let _stream = channel.start().await.expect("Channel should start"); let _stream = channel.start().await.expect("Channel should start");
+6
View File
@@ -12,12 +12,14 @@
mod config; mod config;
mod mcp; mod mcp;
pub mod memory; pub mod memory;
mod pairing;
pub mod status; pub mod status;
mod tool; mod tool;
pub use config::{ConfigCommand, run_config_command}; pub use config::{ConfigCommand, run_config_command};
pub use mcp::{McpCommand, run_mcp_command}; pub use mcp::{McpCommand, run_mcp_command};
pub use memory::{MemoryCommand, run_memory_command}; pub use memory::{MemoryCommand, run_memory_command};
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use status::run_status_command; pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command}; pub use tool::{ToolCommand, run_tool_command};
@@ -86,6 +88,10 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
Memory(MemoryCommand), Memory(MemoryCommand),
/// DM pairing (approve inbound requests from unknown senders)
#[command(subcommand)]
Pairing(PairingCommand),
/// Show system health and diagnostics /// Show system health and diagnostics
Status, Status,
+183
View File
@@ -0,0 +1,183 @@
//! DM pairing CLI commands.
//!
//! Manage pairing requests for channels (Telegram, Slack, etc.).
use clap::Subcommand;
use crate::pairing::PairingStore;
/// Pairing subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum PairingCommand {
/// List pending pairing requests
List {
/// Channel name (e.g., telegram, slack)
#[arg(required = true)]
channel: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Approve a pairing request by code
Approve {
/// Channel name (e.g., telegram, slack)
#[arg(required = true)]
channel: String,
/// Pairing code (e.g., ABC12345)
#[arg(required = true)]
code: String,
},
}
/// Run pairing CLI command.
pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> {
run_pairing_command_with_store(&PairingStore::new(), cmd)
}
/// Run pairing CLI command with a given store (for testing).
pub fn run_pairing_command_with_store(
store: &PairingStore,
cmd: PairingCommand,
) -> Result<(), String> {
match cmd {
PairingCommand::List { channel, json } => run_list(store, &channel, json),
PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code),
}
}
fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> {
let requests = store.list_pending(channel).map_err(|e| e.to_string())?;
if json {
println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?);
return Ok(());
}
if requests.is_empty() {
println!("No pending {} pairing requests.", channel);
return Ok(());
}
println!("Pairing requests ({}):", requests.len());
for r in &requests {
let meta = r
.meta
.as_ref()
.and_then(|m| m.as_object())
.map(|o| {
o.iter()
.filter_map(|(k, v)| {
v.as_str().map(|s| format!("{}={}", k, s))
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
println!(" {} {} {} {}", r.code, r.id, meta, r.created_at);
}
Ok(())
}
fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> {
match store.approve(channel, code) {
Ok(Some(entry)) => {
println!("Approved {} sender {}.", channel, entry.id);
Ok(())
}
Ok(None) => Err(format!("No pending pairing request found for code: {}", code)),
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err(
"Too many failed approve attempts. Wait a few minutes before trying again.".to_string(),
),
Err(e) => Err(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_store() -> (PairingStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
(store, dir)
}
#[test]
fn test_list_empty_returns_ok() {
let (store, _) = test_store();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: false,
},
);
assert!(result.is_ok());
}
#[test]
fn test_list_json_empty_returns_ok() {
let (store, _) = test_store();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: true,
},
);
assert!(result.is_ok());
}
#[test]
fn test_approve_invalid_code_returns_err() {
let (store, _) = test_store();
// Create a pending request so the pairing file exists, then approve with wrong code
store.upsert_request("telegram", "user1", None).unwrap();
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code: "BADCODE1".to_string(),
},
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No pending pairing request"));
}
#[test]
fn test_approve_valid_code_returns_ok() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user1", None).unwrap();
assert!(r.created);
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code: r.code,
},
);
assert!(result.is_ok());
}
#[test]
fn test_list_with_pending_returns_ok() {
let (store, _) = test_store();
store.upsert_request("telegram", "user1", None).unwrap();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: false,
},
);
assert!(result.is_ok());
}
}
+1
View File
@@ -43,6 +43,7 @@ pub mod bootstrap;
pub mod channels; pub mod channels;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod pairing;
pub mod context; pub mod context;
pub mod error; pub mod error;
pub mod estimation; pub mod estimation;
+14 -2
View File
@@ -7,6 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
use ironclaw::{ use ironclaw::{
agent::{Agent, AgentDeps, SessionManager}, agent::{Agent, AgentDeps, SessionManager},
pairing::PairingStore,
channels::{ channels::{
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
WebhookServerConfig, WebhookServerConfig,
@@ -17,7 +18,8 @@ use ironclaw::{
web::log_layer::{LogBroadcaster, WebLogLayer}, web::log_layer::{LogBroadcaster, WebLogLayer},
}, },
cli::{ cli::{
Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command, Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command,
run_tool_command,
}, },
config::Config, config::Config,
context::ContextManager, context::ContextManager,
@@ -128,6 +130,15 @@ async fn main() -> anyhow::Result<()> {
return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await; return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await;
} }
Some(Command::Pairing(pairing_cmd)) => {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e));
}
Some(Command::Status) => { Some(Command::Status) => {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
tracing_subscriber::fmt() tracing_subscriber::fmt()
@@ -725,7 +736,8 @@ async fn main() -> anyhow::Result<()> {
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(runtime) => { Ok(runtime) => {
let runtime = Arc::new(runtime); let runtime = Arc::new(runtime);
let loader = WasmChannelLoader::new(Arc::clone(&runtime)); let pairing_store = Arc::new(PairingStore::new());
let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store);
match loader match loader
.load_from_dir(&config.channels.wasm_channels_dir) .load_from_dir(&config.channels.wasm_channels_dir)
+10
View File
@@ -0,0 +1,10 @@
//! DM pairing for channels.
//!
//! Gates DMs from unknown senders. Only approved senders can message the agent.
//! Unknown senders receive a pairing code and must be approved via `ironclaw pairing approve`.
//!
//! OpenClaw reference: src/pairing/pairing-store.ts
mod store;
pub use store::{PairingRequest, PairingStore, PairingStoreError};
+669
View File
@@ -0,0 +1,669 @@
//! Pairing store: pending requests and allowFrom list.
//!
//! Stored in ~/.ironclaw/{channel}-pairing.json and {channel}-allowFrom.json.
use std::collections::HashSet;
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::FileExt;
use rand::Rng;
use serde::{Deserialize, Serialize};
const PAIRING_CODE_LENGTH: usize = 8;
const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/// TTL for pending pairing requests (minutes, not hours — reduces brute-force window).
const PAIRING_PENDING_TTL_SECS: u64 = 15 * 60;
const PAIRING_PENDING_MAX: usize = 3;
/// Max failed approve attempts per channel before rate limit kicks in.
const PAIRING_APPROVE_RATE_LIMIT: usize = 10;
/// Time window for rate limit (seconds).
const PAIRING_APPROVE_RATE_WINDOW_SECS: u64 = 5 * 60;
/// Error from pairing store operations.
#[derive(Debug, thiserror::Error)]
pub enum PairingStoreError {
#[error("Invalid channel: {0}")]
InvalidChannel(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Rate limit: too many failed approve attempts; try again later")]
ApproveRateLimited,
}
/// Result of upserting a pairing request.
#[derive(Debug)]
pub struct UpsertResult {
pub code: String,
pub created: bool,
}
/// A pending pairing request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairingRequest {
pub id: String,
pub code: String,
pub created_at: String,
pub last_seen_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PairingStoreFile {
version: u8,
requests: Vec<PairingRequest>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AllowFromStoreFile {
version: u8,
#[serde(rename = "allowFrom")]
allow_from: Vec<String>,
}
fn default_pairing_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
}
fn safe_channel_key(channel: &str) -> Result<String, PairingStoreError> {
let raw = channel.trim().to_lowercase();
if raw.is_empty() {
return Err(PairingStoreError::InvalidChannel("empty".to_string()));
}
let safe = raw
.chars()
.map(|c| match c {
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect::<String>()
.replace("..", "_");
if safe.is_empty() || safe == "_" {
return Err(PairingStoreError::InvalidChannel(channel.to_string()));
}
Ok(safe)
}
fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-pairing.json", key)))
}
fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-allowFrom.json", key)))
}
fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-approve-attempts.json", key)))
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ApproveAttemptsFile {
failed_at: Vec<u64>,
}
fn now_iso() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
#[allow(clippy::cast_possible_wrap)]
chrono::DateTime::from_timestamp(now.as_secs() as i64, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| now.as_secs().to_string())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn parse_timestamp(value: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.timestamp() as u64)
.or_else(|| value.parse::<u64>().ok())
}
fn is_expired(req: &PairingRequest, now_secs: u64) -> bool {
let created = parse_timestamp(&req.created_at).unwrap_or(0);
now_secs.saturating_sub(created) > PAIRING_PENDING_TTL_SECS
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..PAIRING_CODE_LENGTH)
.map(|_| {
let idx = rng.gen_range(0..PAIRING_ALPHABET.len());
PAIRING_ALPHABET[idx] as char
})
.collect()
}
fn generate_unique_code(existing: &HashSet<String>) -> String {
let mut rng = rand::thread_rng();
for _ in 0..500 {
let code = random_code();
if !existing.contains(&code) {
return code;
}
}
// Fallback: add suffix
format!("{}{:04}", random_code(), rng.gen_range(0..10000))
}
/// Pairing store for a channel.
#[derive(Debug, Clone)]
pub struct PairingStore {
base_dir: PathBuf,
}
impl PairingStore {
/// Create a new pairing store using default directory (~/.ironclaw).
pub fn new() -> Self {
Self {
base_dir: default_pairing_dir(),
}
}
/// Create a pairing store with a custom base directory (for testing).
pub fn with_base_dir(base_dir: PathBuf) -> Self {
Self { base_dir }
}
/// List pending pairing requests for a channel.
pub fn list_pending(&self, channel: &str) -> Result<Vec<PairingRequest>, PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(e.into()),
};
let file: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now = now_secs();
let original_len = file.requests.len();
let mut requests: Vec<_> = file
.requests
.into_iter()
.filter(|r| !is_expired(r, now))
.collect();
if requests.len() != original_len {
self.write_pairing_file(channel, &requests)?;
}
requests.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(requests)
}
/// Upsert a pairing request. Returns (code, created).
pub fn upsert_request(
&self,
channel: &str,
id: &str,
meta: Option<serde_json::Value>,
) -> Result<UpsertResult, PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now = now_iso();
let now_secs = now_secs();
let id = id.trim().to_string();
if id.is_empty() {
fs4::FileExt::unlock(&file)?;
return Err(PairingStoreError::InvalidChannel("empty id".to_string()));
}
store.requests.retain(|r| !is_expired(r, now_secs));
let existing_codes: HashSet<String> = store
.requests
.iter()
.map(|r| r.code.to_uppercase())
.collect();
if let Some(idx) = store.requests.iter().position(|r| r.id == id) {
let req = &mut store.requests[idx];
let code = if req.code.is_empty() {
generate_unique_code(&existing_codes)
} else {
req.code.clone()
};
req.last_seen_at = now.clone();
req.code = code.clone();
if let Some(m) = meta {
req.meta = Some(m);
}
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
return Ok(UpsertResult {
code,
created: false,
});
}
if store.requests.len() >= PAIRING_PENDING_MAX {
fs4::FileExt::unlock(&file)?;
return Ok(UpsertResult {
code: String::new(),
created: false,
});
}
let code = generate_unique_code(&existing_codes);
store.requests.push(PairingRequest {
id: id.clone(),
code: code.clone(),
created_at: now.clone(),
last_seen_at: now,
meta,
});
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
Ok(UpsertResult { code, created: true })
}
fn is_approve_rate_limited(&self, channel: &str) -> Result<bool, PairingStoreError> {
let path = approve_attempts_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
};
let mut data: ApproveAttemptsFile =
serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
data.failed_at.retain(|&t| t >= cutoff);
Ok(data.failed_at.len() >= PAIRING_APPROVE_RATE_LIMIT)
}
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
let path = approve_attempts_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut data: ApproveAttemptsFile =
serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
data.failed_at.push(now);
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
data.failed_at.retain(|&t| t >= cutoff);
let json = serde_json::to_string_pretty(&data)?;
fs::write(&path, json)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
/// Approve a pairing code and add the sender to allowFrom.
pub fn approve(
&self,
channel: &str,
code: &str,
) -> Result<Option<PairingRequest>, PairingStoreError> {
let code = code.trim().to_uppercase();
if code.is_empty() {
return Ok(None);
}
if self.is_approve_rate_limited(channel)? {
return Err(PairingStoreError::ApproveRateLimited);
}
let path = pairing_path(&self.base_dir, channel)?;
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.open(&path)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
PairingStoreError::InvalidChannel("no pairing file".to_string())
} else {
PairingStoreError::Io(e)
}
})?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now_secs = now_secs();
store.requests.retain(|r| !is_expired(r, now_secs));
let idx = store
.requests
.iter()
.position(|r| r.code.to_uppercase() == code);
let entry = match idx {
Some(i) => store.requests.remove(i),
None => {
fs4::FileExt::unlock(&file)?;
self.record_failed_approve(channel)?;
return Ok(None);
}
};
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
self.add_allow_from(channel, &entry.id)?;
Ok(Some(entry))
}
/// Read the allowFrom list for a channel.
pub fn read_allow_from(&self, channel: &str) -> Result<Vec<String>, PairingStoreError> {
let path = allow_from_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(e.into()),
};
let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
Ok(file.allow_from)
}
/// Check if a sender is allowed (by id or username).
pub fn is_sender_allowed(
&self,
channel: &str,
id: &str,
username: Option<&str>,
) -> Result<bool, PairingStoreError> {
let allow = self.read_allow_from(channel)?;
let id = id.trim();
let id_ok = allow.iter().any(|e| e.trim() == id);
if id_ok {
return Ok(true);
}
if let Some(u) = username {
let u = u.trim().to_lowercase();
let u_norm = u.strip_prefix('@').unwrap_or(&u);
if allow
.iter()
.any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm))
{
return Ok(true);
}
}
Ok(false)
}
fn add_allow_from(&self, channel: &str, entry: &str) -> Result<(), PairingStoreError> {
let entry = entry.trim().to_string();
if entry.is_empty() {
return Ok(());
}
let path = allow_from_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: AllowFromStoreFile =
serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
let normalized = entry.to_lowercase();
if store
.allow_from
.iter()
.any(|e| e.to_lowercase() == normalized)
{
fs4::FileExt::unlock(&file)?;
return Ok(());
}
store.allow_from.push(entry);
let json = serde_json::to_string_pretty(&store)?;
fs::write(&path, json)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
fn write_pairing_file(
&self,
channel: &str,
requests: &[PairingRequest],
) -> Result<(), PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
self.write_pairing_file_locked(&mut file, channel, requests)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
fn write_pairing_file_locked(
&self,
file: &mut fs::File,
_channel: &str,
requests: &[PairingRequest],
) -> Result<(), PairingStoreError> {
let store = PairingStoreFile {
version: 1,
requests: requests.to_vec(),
};
let json = serde_json::to_string_pretty(&store)?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
Ok(())
}
}
impl Default for PairingStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_safe_channel_key() {
assert_eq!(safe_channel_key("telegram").unwrap(), "telegram");
assert_eq!(safe_channel_key("Telegram").unwrap(), "telegram");
safe_channel_key("").unwrap_err();
}
#[test]
fn test_random_code() {
let c = random_code();
assert_eq!(c.len(), PAIRING_CODE_LENGTH);
assert!(c.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
}
fn test_store() -> (PairingStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
(store, dir)
}
#[test]
fn test_list_pending_empty() {
let (store, _) = test_store();
let requests = store.list_pending("telegram").unwrap();
assert!(requests.is_empty());
}
#[test]
fn test_upsert_request_creates_new() {
let (store, _) = test_store();
let result = store
.upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456})))
.unwrap();
assert!(result.created);
assert_eq!(result.code.len(), PAIRING_CODE_LENGTH);
assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
}
#[test]
fn test_upsert_request_updates_existing() {
let (store, _) = test_store();
let r1 = store.upsert_request("telegram", "user123", None).unwrap();
assert!(r1.created);
let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap();
assert!(!r2.created);
assert_eq!(r1.code, r2.code);
let pending = store.list_pending("telegram").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "user123");
assert_eq!(pending[0].meta, Some(serde_json::json!({"x": 1})));
}
#[test]
fn test_approve_adds_to_allow_from() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user456", None).unwrap();
assert!(r.created);
let approved = store.approve("telegram", &r.code).unwrap();
assert!(approved.is_some());
assert_eq!(approved.unwrap().id, "user456");
let allow = store.read_allow_from("telegram").unwrap();
assert_eq!(allow, vec!["user456"]);
}
#[test]
fn test_approve_case_insensitive_code() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user789", None).unwrap();
let code_lower = r.code.to_lowercase();
let approved = store.approve("telegram", &code_lower).unwrap();
assert!(approved.is_some());
}
#[test]
fn test_approve_invalid_code_returns_none() {
let (store, _) = test_store();
store.upsert_request("telegram", "user123", None).unwrap();
let approved = store.approve("telegram", "BADCODE1").unwrap();
assert!(approved.is_none());
}
#[test]
fn test_approve_rate_limited_after_many_failures() {
let (store, _) = test_store();
store.upsert_request("telegram", "user123", None).unwrap();
for _ in 0..PAIRING_APPROVE_RATE_LIMIT {
let _ = store.approve("telegram", "WRONG01");
}
let err = store.approve("telegram", "WRONG02").unwrap_err();
assert!(matches!(err, PairingStoreError::ApproveRateLimited));
}
#[test]
fn test_is_sender_allowed_by_id() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user999", None).unwrap();
store.approve("telegram", &r.code).unwrap();
assert!(store.is_sender_allowed("telegram", "user999", None).unwrap());
assert!(!store.is_sender_allowed("telegram", "other", None).unwrap());
}
#[test]
fn test_is_sender_allowed_by_username() {
let (store, _) = test_store();
store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap();
let pending = store.list_pending("telegram").unwrap();
store.approve("telegram", &pending[0].code).unwrap();
// approve adds id to allow_from. For username we need to add it manually.
// Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work.
assert!(store.is_sender_allowed("telegram", "alice", None).unwrap());
assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap());
}
#[test]
fn test_channel_normalization() {
let (store, _) = test_store();
store.upsert_request("Telegram", "u1", None).unwrap();
let pending = store.list_pending("telegram").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "u1");
}
#[test]
fn test_invalid_channel_rejected() {
let (store, _) = test_store();
store.upsert_request("telegram", "u1", None).unwrap();
store.list_pending("").unwrap_err();
store.upsert_request("", "u1", None).unwrap_err();
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Integration tests for the DM pairing flow.
//!
//! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed.
//! Uses temp directory for isolation.
use ironclaw::cli::{run_pairing_command_with_store, PairingCommand};
use ironclaw::pairing::PairingStore;
use tempfile::TempDir;
fn test_store() -> (PairingStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
(store, dir)
}
#[test]
fn test_pairing_flow_unknown_user_to_approved() {
let (store, _) = test_store();
let channel = "telegram";
// 1. Unknown user sends first message -> upsert creates request
let r1 = store.upsert_request(channel, "user_12345", Some(serde_json::json!({
"chat_id": 999,
"username": "alice"
}))).unwrap();
assert!(r1.created);
assert!(!r1.code.is_empty());
assert_eq!(r1.code.len(), 8);
// 2. List pending shows the request
let pending = store.list_pending(channel).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "user_12345");
assert_eq!(pending[0].code, r1.code);
// 3. User is not allowed yet
assert!(!store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap());
// 4. Approve via code
let approved = store.approve(channel, &r1.code).unwrap();
assert!(approved.is_some());
assert_eq!(approved.unwrap().id, "user_12345");
// 5. User is now allowed
assert!(store.is_sender_allowed(channel, "user_12345", None).unwrap());
assert!(store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap());
// 6. Pending list is empty
let pending_after = store.list_pending(channel).unwrap();
assert!(pending_after.is_empty());
// 7. allowFrom contains the user
let allow = store.read_allow_from(channel).unwrap();
assert_eq!(allow, vec!["user_12345"]);
}
#[test]
fn test_pairing_flow_cli_approve() {
let (store, _) = test_store();
store.upsert_request("telegram", "user_999", None).unwrap();
let pending = store.list_pending("telegram").unwrap();
let code = pending[0].code.clone();
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code,
},
);
assert!(result.is_ok());
assert!(store.is_sender_allowed("telegram", "user_999", None).unwrap());
}
#[test]
fn test_pairing_reject_invalid_code() {
let (store, _) = test_store();
store.upsert_request("telegram", "user_1", None).unwrap();
let result = store.approve("telegram", "INVALID1");
assert!(result.unwrap().is_none());
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code: "BADCODE1".to_string(),
},
);
assert!(result.is_err());
}
#[test]
fn test_pairing_multiple_channels_isolated() {
let (store, _) = test_store();
let r_telegram = store.upsert_request("telegram", "user_a", None).unwrap();
let r_slack = store.upsert_request("slack", "user_b", None).unwrap();
// Each channel has its own pending
assert_eq!(store.list_pending("telegram").unwrap().len(), 1);
assert_eq!(store.list_pending("slack").unwrap().len(), 1);
// Approve in one channel doesn't affect the other
store.approve("telegram", &r_telegram.code).unwrap();
assert!(store.is_sender_allowed("telegram", "user_a", None).unwrap());
assert!(!store.is_sender_allowed("slack", "user_a", None).unwrap());
store.approve("slack", &r_slack.code).unwrap();
assert!(store.is_sender_allowed("slack", "user_b", None).unwrap());
}
+8 -1
View File
@@ -10,6 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use ironclaw::channels::Channel; use ironclaw::channels::Channel;
use ironclaw::pairing::PairingStore;
use ironclaw::channels::wasm::{ use ironclaw::channels::wasm::{
ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint,
WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
@@ -38,7 +39,13 @@ fn create_test_channel(
capabilities = capabilities.with_path(path.to_string()); capabilities = capabilities.with_path(path.to_string());
} }
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) WasmChannel::new(
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
)
} }
mod router_tests { mod router_tests {
+26
View File
@@ -147,6 +147,32 @@ interface channel-host {
/// - Path validation fails (traversal attempt, absolute path) /// - Path validation fails (traversal attempt, absolute path)
/// - Write operation fails /// - Write operation fails
workspace-write: func(path: string, content: string) -> result<_, string>; workspace-write: func(path: string, content: string) -> result<_, string>;
// ==================== DM Pairing ====================
/// Result of upserting a pairing request.
record pairing-upsert-result {
code: string,
created: bool,
}
/// Upsert a pairing request for an unknown sender.
/// Returns (code, created). When created is true, the channel should send a pairing reply.
pairing-upsert-request: func(
channel: string,
id: string,
meta-json: string
) -> result<pairing-upsert-result, string>;
/// Check if a sender is allowed (in allowFrom store).
pairing-is-allowed: func(
channel: string,
id: string,
username: option<string>
) -> result<bool, string>;
/// Read the allowFrom list (for merging with config allowFrom).
pairing-read-allow-from: func(channel: string) -> result<list<string>, string>;
} }
/// Channel interface that sandboxed channels must implement. /// Channel interface that sandboxed channels must implement.