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
+269 -48
View File
@@ -72,6 +72,10 @@ struct TelegramMessage {
/// Message text.
text: Option<String>,
/// Caption for media (photo, video, document, etc.).
#[serde(default)]
caption: Option<String>,
/// Original message if this is a reply.
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.
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
// ============================================================================
@@ -196,6 +215,14 @@ struct TelegramConfig {
#[serde(default)]
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).
#[serde(default)]
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
// If tunnel is configured, use webhooks. Otherwise, use polling.
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
// ============================================================================
@@ -799,11 +889,16 @@ fn handle_update(update: TelegramUpdate) {
/// Process a single message.
fn handle_message(message: TelegramMessage) {
// Skip messages without text
let text = match message.text {
Some(t) if !t.is_empty() => t,
_ => return,
};
// Use text or caption (for media messages)
let content = message
.text
.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)
let from = match message.from {
@@ -816,41 +911,111 @@ fn handle_message(message: TelegramMessage) {
return;
}
// Owner validation: silently drop messages from non-owner users
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
if !owner_id_str.is_empty() {
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
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;
let is_private = message.chat.chat_type == "private";
// Owner validation: when owner_id is set, only that user can message
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
.map(|s| !s.is_empty())
.unwrap_or(false);
if owner_configured {
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
.unwrap()
.parse::<i64>()
{
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, 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
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
if !is_private {
// In groups, only respond if there's a bot mention or command
// This is a simplified check - proper implementation would use entities
let has_command = text.starts_with('/');
let has_mention = text.contains('@');
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
.as_deref()
.unwrap_or("false")
== "true";
if !has_command && !has_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", text),
);
return;
if !respond_to_all {
let has_command = content.starts_with('/');
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
.unwrap_or_default();
let has_bot_mention = if bot_username.is_empty() {
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());
// 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;
}
} else {
cleaned_text
};
// Emit the message to the agent
channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(),
user_name: Some(user_name),
content: cleaned_text,
content: content_to_emit,
thread_id: None, // Telegram doesn't have threads in the same way
metadata_json,
});
@@ -897,7 +1075,8 @@ fn handle_message(message: TelegramMessage) {
}
/// 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();
// Remove leading /command
@@ -912,11 +1091,30 @@ fn clean_message_text(text: &str) -> String {
// Remove leading @mention
if result.starts_with('@') {
if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
if let Some(bot) = bot_username {
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 {
// Just a mention with no text
return String::new();
// No bot_username: strip any leading @mention
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]
fn test_clean_message_text() {
assert_eq!(clean_message_text("/start hello"), "hello");
assert_eq!(clean_message_text("@bot hello world"), "hello world");
assert_eq!(clean_message_text("/start"), "");
assert_eq!(clean_message_text("@botname"), "");
assert_eq!(clean_message_text("just text"), "just text");
assert_eq!(clean_message_text(" spaced "), "spaced");
// Without bot_username: strips any leading @mention
assert_eq!(clean_message_text("/start hello", None), "hello");
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
assert_eq!(clean_message_text("/start", None), "");
assert_eq!(clean_message_text("@botname", None), "");
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]
@@ -1025,4 +1233,17 @@ mod tests {
assert_eq!(from.id, 789);
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,
"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
}
}
{"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":[]}}