diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 127e2f5..bf27247 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -82,6 +82,10 @@ enum ChannelRuntimeCommand { SetProvider(String), ShowModel, SetModel(String), + Clear, + ShowSystem, + ShowStatus, + Help, } #[derive(Debug, Clone, Default, Deserialize)] @@ -177,6 +181,10 @@ fn parse_runtime_command(channel_name: &str, content: &str) -> Option Some(ChannelRuntimeCommand::Clear), + "/system" => Some(ChannelRuntimeCommand::ShowSystem), + "/status" => Some(ChannelRuntimeCommand::ShowStatus), + "/help" => Some(ChannelRuntimeCommand::Help), _ => None, } } @@ -423,6 +431,52 @@ async fn handle_runtime_command_if_needed( ) } } + ChannelRuntimeCommand::Clear => { + clear_sender_history(ctx, &sender_key); + "Conversation history cleared.".to_string() + } + ChannelRuntimeCommand::ShowSystem => { + let prompt = ctx.system_prompt.as_str(); + if prompt.is_empty() { + "No system prompt configured.".to_string() + } else { + let truncated = truncate_with_ellipsis(prompt, 2000); + format!("```\n{truncated}\n```") + } + } + ChannelRuntimeCommand::ShowStatus => { + let tool_names: Vec<&str> = ctx.tools_registry.iter().map(|t| t.name()).collect(); + let mut status = String::new(); + let _ = writeln!(status, "Provider: `{}`", current.provider); + let _ = writeln!(status, "Model: `{}`", current.model); + let _ = writeln!(status, "Temperature: `{}`", ctx.temperature); + let _ = writeln!( + status, + "Tools: {} ({})", + tool_names.len(), + tool_names.join(", ") + ); + let _ = writeln!(status, "Memory: `{}`", ctx.memory.name()); + let _ = writeln!(status, "Max tool iterations: `{}`", ctx.max_tool_iterations); + let _ = writeln!( + status, + "Message timeout: `{}s`", + ctx.channel_message_timeout_secs + ); + status + } + ChannelRuntimeCommand::Help => { + let mut help = String::new(); + help.push_str("/help \u{2014} Show available commands\n"); + help.push_str("/model \u{2014} Show current model\n"); + help.push_str("/model \u{2014} Switch model\n"); + help.push_str("/models \u{2014} List providers\n"); + help.push_str("/models \u{2014} Switch provider\n"); + help.push_str("/clear \u{2014} Clear conversation history\n"); + help.push_str("/system \u{2014} Show system prompt\n"); + help.push_str("/status \u{2014} Show current configuration\n"); + help + } }; if let Err(err) = channel @@ -3451,4 +3505,228 @@ mod tests { .contains("listen boom")); assert!(calls.load(Ordering::SeqCst) >= 1); } + + // ── Runtime command parsing tests ───────────────────────── + + #[test] + fn parse_runtime_command_clear() { + assert_eq!( + parse_runtime_command("telegram", "/clear"), + Some(ChannelRuntimeCommand::Clear) + ); + } + + #[test] + fn parse_runtime_command_system() { + assert_eq!( + parse_runtime_command("telegram", "/system"), + Some(ChannelRuntimeCommand::ShowSystem) + ); + } + + #[test] + fn parse_runtime_command_status() { + assert_eq!( + parse_runtime_command("telegram", "/status"), + Some(ChannelRuntimeCommand::ShowStatus) + ); + } + + #[test] + fn parse_runtime_command_help() { + assert_eq!( + parse_runtime_command("telegram", "/help"), + Some(ChannelRuntimeCommand::Help) + ); + } + + #[test] + fn parse_runtime_command_ignores_unsupported_channel() { + assert_eq!(parse_runtime_command("cli", "/clear"), None); + assert_eq!(parse_runtime_command("slack", "/help"), None); + } + + #[test] + fn parse_runtime_command_strips_bot_mention_suffix() { + assert_eq!( + parse_runtime_command("telegram", "/clear@MyBot"), + Some(ChannelRuntimeCommand::Clear) + ); + assert_eq!( + parse_runtime_command("telegram", "/help@SomeBot extra"), + Some(ChannelRuntimeCommand::Help) + ); + } + + #[tokio::test] + async fn handle_runtime_command_clear_clears_history() { + let channel_impl = Arc::new(TelegramRecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel.clone()); + + let histories: ConversationHistoryMap = Arc::new(Mutex::new(HashMap::new())); + histories + .lock() + .unwrap() + .insert("telegram_alice".to_string(), vec![]); + + let ctx = ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(SlowProvider { + delay: Duration::from_millis(1), + }), + default_provider: Arc::new("test".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.7, + auto_save_memory: false, + max_tool_iterations: 5, + min_relevance_score: 0.0, + conversation_histories: histories.clone(), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + channel_message_timeout_secs: 300, + }; + + let msg = traits::ChannelMessage { + id: "msg-1".into(), + sender: "alice".into(), + reply_target: "chat-1".into(), + content: "/clear".into(), + channel: "telegram".into(), + timestamp: 1, + }; + + let handled = handle_runtime_command_if_needed(&ctx, &msg, Some(&channel)).await; + assert!(handled); + + assert!(!histories.lock().unwrap().contains_key("telegram_alice")); + + let sent = channel_impl.sent_messages.lock().await; + assert_eq!(sent.len(), 1); + assert!(sent[0].contains("Conversation history cleared.")); + } + + #[tokio::test] + async fn handle_runtime_command_help_lists_all_commands() { + let channel_impl = Arc::new(TelegramRecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel.clone()); + + let ctx = ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(SlowProvider { + delay: Duration::from_millis(1), + }), + default_provider: Arc::new("test".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.7, + auto_save_memory: false, + max_tool_iterations: 5, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + channel_message_timeout_secs: 300, + }; + + let msg = traits::ChannelMessage { + id: "msg-1".into(), + sender: "alice".into(), + reply_target: "chat-1".into(), + content: "/help".into(), + channel: "telegram".into(), + timestamp: 1, + }; + + handle_runtime_command_if_needed(&ctx, &msg, Some(&channel)).await; + + let sent = channel_impl.sent_messages.lock().await; + assert_eq!(sent.len(), 1); + let response = &sent[0]; + assert!(response.contains("/help")); + assert!(response.contains("/model")); + assert!(response.contains("/models")); + assert!(response.contains("/clear")); + assert!(response.contains("/system")); + assert!(response.contains("/status")); + } + + #[tokio::test] + async fn handle_runtime_command_status_shows_config_fields() { + let channel_impl = Arc::new(TelegramRecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel.clone()); + + let ctx = ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(SlowProvider { + delay: Duration::from_millis(1), + }), + default_provider: Arc::new("openai".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![Box::new(MockPriceTool) as Box]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test".to_string()), + model: Arc::new("gpt-4".to_string()), + temperature: 0.5, + auto_save_memory: false, + max_tool_iterations: 10, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + channel_message_timeout_secs: 120, + }; + + let msg = traits::ChannelMessage { + id: "msg-1".into(), + sender: "alice".into(), + reply_target: "chat-1".into(), + content: "/status".into(), + channel: "telegram".into(), + timestamp: 1, + }; + + handle_runtime_command_if_needed(&ctx, &msg, Some(&channel)).await; + + let sent = channel_impl.sent_messages.lock().await; + assert_eq!(sent.len(), 1); + let response = &sent[0]; + assert!(response.contains("openai")); + assert!(response.contains("gpt-4")); + assert!(response.contains("0.5")); + assert!(response.contains("mock_price")); + assert!(response.contains("noop")); + assert!(response.contains("10")); + assert!(response.contains("120s")); + } } diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 0d9aa1f..d9e7725 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -1691,6 +1691,38 @@ impl Channel for TelegramChannel { let _ = self.get_bot_username().await; } + // Register bot slash-command menu with Telegram + let commands_body = serde_json::json!({ + "commands": [ + {"command": "help", "description": "Show available commands"}, + {"command": "model", "description": "Show or switch model"}, + {"command": "models", "description": "List or switch providers"}, + {"command": "clear", "description": "Clear conversation history"}, + {"command": "system", "description": "Show system prompt"}, + {"command": "status", "description": "Show current configuration"} + ] + }); + match self + .http_client() + .post(self.api_url("setMyCommands")) + .json(&commands_body) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + tracing::debug!("Telegram setMyCommands registered successfully"); + } + Ok(resp) => { + tracing::debug!( + "Telegram setMyCommands failed with status {}", + resp.status() + ); + } + Err(e) => { + tracing::debug!("Telegram setMyCommands request failed: {e}"); + } + } + tracing::info!("Telegram channel listening for messages..."); loop {