feat(channels): add /clear, /system, /status, /help slash commands + Telegram menu

Add four new runtime commands for Telegram/Discord channels:
- /clear — clears per-sender conversation history
- /system — shows current system prompt (truncated to 2000 chars)
- /status — shows provider, model, temperature, tools, memory, limits
- /help — lists all available slash commands with descriptions

Register commands with Telegram's setMyCommands API on listener startup
so they appear in the bot's "/" autocomplete menu.

Includes 9 new tests covering parsing, bot-mention suffix stripping,
and handler behavior for /clear, /help, and /status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harald 2026-02-25 16:49:24 +01:00
parent 0e5215e1ef
commit 7ca71f500a
2 changed files with 310 additions and 0 deletions

View file

@ -82,6 +82,10 @@ enum ChannelRuntimeCommand {
SetProvider(String), SetProvider(String),
ShowModel, ShowModel,
SetModel(String), SetModel(String),
Clear,
ShowSystem,
ShowStatus,
Help,
} }
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
@ -177,6 +181,10 @@ fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRun
Some(ChannelRuntimeCommand::SetModel(model)) Some(ChannelRuntimeCommand::SetModel(model))
} }
} }
"/clear" => Some(ChannelRuntimeCommand::Clear),
"/system" => Some(ChannelRuntimeCommand::ShowSystem),
"/status" => Some(ChannelRuntimeCommand::ShowStatus),
"/help" => Some(ChannelRuntimeCommand::Help),
_ => None, _ => 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 <id> \u{2014} Switch model\n");
help.push_str("/models \u{2014} List providers\n");
help.push_str("/models <name> \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 if let Err(err) = channel
@ -3451,4 +3505,228 @@ mod tests {
.contains("listen boom")); .contains("listen boom"));
assert!(calls.load(Ordering::SeqCst) >= 1); 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<dyn Channel> = 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<dyn Channel> = 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<dyn Channel> = 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<dyn Tool>]),
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"));
}
} }

View file

@ -1691,6 +1691,38 @@ impl Channel for TelegramChannel {
let _ = self.get_bot_username().await; 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..."); tracing::info!("Telegram channel listening for messages...");
loop { loop {