mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
@@ -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":[]}}
|
||||
|
||||
Reference in New Issue
Block a user