From 5416866bcfd8baa87d948004af5051ba4d4545f2 Mon Sep 17 00:00:00 2001 From: LikunY <53739955+LikunYDev@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:32:14 +0800 Subject: [PATCH] fix: Telegram control commands being stripped (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Telegram control commands being stripped The `clean_message_text()` function was returning an empty string for bare slash commands like `/interrupt`, `/stop`, `/help`, etc. This caused the commands to be replaced with "[User started the bot]" placeholder which broke command parsing in the agent. Changes: - Line 1079: Return the command unchanged instead of empty string - Line 1042: Only replace with placeholder for `/start` specifically - Add test coverage for control commands This fixes the issue where `/interrupt` doesn't work when bot is stuck waiting for approval. Co-Authored-By: Claude Sonnet 4.5 * Add workspace declaration to Telegram package Fixes workspace conflict when building WASM component standalone. * Fix content_to_emit logic for bare control commands Addresses code review feedback: keep clean_message_text() returning empty for bare commands (its job is to extract user text, not pass commands through). Instead, fix the caller to distinguish: - /start (no args) → welcome placeholder - Other bare /commands → pass raw command to Submission::parse() - Commands with args → pass cleaned args - Empty/whitespace → skip Add comprehensive test_content_to_emit_logic() covering all edge cases including /start, control commands, args, plain text, and empty input. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: ubuntu Co-authored-by: Claude Sonnet 4.5 Co-authored-by: firat.sertgoz Co-authored-by: Illia Polosukhin --- channels-src/telegram/Cargo.toml | 3 ++ channels-src/telegram/src/lib.rs | 84 +++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml index 855aa8fa..1964e327 100644 --- a/channels-src/telegram/Cargo.toml +++ b/channels-src/telegram/Cargo.toml @@ -16,6 +16,9 @@ wit-bindgen = "0.36" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +# Exclude from parent workspace (this is a standalone WASM component) +[workspace] + [profile.release] # Optimize for size opt-level = "s" diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index a7f7f5cb..5c2f91af 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -1038,9 +1038,18 @@ fn handle_message(message: TelegramMessage) { }, ); - // 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('/') { + // Determine what to emit to the agent. + // - `/start` (no args): emit a welcome placeholder so the agent greets the user + // - Other bare `/commands` (e.g. /interrupt, /help): pass the raw command through + // so Submission::parse() can handle it + // - Commands with args (e.g. `/start hello`): cleaned_text already has the args + // - Plain text: pass through as-is + let trimmed_content = content.trim(); + let content_to_emit = if trimmed_content.eq_ignore_ascii_case("/start") { "[User started the bot]".to_string() + } else if cleaned_text.is_empty() && trimmed_content.starts_with('/') { + // Bare control command like /interrupt, /stop, /help — pass through raw + trimmed_content.to_string() } else if cleaned_text.is_empty() { return; } else { @@ -1159,6 +1168,77 @@ mod tests { assert_eq!(clean_message_text("@MyBot", Some("MyBot")), ""); } + #[test] + fn test_clean_message_text_bare_commands() { + // Bare commands return empty (the caller decides what to emit) + assert_eq!(clean_message_text("/start", None), ""); + assert_eq!(clean_message_text("/interrupt", None), ""); + assert_eq!(clean_message_text("/stop", None), ""); + assert_eq!(clean_message_text("/help", None), ""); + assert_eq!(clean_message_text("/undo", None), ""); + assert_eq!(clean_message_text("/ping", None), ""); + + // Commands with args: command prefix stripped, args returned + assert_eq!(clean_message_text("/start hello", None), "hello"); + assert_eq!(clean_message_text("/help me please", None), "me please"); + assert_eq!(clean_message_text("/model claude-opus-4-6", None), "claude-opus-4-6"); + } + + /// Tests for the content_to_emit logic in handle_message. + /// Since handle_message uses WASM host calls, we test the decision logic inline. + #[test] + fn test_content_to_emit_logic() { + // Simulates the content_to_emit decision for various inputs. + // This mirrors the logic in handle_message after clean_message_text. + fn resolve_content(content: &str) -> Option { + let cleaned_text = clean_message_text(content, None); + let trimmed_content = content.trim(); + if trimmed_content.eq_ignore_ascii_case("/start") { + Some("[User started the bot]".to_string()) + } else if cleaned_text.is_empty() && trimmed_content.starts_with('/') { + Some(trimmed_content.to_string()) + } else if cleaned_text.is_empty() { + None // would return/skip in handle_message + } else { + Some(cleaned_text) + } + } + + // /start → welcome placeholder + assert_eq!(resolve_content("/start"), Some("[User started the bot]".to_string())); + assert_eq!(resolve_content("/Start"), Some("[User started the bot]".to_string())); + assert_eq!(resolve_content(" /start "), Some("[User started the bot]".to_string())); + + // /start with args → pass args through + assert_eq!(resolve_content("/start hello"), Some("hello".to_string())); + + // Control commands → pass through raw so Submission::parse() can match + assert_eq!(resolve_content("/interrupt"), Some("/interrupt".to_string())); + assert_eq!(resolve_content("/stop"), Some("/stop".to_string())); + assert_eq!(resolve_content("/help"), Some("/help".to_string())); + assert_eq!(resolve_content("/undo"), Some("/undo".to_string())); + assert_eq!(resolve_content("/redo"), Some("/redo".to_string())); + assert_eq!(resolve_content("/ping"), Some("/ping".to_string())); + assert_eq!(resolve_content("/tools"), Some("/tools".to_string())); + assert_eq!(resolve_content("/compact"), Some("/compact".to_string())); + assert_eq!(resolve_content("/clear"), Some("/clear".to_string())); + assert_eq!(resolve_content("/version"), Some("/version".to_string())); + + // Commands with args → cleaned text (command stripped) + assert_eq!(resolve_content("/help me please"), Some("me please".to_string())); + + // Plain text → pass through + assert_eq!(resolve_content("hello world"), Some("hello world".to_string())); + assert_eq!(resolve_content("just text"), Some("just text".to_string())); + + // Empty / whitespace → skip (None) + assert_eq!(resolve_content(""), None); + assert_eq!(resolve_content(" "), None); + + // Bare @mention without bot → skip + assert_eq!(resolve_content("@botname"), None); + } + #[test] fn test_config_with_owner_id() { let json = r#"{"owner_id": 123456789}"#;