diff --git a/server/src/chat/transport/slack.rs b/server/src/chat/transport/slack.rs index b2c9de86..bf8ff03c 100644 --- a/server/src/chat/transport/slack.rs +++ b/server/src/chat/transport/slack.rs @@ -939,6 +939,32 @@ async fn handle_incoming_message( return; } + if let Some(assign_cmd) = crate::chat::transport::matrix::assign::extract_assign_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match assign_cmd { + crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => { + slog!("[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}"); + crate::chat::transport::matrix::assign::handle_assign( + &ctx.bot_name, + &story_number, + &model, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::assign::AssignCommand::BadArgs => { + format!("Usage: `{} assign `", ctx.bot_name) + } + }; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + // No command matched — forward to LLM for conversational response. slog!("[slack] No command matched, forwarding to LLM for {user} in {channel}"); handle_llm_message(ctx, channel, user, message).await; @@ -1899,4 +1925,61 @@ mod tests { ); assert!(result.is_none(), "'help' should not be recognised as start"); } + + // ── assign command extraction ────────────────────────────────────── + + #[test] + fn assign_command_extracted_from_plain_message_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42 opus", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "plain 'assign 42 opus' should be recognised on Slack" + ); + } + + #[test] + fn assign_command_extracted_with_bot_name_prefix_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "Timmy assign 42 sonnet", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "'Timmy assign 42 sonnet' should be recognised on Slack" + ); + } + + #[test] + fn assign_command_returns_bad_args_without_model_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42", + "Timmy", + "@timmy:home.local", + ); + assert_eq!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::BadArgs) + ); + } + + #[test] + fn non_assign_slack_message_not_extracted() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "status", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_none(), "'status' should not be recognised as assign on Slack"); + } } diff --git a/server/src/chat/transport/whatsapp/commands.rs b/server/src/chat/transport/whatsapp/commands.rs index d892e1c3..d2e419f0 100644 --- a/server/src/chat/transport/whatsapp/commands.rs +++ b/server/src/chat/transport/whatsapp/commands.rs @@ -220,6 +220,32 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender return; } + if let Some(assign_cmd) = crate::chat::transport::matrix::assign::extract_assign_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match assign_cmd { + crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => { + slog!("[whatsapp] Handling assign command from {sender}: story {story_number} model {model}"); + crate::chat::transport::matrix::assign::handle_assign( + &ctx.bot_name, + &story_number, + &model, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::assign::AssignCommand::BadArgs => { + format!("Usage: `{} assign `", ctx.bot_name) + } + }; + let formatted = markdown_to_whatsapp(&response); + let _ = ctx.transport.send_message(sender, &formatted, "").await; + return; + } + // No command matched — forward to LLM for conversational response. slog!("[whatsapp] No command matched, forwarding to LLM for {sender}"); handle_llm_message(ctx, sender, message).await; @@ -707,4 +733,61 @@ mod tests { ); assert!(result.is_none(), "'status' should not be recognised as rmtree"); } + + // ── assign command extraction ────────────────────────────────────── + + #[test] + fn assign_command_extracted_from_plain_message() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42 opus", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "plain 'assign 42 opus' should be recognised" + ); + } + + #[test] + fn assign_command_extracted_with_bot_name_prefix() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "Timmy assign 42 sonnet", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "'Timmy assign 42 sonnet' should be recognised" + ); + } + + #[test] + fn assign_command_returns_bad_args_without_model() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42", + "Timmy", + "@timmy:home.local", + ); + assert_eq!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::BadArgs) + ); + } + + #[test] + fn non_assign_whatsapp_message_not_extracted() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "status", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_none(), "'status' should not be recognised as assign"); + } }