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:
parent
0e5215e1ef
commit
7ca71f500a
2 changed files with 310 additions and 0 deletions
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue