fix: add --all to cargo fmt in script/test and autoformat codebase

cargo fmt without --all fails with "Failed to find targets" in
workspace repos. This was blocking every story's gates. Also ran
cargo fmt --all to fix all existing formatting issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-13 14:07:08 +00:00
parent ed2526ce41
commit 845b85e7a7
128 changed files with 3566 additions and 2395 deletions
+11 -24
View File
@@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex};
use tokio::sync::{Mutex as TokioMutex, oneshot};
use crate::agents::AgentPool;
use crate::chat::ChatTransport;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::chat::ChatTransport;
use crate::http::context::{PermissionDecision, PermissionForward};
use crate::slog;
@@ -42,8 +42,7 @@ pub struct DiscordContext {
/// Permission requests from the MCP `prompt_permission` tool arrive here.
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
/// Pending permission replies keyed by channel ID.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
/// Seconds before an unanswered permission prompt is auto-denied.
pub permission_timeout_secs: u64,
}
@@ -135,16 +134,13 @@ pub(super) async fn handle_incoming_message(
let total_ticks = (duration_secs as usize) / 2;
for tick in 1..=total_ticks {
tokio::time::sleep(interval).await;
let updated =
crate::chat::transport::matrix::htop::build_htop_message(
&agents,
(tick * 2) as u32,
duration_secs,
);
let updated = crate::chat::transport::matrix::htop::build_htop_message(
&agents,
(tick * 2) as u32,
duration_secs,
);
let updated = markdown_to_discord(&updated);
if let Err(e) =
transport.edit_message(&ch, &msg_id, &updated, "").await
{
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
slog!("[discord] Failed to edit htop message: {e}");
break;
}
@@ -320,12 +316,7 @@ pub(super) async fn handle_incoming_message(
}
/// Forward a message to Claude Code and send the response back via Discord.
async fn handle_llm_message(
ctx: &DiscordContext,
channel: &str,
user: &str,
user_message: &str,
) {
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
use crate::chat::util::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -334,9 +325,7 @@ async fn handle_llm_message(
// Look up existing session ID for this channel.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard
.get(channel)
.and_then(|conv| conv.session_id.clone())
guard.get(channel).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.bot_name;
@@ -446,9 +435,7 @@ async fn handle_llm_message(
let last_text = messages
.iter()
.rev()
.find(|m| {
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
})
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
+10 -25
View File
@@ -150,8 +150,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
.ok_or("Gateway closed before Hello")?
.map_err(|e| format!("Gateway read error: {e}"))?;
let hello_payload: GatewayPayload =
parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
let hello_payload: GatewayPayload = parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
if hello_payload.op != OP_HELLO {
return Err(format!(
@@ -164,8 +163,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
serde_json::from_value(hello_payload.d.ok_or("Hello missing data")?)
.map_err(|e| format!("Failed to parse Hello data: {e}"))?;
let heartbeat_interval =
std::time::Duration::from_millis(hello_data.heartbeat_interval);
let heartbeat_interval = std::time::Duration::from_millis(hello_data.heartbeat_interval);
slog!(
"[discord] Heartbeat interval: {}ms",
hello_data.heartbeat_interval
@@ -258,19 +256,12 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
&& let Ok(ready) = serde_json::from_value::<ReadyData>(d)
{
bot_user_id = Some(ready.user.id.clone());
slog!(
"[discord] READY — bot user ID: {}",
ready.user.id
);
slog!("[discord] READY — bot user ID: {}", ready.user.id);
}
}
"MESSAGE_CREATE" => {
if let Some(d) = payload.d {
dispatch_message(
Arc::clone(&ctx),
d,
bot_user_id.clone(),
);
dispatch_message(Arc::clone(&ctx), d, bot_user_id.clone());
}
}
_ => {}
@@ -355,15 +346,11 @@ fn dispatch_message(
// Check if the bot was mentioned, or if we respond to all messages in
// configured channels (ambient mode).
let bot_mentioned = bot_user_id.as_ref().is_some_and(|bid| {
msg.mentions.iter().any(|m| m.id == *bid)
});
let bot_mentioned = bot_user_id
.as_ref()
.is_some_and(|bid| msg.mentions.iter().any(|m| m.id == *bid));
let in_ambient = ctx
.ambient_rooms
.lock()
.unwrap()
.contains(&msg.channel_id);
let in_ambient = ctx.ambient_rooms.lock().unwrap().contains(&msg.channel_id);
if !bot_mentioned && !in_ambient {
return;
@@ -392,8 +379,7 @@ fn dispatch_message(
msg.channel_id
);
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content)
.await;
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content).await;
});
}
@@ -417,8 +403,7 @@ mod tests {
let json = r#"{"op": 10, "d": {"heartbeat_interval": 41250}}"#;
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.op, OP_HELLO);
let hello: HelloData =
serde_json::from_value(payload.d.unwrap()).unwrap();
let hello: HelloData = serde_json::from_value(payload.d.unwrap()).unwrap();
assert_eq!(hello.heartbeat_interval, 41250);
}
+8 -17
View File
@@ -181,8 +181,7 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
let result = transport
.send_message("123456", "hello", "<p>hello</p>")
@@ -202,8 +201,7 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
let result = transport.send_message("bad", "hello", "").await;
assert!(result.is_err());
@@ -220,8 +218,7 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
let result = transport
.edit_message("123456", "999888777", "updated", "")
@@ -240,12 +237,9 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
let result = transport
.edit_message("123456", "bad", "updated", "")
.await;
let result = transport.edit_message("123456", "bad", "updated", "").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("404"));
}
@@ -259,8 +253,7 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
assert!(transport.send_typing("123456", true).await.is_ok());
}
@@ -281,8 +274,7 @@ mod tests {
.create_async()
.await;
let transport =
DiscordTransport::with_api_base("test-token".to_string(), server.url());
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
let result = transport.send_message("123456", "hello", "").await;
assert!(result.is_err());
@@ -296,7 +288,6 @@ mod tests {
fn assert_transport<T: ChatTransport>() {}
assert_transport::<DiscordTransport>();
let _: Arc<dyn ChatTransport> =
Arc::new(DiscordTransport::new("test-token".to_string()));
let _: Arc<dyn ChatTransport> = Arc::new(DiscordTransport::new("test-token".to_string()));
}
}
+6 -13
View File
@@ -17,10 +17,7 @@ use std::path::Path;
#[derive(Debug, PartialEq)]
pub enum AssignCommand {
/// Assign the story with this number to the given model.
Assign {
story_number: String,
model: String,
},
Assign { story_number: String, model: String },
/// The user typed `assign` but without valid arguments.
BadArgs,
}
@@ -96,9 +93,7 @@ pub async fn handle_assign(
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
@@ -282,11 +277,8 @@ mod tests {
fn extract_assign_command_multibyte_prefix_no_panic() {
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
let cmd = extract_assign_command(
"xxxx\u{23FA} assign 42 opus",
"Timmy",
"@timmy:home.local",
);
let cmd =
extract_assign_command("xxxx\u{23FA} assign 42 opus", "Timmy", "@timmy:home.local");
assert_eq!(cmd, None);
}
@@ -453,7 +445,8 @@ mod tests {
);
// Should indicate a restart occurred (not just "will be used when starts")
assert!(
response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"),
response.to_lowercase().contains("stop")
|| response.to_lowercase().contains("reassign"),
"response should indicate stop/reassign: {response}"
);
}
@@ -1,7 +1,7 @@
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
use crate::agents::AgentPool;
use crate::chat::timer::TimerStore;
use crate::chat::ChatTransport;
use crate::chat::timer::TimerStore;
use crate::http::context::{PermissionDecision, PermissionForward};
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
use std::collections::{HashMap, HashSet};
@@ -104,7 +104,10 @@ mod tests {
#[test]
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online.");
assert_eq!(
format_startup_announcement("Assistant"),
"Assistant is online."
);
}
#[test]
@@ -71,11 +71,7 @@ pub fn load_history(project_root: &std::path::Path) -> HashMap<OwnedRoomId, Room
persisted
.rooms
.into_iter()
.filter_map(|(k, v)| {
k.parse::<OwnedRoomId>()
.ok()
.map(|room_id| (room_id, v))
})
.filter_map(|(k, v)| k.parse::<OwnedRoomId>().ok().map(|room_id| (room_id, v)))
.collect()
}
@@ -97,9 +97,7 @@ pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &s
// Handles both "@localpart" and "@localpart:homeserver" forms.
if let Some(rest) = lower.strip_prefix('@') {
// Extract everything up to the first whitespace character.
let word_end = rest
.find(|c: char| c.is_whitespace())
.unwrap_or(rest.len());
let word_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
// Strip the homeserver part to get just the localpart.
@@ -82,9 +82,7 @@ pub(super) async fn on_room_message(
// Always let "ambient on" through — it is the one command that must work
// even when the bot is not mentioned and ambient mode is off, otherwise
// there is no way to re-enable ambient mode without an @-mention.
let is_ambient_on = body
.to_ascii_lowercase()
.contains("ambient on");
let is_ambient_on = body.to_ascii_lowercase().contains("ambient on");
if !is_addressed && !is_ambient && !is_ambient_on {
slog!(
@@ -97,7 +95,9 @@ pub(super) async fn on_room_message(
// In ambient mode, ignore messages that are explicitly addressed to a
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
// We still let through messages addressed to us and the "ambient on" command.
if is_ambient && !is_addressed && !is_ambient_on
if is_ambient
&& !is_addressed
&& !is_ambient_on
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
{
slog!(
@@ -158,7 +158,10 @@ pub(super) async fn on_room_message(
"Permission denied."
};
let html = markdown_to_html(confirmation);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, confirmation, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, confirmation, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -182,9 +185,14 @@ pub(super) async fn on_room_message(
ambient_rooms: &ctx.ambient_rooms,
room_id: &room_id_str,
};
if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) {
if let Some((response, response_html)) =
super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
{
slog!("[matrix-bot] Handled bot command from {sender}");
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &response_html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -224,7 +232,10 @@ pub(super) async fn on_room_message(
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -272,9 +283,7 @@ pub(super) async fn on_room_message(
) {
let response = match del_cmd {
super::super::delete::DeleteCommand::Delete { story_number } => {
slog!(
"[matrix-bot] Handling delete command from {sender}: story {story_number}"
);
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
super::super::delete::handle_delete(
&ctx.bot_name,
&story_number,
@@ -288,7 +297,10 @@ pub(super) async fn on_room_message(
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -305,9 +317,7 @@ pub(super) async fn on_room_message(
) {
let response = match rmtree_cmd {
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
slog!(
"[matrix-bot] Handling rmtree command from {sender}: story {story_number}"
);
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
super::super::rmtree::handle_rmtree(
&ctx.bot_name,
&story_number,
@@ -321,7 +331,10 @@ pub(super) async fn on_room_message(
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -361,7 +374,10 @@ pub(super) async fn on_room_message(
}
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -387,7 +403,10 @@ pub(super) async fn on_room_message(
)
.await;
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -408,19 +427,22 @@ pub(super) async fn on_room_message(
// Acknowledge immediately — the rebuild may take a while or re-exec.
let ack = "Rebuilding server… this may take a moment.";
let ack_html = markdown_to_html(ack);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, ack, &ack_html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, ack, &ack_html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
let response = super::super::rebuild::handle_rebuild(
&ctx.bot_name,
&ctx.project_root,
&ctx.agents,
)
.await;
let response =
super::super::rebuild::handle_rebuild(&ctx.bot_name, &ctx.project_root, &ctx.agents)
.await;
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -443,7 +465,10 @@ pub(super) async fn on_room_message(
)
.await;
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
@@ -470,9 +495,7 @@ pub(super) async fn handle_message(
// flattening history into a text prefix.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard
.get(&room_id)
.and_then(|conv| conv.session_id.clone())
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
};
// The prompt is just the current message with sender attribution.
@@ -501,7 +524,9 @@ pub(super) async fn handle_message(
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
let html = markdown_to_html(&chunk);
if let Ok(msg_id) = post_transport.send_message(&post_room_id, &chunk, &html).await
if let Ok(msg_id) = post_transport
.send_message(&post_room_id, &chunk, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
sent_ids_for_post.lock().await.insert(event_id);
@@ -631,9 +656,7 @@ pub(super) async fn handle_message(
Err(e) => {
slog!("[matrix-bot] LLM error: {e}");
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. [Click here to log in to Claude]({url})"
)
format!("Authentication required. [Click here to log in to Claude]({url})")
} else {
format!("Error processing your request: {e}")
};
@@ -654,7 +677,11 @@ pub(super) async fn handle_message(
let conv = guard.entry(room_id).or_default();
// Store the session ID so the next turn uses --resume.
slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id);
slog!(
"[matrix-bot] storing session_id: {:?} (was: {:?})",
new_session_id,
conv.session_id
);
if new_session_id.is_some() {
conv.session_id = new_session_id;
}
@@ -713,7 +740,10 @@ mod tests {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
let msg = format!(
"Authentication required. [Click here to log in to Claude]({})",
url.unwrap()
);
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(msg.contains("[Click here to log in to Claude]"));
}
+30 -30
View File
@@ -1,12 +1,12 @@
//! Matrix bot run loop — connects to the homeserver and processes sync events.
use crate::agents::AgentPool;
use crate::slog;
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
use matrix_sdk::ruma::OwnedRoomId;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{mpsc, watch};
@@ -73,7 +73,10 @@ pub async fn run_bot(
.ok_or_else(|| "No user ID after login".to_string())?
.to_owned();
slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id);
slog!(
"[matrix-bot] Logged in as {bot_user_id} (device: {})",
login_response.device_id
);
// Bootstrap cross-signing keys for E2EE verification support.
// Pass the bot's password for UIA (User-Interactive Authentication) —
@@ -81,9 +84,7 @@ pub async fn run_bot(
{
use matrix_sdk::ruma::api::client::uiaa;
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
uiaa::UserIdentifier::UserIdOrLocalpart(
config.username.clone().unwrap_or_default(),
),
uiaa::UserIdentifier::UserIdOrLocalpart(config.username.clone().unwrap_or_default()),
config.password.clone().unwrap_or_default(),
));
if let Err(e) = client
@@ -171,11 +172,7 @@ pub async fn run_bot(
);
// Restore persisted ambient rooms from config.
let persisted_ambient: HashSet<String> = config
.ambient_rooms
.iter()
.cloned()
.collect();
let persisted_ambient: HashSet<String> = config.ambient_rooms.iter().cloned().collect();
if !persisted_ambient.is_empty() {
slog!(
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
@@ -189,11 +186,13 @@ pub async fn run_bot(
"whatsapp" => {
if config.whatsapp_provider == "twilio" {
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
config.twilio_account_sid.clone().unwrap_or_default(),
config.twilio_auth_token.clone().unwrap_or_default(),
config.twilio_whatsapp_number.clone().unwrap_or_default(),
))
Arc::new(
crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
config.twilio_account_sid.clone().unwrap_or_default(),
config.twilio_auth_token.clone().unwrap_or_default(),
config.twilio_whatsapp_number.clone().unwrap_or_default(),
),
)
} else {
slog!("[matrix-bot] Using WhatsApp/Meta transport");
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
@@ -208,7 +207,9 @@ pub async fn run_bot(
}
_ => {
slog!("[matrix-bot] Using Matrix transport");
Arc::new(super::super::transport_impl::MatrixTransport::new(client.clone()))
Arc::new(super::super::transport_impl::MatrixTransport::new(
client.clone(),
))
}
};
@@ -222,10 +223,7 @@ pub async fn run_bot(
project_root.join(".huskies").join("timers.json"),
));
// Auto-schedule timers when an agent hits a hard rate limit.
crate::chat::timer::spawn_rate_limit_auto_scheduler(
Arc::clone(&timer_store),
watcher_rx_auto,
);
crate::chat::timer::spawn_rate_limit_auto_scheduler(Arc::clone(&timer_store), watcher_rx_auto);
let ctx = BotContext {
bot_user_id,
@@ -246,7 +244,9 @@ pub async fn run_bot(
timer_store,
};
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
slog!(
"[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"
);
// Register event handlers and inject shared context.
client.add_event_handler_context(ctx);
@@ -256,8 +256,7 @@ pub async fn run_bot(
// Spawn the stage-transition notification listener before entering the
// sync loop so it starts receiving watcher events immediately.
let notif_room_id_strings: Vec<String> =
notif_room_ids.iter().map(|r| r.to_string()).collect();
let notif_room_id_strings: Vec<String> = notif_room_ids.iter().map(|r| r.to_string()).collect();
super::super::notifications::spawn_notification_listener(
Arc::clone(&transport),
move || notif_room_id_strings.clone(),
@@ -269,8 +268,7 @@ pub async fn run_bot(
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
{
let shutdown_transport = Arc::clone(&transport);
let shutdown_rooms: Vec<String> =
announce_room_ids.iter().map(|r| r.to_string()).collect();
let shutdown_rooms: Vec<String> = announce_room_ids.iter().map(|r| r.to_string()).collect();
let shutdown_bot_name = announce_bot_name.clone();
let mut rx = shutdown_rx;
tokio::spawn(async move {
@@ -400,8 +398,7 @@ mod tests {
#[test]
fn io_error_is_not_fatal() {
let e: matrix_sdk::Error =
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused")
.into();
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused").into();
assert!(!is_fatal_sync_error(&e));
}
@@ -423,7 +420,11 @@ mod tests {
const MAX_BACKOFF_SECS: u64 = 300;
let steps: Vec<u64> = std::iter::successors(Some(5u64), |&d| {
let next = (d * 2).min(MAX_BACKOFF_SECS);
if next < MAX_BACKOFF_SECS { Some(next) } else { None }
if next < MAX_BACKOFF_SECS {
Some(next)
} else {
None
}
})
.collect();
// First few steps: 5, 10, 20, 40, 80, 160
@@ -433,4 +434,3 @@ mod tests {
assert_eq!(steps[3], 40);
}
}
@@ -84,8 +84,9 @@ pub(super) async fn on_to_device_verification_request(
}
break;
}
VerificationRequestState::Done
| VerificationRequestState::Cancelled(_) => break,
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
break;
}
_ => {}
}
}
@@ -100,10 +101,7 @@ pub(super) async fn on_to_device_verification_request(
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
/// event rather than a to-device event. We look for that message type and
/// drive the same SAS flow as the to-device handler.
pub(super) async fn on_room_verification_request(
ev: OriginalSyncRoomMessageEvent,
client: Client,
) {
pub(super) async fn on_room_verification_request(ev: OriginalSyncRoomMessageEvent, client: Client) {
// Only act on in-room verification request messages.
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
return;
@@ -152,8 +150,9 @@ pub(super) async fn on_room_verification_request(
}
break;
}
VerificationRequestState::Done
| VerificationRequestState::Cancelled(_) => break,
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
break;
}
_ => {}
}
}
+53 -46
View File
@@ -77,7 +77,6 @@ pub struct BotConfig {
// ── WhatsApp Business API fields ─────────────────────────────────
// These are only required when `transport = "whatsapp"`.
/// WhatsApp Business phone number ID from the Meta dashboard.
#[serde(default)]
pub whatsapp_phone_number_id: Option<String>,
@@ -105,7 +104,6 @@ pub struct BotConfig {
// ── Twilio WhatsApp fields ─────────────────────────────────────────
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
/// Twilio Account SID (starts with `AC`).
#[serde(default)]
pub twilio_account_sid: Option<String>,
@@ -126,7 +124,6 @@ pub struct BotConfig {
// ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`.
/// Slack Bot User OAuth Token (starts with `xoxb-`).
#[serde(default)]
pub slack_bot_token: Option<String>,
@@ -139,7 +136,6 @@ pub struct BotConfig {
// ── Discord Bot API fields ──────────────────────────────────────
// These are only required when `transport = "discord"`.
/// Discord bot token from the Discord Developer Portal.
#[serde(default)]
pub discord_bot_token: Option<String>,
@@ -189,21 +185,33 @@ impl BotConfig {
if config.transport == "whatsapp" {
if config.whatsapp_provider == "twilio" {
// Validate Twilio-specific fields.
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
if config
.twilio_account_sid
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_account_sid"
);
return None;
}
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
if config
.twilio_auth_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_auth_token"
);
return None;
}
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
if config
.twilio_whatsapp_number
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_whatsapp_number"
@@ -212,21 +220,33 @@ impl BotConfig {
}
} else {
// Validate Meta (default) WhatsApp fields.
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
if config
.whatsapp_phone_number_id
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_phone_number_id"
);
return None;
}
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
if config
.whatsapp_access_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_access_token"
);
return None;
}
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
if config
.whatsapp_verify_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_verify_token"
@@ -243,7 +263,11 @@ impl BotConfig {
);
return None;
}
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
if config
.slack_signing_secret
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_signing_secret"
@@ -259,7 +283,11 @@ impl BotConfig {
}
} else if config.transport == "discord" {
// Validate Discord-specific fields.
if config.discord_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
if config
.discord_bot_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"discord\" requires \
discord_bot_token"
@@ -276,21 +304,15 @@ impl BotConfig {
} else {
// Default transport is Matrix — validate Matrix-specific fields.
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires homeserver"
);
eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver");
return None;
}
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires username"
);
eprintln!("[bot] bot.toml: transport=\"matrix\" requires username");
return None;
}
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"matrix\" requires password"
);
eprintln!("[bot] bot.toml: transport=\"matrix\" requires password");
return None;
}
if config.room_ids.is_empty() {
@@ -402,7 +424,10 @@ enabled = true
let result = BotConfig::load(tmp.path());
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.homeserver.as_deref(), Some("https://matrix.example.com"));
assert_eq!(
config.homeserver.as_deref(),
Some("https://matrix.example.com")
);
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
assert_eq!(
config.effective_room_ids(),
@@ -761,18 +786,9 @@ whatsapp_verify_token = "my-verify"
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "whatsapp");
assert_eq!(
config.whatsapp_phone_number_id.as_deref(),
Some("123456")
);
assert_eq!(
config.whatsapp_access_token.as_deref(),
Some("EAAtoken")
);
assert_eq!(
config.whatsapp_verify_token.as_deref(),
Some("my-verify")
);
assert_eq!(config.whatsapp_phone_number_id.as_deref(), Some("123456"));
assert_eq!(config.whatsapp_access_token.as_deref(), Some("EAAtoken"));
assert_eq!(config.whatsapp_verify_token.as_deref(), Some("my-verify"));
}
#[test]
@@ -1106,14 +1122,8 @@ discord_channel_ids = ["123456789012345678"]
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.transport, "discord");
assert_eq!(
config.discord_bot_token.as_deref(),
Some("Bot.Token.Here")
);
assert_eq!(
config.discord_channel_ids,
vec!["123456789012345678"]
);
assert_eq!(config.discord_bot_token.as_deref(), Some("Bot.Token.Here"));
assert_eq!(config.discord_channel_ids, vec!["123456789012345678"]);
}
#[test]
@@ -1176,9 +1186,6 @@ discord_allowed_users = ["111222333", "444555666"]
"#,
)
.unwrap();
assert_eq!(
config.discord_allowed_users,
vec!["111222333", "444555666"]
);
assert_eq!(config.discord_allowed_users, vec!["111222333", "444555666"]);
}
}
+1 -3
View File
@@ -65,9 +65,7 @@ pub async fn handle_delete(
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
+18 -5
View File
@@ -13,9 +13,9 @@ use std::time::Duration;
use tokio::sync::{Mutex as TokioMutex, watch};
use crate::agents::{AgentPool, AgentStatus};
use crate::chat::ChatTransport;
use crate::chat::util::strip_bot_mention;
use crate::slog;
use crate::chat::ChatTransport;
use super::bot::markdown_to_html;
@@ -51,7 +51,11 @@ pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
/// - `htop stop` → `Stop`
/// - `htop 10m` → `Start { duration_secs: 600 }`
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
pub fn extract_htop_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<HtopCommand> {
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
let trimmed = stripped.trim();
@@ -261,7 +265,10 @@ pub async fn run_htop_loop(
let text = build_htop_message(&agents, tick as u32, duration_secs);
let html = markdown_to_html(&text);
if let Err(e) = transport.edit_message(&room_id, &initial_message_id, &text, &html).await {
if let Err(e) = transport
.edit_message(&room_id, &initial_message_id, &text, &html)
.await
{
slog!("[htop] Failed to update message: {e}");
return;
}
@@ -274,7 +281,10 @@ pub async fn run_htop_loop(
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
let text = "**htop** — monitoring stopped.";
let html = markdown_to_html(text);
if let Err(e) = transport.edit_message(room_id, message_id, text, &html).await {
if let Err(e) = transport
.edit_message(room_id, message_id, text, &html)
.await
{
slog!("[htop] Failed to send stop message: {e}");
}
}
@@ -302,7 +312,10 @@ pub async fn handle_htop_start(
// Send the initial message.
let initial_text = build_htop_message(&agents, 0, duration_secs);
let initial_html = markdown_to_html(&initial_text);
let message_id = match transport.send_message(room_id, &initial_text, &initial_html).await {
let message_id = match transport
.send_message(room_id, &initial_text, &initial_html)
.await
{
Ok(id) => id,
Err(e) => {
slog!("[htop] Failed to send initial message: {e}");
+11 -4
View File
@@ -21,11 +21,11 @@ pub mod commands;
pub(crate) mod config;
pub mod delete;
pub mod htop;
pub mod notifications;
pub mod rebuild;
pub mod reset;
pub mod rmtree;
pub mod start;
pub mod notifications;
pub mod transport_impl;
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
@@ -92,9 +92,16 @@ pub fn spawn_bot(
let watcher_rx = watcher_tx.subscribe();
let watcher_rx_auto = watcher_tx.subscribe();
tokio::spawn(async move {
if let Err(e) =
bot::run_bot(config, root, watcher_rx, watcher_rx_auto, perm_rx, agents, shutdown_rx)
.await
if let Err(e) = bot::run_bot(
config,
root,
watcher_rx,
watcher_rx_auto,
perm_rx,
agents,
shutdown_rx,
)
.await
{
crate::slog!("[matrix-bot] Fatal error: {e}");
}
+236 -214
View File
@@ -3,11 +3,11 @@
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
//! configured Matrix rooms whenever a work item moves between pipeline stages.
use crate::chat::ChatTransport;
use crate::config::ProjectConfig;
use crate::io::story_metadata::parse_front_matter;
use crate::io::watcher::WatcherEvent;
use crate::slog;
use crate::chat::ChatTransport;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -81,9 +81,7 @@ pub fn format_error_notification(
let name = story_name.unwrap_or(item_id);
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
let html = format!(
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
);
let html = format!("\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}");
(plain, html)
}
@@ -113,9 +111,8 @@ pub fn format_blocked_notification(
let name = story_name.unwrap_or(item_id);
let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}");
let html = format!(
"\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}"
);
let html =
format!("\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}");
(plain, html)
}
@@ -126,7 +123,6 @@ const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
/// into a single notification (only the final stage is announced).
const STAGE_TRANSITION_DEBOUNCE: Duration = Duration::from_millis(200);
/// Format a rate limit warning notification message.
///
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
@@ -138,9 +134,8 @@ pub fn format_rate_limit_notification(
let number = extract_story_number(item_id).unwrap_or(item_id);
let name = story_name.unwrap_or(item_id);
let plain = format!(
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
);
let plain =
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit");
let html = format!(
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
{agent_name} hit an API rate limit"
@@ -223,9 +218,7 @@ pub fn spawn_notification_listener(
// and must be skipped — the old inferred_from_stage fallback
// produced wrong notifications for stories that skipped stages
// (e.g. "QA → Merge" when QA was never entered).
let from_display = from_stage
.as_deref()
.map(stage_display_name);
let from_display = from_stage.as_deref().map(stage_display_name);
let Some(from_display) = from_display else {
continue; // creation or unknown transition — skip
};
@@ -246,33 +239,24 @@ pub fn spawn_notification_listener(
e.2 = story_name.clone();
}
})
.or_insert_with(|| {
(from_display.to_string(), stage.clone(), story_name)
});
.or_insert_with(|| (from_display.to_string(), stage.clone(), story_name));
// Start or extend the debounce window.
flush_deadline =
Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
}
Ok(WatcherEvent::MergeFailure {
ref story_id,
ref reason,
}) => {
let story_name =
read_story_name(&project_root, "4_merge", story_id);
let (plain, html) = format_error_notification(
story_id,
story_name.as_deref(),
reason,
);
let story_name = read_story_name(&project_root, "4_merge", story_id);
let (plain, html) =
format_error_notification(story_id, story_name.as_deref(), reason);
slog!("[bot] Sending error notification: {plain}");
for room_id in &get_room_ids() {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[bot] Failed to send error notification to {room_id}: {e}"
);
slog!("[bot] Failed to send error notification to {room_id}: {e}");
}
}
}
@@ -303,11 +287,8 @@ pub fn spawn_notification_listener(
rate_limit_last_notified.insert(debounce_key, now);
let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_rate_limit_notification(
story_id,
story_name.as_deref(),
agent_name,
);
let (plain, html) =
format_rate_limit_notification(story_id, story_name.as_deref(), agent_name);
slog!("[bot] Sending rate-limit notification: {plain}");
@@ -325,19 +306,14 @@ pub fn spawn_notification_listener(
ref reason,
}) => {
let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_blocked_notification(
story_id,
story_name.as_deref(),
reason,
);
let (plain, html) =
format_blocked_notification(story_id, story_name.as_deref(), reason);
slog!("[bot] Sending blocked notification: {plain}");
for room_id in &get_room_ids() {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!(
"[bot] Failed to send blocked notification to {room_id}: {e}"
);
slog!("[bot] Failed to send blocked notification to {room_id}: {e}");
}
}
}
@@ -362,14 +338,10 @@ pub fn spawn_notification_listener(
}
Ok(_) => {} // Ignore other events
Err(broadcast::error::RecvError::Lagged(n)) => {
slog!(
"[bot] Notification listener lagged, skipped {n} events"
);
slog!("[bot] Notification listener lagged, skipped {n} events");
}
Err(broadcast::error::RecvError::Closed) => {
slog!(
"[bot] Watcher channel closed, stopping notification listener"
);
slog!("[bot] Watcher channel closed, stopping notification listener");
// Flush any coalesced transitions that haven't fired yet.
for (item_id, (from_display, to_stage_key, story_name)) in
pending_transitions.drain()
@@ -383,12 +355,8 @@ pub fn spawn_notification_listener(
);
slog!("[bot] Sending stage notification: {plain}");
for room_id in &get_room_ids() {
if let Err(e) =
transport.send_message(room_id, &plain, &html).await
{
slog!(
"[bot] Failed to send notification to {room_id}: {e}"
);
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!("[bot] Failed to send notification to {room_id}: {e}");
}
}
}
@@ -402,8 +370,8 @@ pub fn spawn_notification_listener(
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use crate::chat::MessageId;
use async_trait::async_trait;
// ── MockTransport ───────────────────────────────────────────────────────
@@ -417,18 +385,38 @@ mod tests {
impl MockTransport {
fn new() -> (Arc<Self>, CallLog) {
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
(
Arc::new(Self {
calls: Arc::clone(&calls),
}),
calls,
)
}
}
#[async_trait]
impl crate::chat::ChatTransport for MockTransport {
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
async fn send_message(
&self,
room_id: &str,
plain: &str,
html: &str,
) -> Result<MessageId, String> {
self.calls.lock().unwrap().push((
room_id.to_string(),
plain.to_string(),
html.to_string(),
));
Ok("mock-msg-id".to_string())
}
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
@@ -462,10 +450,12 @@ mod tests {
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "365_story_rate_limit".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "365_story_rate_limit".to_string(),
agent_name: "coder-1".to_string(),
})
.unwrap();
// Give the spawned task time to process the event.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -475,9 +465,15 @@ mod tests {
let (room_id, plain, _html) = &calls[0];
assert_eq!(room_id, "!room123:example.org");
assert!(plain.contains("365"), "plain should contain story number");
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
assert!(
plain.contains("Rate Limit Test Story"),
"plain should contain story name"
);
assert!(plain.contains("coder-1"), "plain should contain agent name");
assert!(plain.contains("rate limit"), "plain should mention rate limit");
assert!(
plain.contains("rate limit"),
"plain should mention rate limit"
);
}
/// AC4: a second RateLimitWarning for the same agent within the debounce
@@ -498,16 +494,22 @@ mod tests {
// Send the same warning twice in rapid succession.
for _ in 0..2 {
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_debounce".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_debounce".to_string(),
agent_name: "coder-2".to_string(),
})
.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
assert_eq!(
calls.len(),
1,
"Debounce should suppress the second notification"
);
}
/// AC4 (corollary): warnings for different agents are NOT debounced against
@@ -526,19 +528,27 @@ mod tests {
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
})
.unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_foo".to_string(),
agent_name: "coder-2".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
assert_eq!(
calls.len(),
2,
"Different agents should each trigger a notification"
);
}
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
@@ -573,25 +583,40 @@ mod tests {
);
// Add a room after the listener is spawned (simulates a user messaging first).
rooms.lock().unwrap().insert("phone:+15551234567".to_string());
rooms
.lock()
.unwrap()
.insert("phone:+15551234567".to_string());
watcher_tx.send(WatcherEvent::WorkItem {
stage: "3_qa".to_string(),
item_id: "10_story_foo".to_string(),
action: "qa".to_string(),
commit_msg: "huskies: qa 10_story_foo".to_string(),
from_stage: Some("2_current".to_string()),
}).unwrap();
watcher_tx
.send(WatcherEvent::WorkItem {
stage: "3_qa".to_string(),
item_id: "10_story_foo".to_string(),
action: "qa".to_string(),
commit_msg: "huskies: qa 10_story_foo".to_string(),
from_stage: Some("2_current".to_string()),
})
.unwrap();
// Wait longer than STAGE_TRANSITION_DEBOUNCE (200ms) so the coalesced
// notification flushes.
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Should deliver to the dynamically added room");
assert_eq!(
calls.len(),
1,
"Should deliver to the dynamically added room"
);
assert_eq!(calls[0].0, "phone:+15551234567");
assert!(calls[0].1.contains("10"), "plain should contain story number");
assert!(calls[0].1.contains("Foo Story"), "plain should contain story name");
assert!(
calls[0].1.contains("10"),
"plain should contain story number"
);
assert!(
calls[0].1.contains("Foo Story"),
"plain should contain story name"
);
}
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
@@ -603,20 +628,17 @@ mod tests {
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
Vec::new,
watcher_rx,
tmp.path().to_path_buf(),
);
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
watcher_tx.send(WatcherEvent::WorkItem {
stage: "3_qa".to_string(),
item_id: "10_story_foo".to_string(),
action: "qa".to_string(),
commit_msg: "huskies: qa 10_story_foo".to_string(),
from_stage: Some("2_current".to_string()),
}).unwrap();
watcher_tx
.send(WatcherEvent::WorkItem {
stage: "3_qa".to_string(),
item_id: "10_story_foo".to_string(),
action: "qa".to_string(),
commit_msg: "huskies: qa 10_story_foo".to_string(),
from_stage: Some("2_current".to_string()),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -660,11 +682,7 @@ mod tests {
#[test]
fn read_story_name_reads_from_front_matter() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp
.path()
.join(".huskies")
.join("work")
.join("2_current");
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("42_story_my_feature.md"),
@@ -686,11 +704,7 @@ mod tests {
#[test]
fn read_story_name_returns_none_for_missing_name_field() {
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp
.path()
.join(".huskies")
.join("work")
.join("2_current");
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
std::fs::create_dir_all(&stage_dir).unwrap();
std::fs::write(
stage_dir.join("42_story_no_name.md"),
@@ -706,8 +720,11 @@ mod tests {
#[test]
fn format_error_notification_with_story_name() {
let (plain, html) =
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
let (plain, html) = format_error_notification(
"262_story_bot_errors",
Some("Bot error notifications"),
"merge conflict in src/main.rs",
);
assert_eq!(
plain,
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
@@ -720,12 +737,8 @@ mod tests {
#[test]
fn format_error_notification_without_story_name_falls_back_to_item_id() {
let (plain, _html) =
format_error_notification("42_bug_fix_thing", None, "tests failed");
assert_eq!(
plain,
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
);
let (plain, _html) = format_error_notification("42_bug_fix_thing", None, "tests failed");
assert_eq!(plain, "\u{274c} #42 42_bug_fix_thing \u{2014} tests failed");
}
#[test]
@@ -759,8 +772,7 @@ mod tests {
#[test]
fn format_blocked_notification_falls_back_to_item_id() {
let (plain, _html) =
format_blocked_notification("42_story_thing", None, "empty diff");
let (plain, _html) = format_blocked_notification("42_story_thing", None, "empty diff");
assert_eq!(
plain,
"\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff"
@@ -792,10 +804,12 @@ mod tests {
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::StoryBlocked {
story_id: "425_story_blocking_test".to_string(),
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::StoryBlocked {
story_id: "425_story_blocking_test".to_string(),
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -804,10 +818,22 @@ mod tests {
let (room_id, plain, html) = &calls[0];
assert_eq!(room_id, "!room123:example.org");
assert!(plain.contains("425"), "plain should contain story number");
assert!(plain.contains("Blocking Test Story"), "plain should contain story name");
assert!(plain.contains("BLOCKED"), "plain should contain BLOCKED label");
assert!(plain.contains("Retry limit exceeded"), "plain should contain the reason");
assert!(html.contains("BLOCKED"), "html should contain BLOCKED label");
assert!(
plain.contains("Blocking Test Story"),
"plain should contain story name"
);
assert!(
plain.contains("BLOCKED"),
"plain should contain BLOCKED label"
);
assert!(
plain.contains("Retry limit exceeded"),
"plain should contain the reason"
);
assert!(
html.contains("BLOCKED"),
"html should contain BLOCKED label"
);
}
/// StoryBlocked with no room registered should not panic.
@@ -818,17 +844,14 @@ mod tests {
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
let (transport, calls) = MockTransport::new();
spawn_notification_listener(
transport,
Vec::new,
watcher_rx,
tmp.path().to_path_buf(),
);
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
watcher_tx.send(WatcherEvent::StoryBlocked {
story_id: "42_story_no_rooms".to_string(),
reason: "empty diff".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::StoryBlocked {
story_id: "42_story_no_rooms".to_string(),
reason: "empty diff".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -840,11 +863,8 @@ mod tests {
#[test]
fn format_rate_limit_notification_includes_agent_and_story() {
let (plain, html) = format_rate_limit_notification(
"365_story_my_feature",
Some("My Feature"),
"coder-2",
);
let (plain, html) =
format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2");
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
@@ -857,8 +877,7 @@ mod tests {
#[test]
fn format_rate_limit_notification_falls_back_to_item_id() {
let (plain, _html) =
format_rate_limit_notification("42_story_thing", None, "coder-1");
let (plain, _html) = format_rate_limit_notification("42_story_thing", None, "coder-1");
assert_eq!(
plain,
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
@@ -869,12 +888,8 @@ mod tests {
#[test]
fn format_notification_done_stage_includes_party_emoji() {
let (plain, html) = format_stage_notification(
"353_story_done",
Some("Done Story"),
"Merge",
"Done",
);
let (plain, html) =
format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done");
assert_eq!(
plain,
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
@@ -887,12 +902,8 @@ mod tests {
#[test]
fn format_notification_non_done_stage_has_no_emoji() {
let (plain, _html) = format_stage_notification(
"42_story_thing",
Some("Some Story"),
"Backlog",
"Current",
);
let (plain, _html) =
format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current");
assert!(!plain.contains("\u{1f389}"));
}
@@ -916,26 +927,14 @@ mod tests {
#[test]
fn format_notification_without_story_name_falls_back_to_item_id() {
let (plain, _html) = format_stage_notification(
"42_bug_fix_thing",
None,
"Current",
"QA",
);
assert_eq!(
plain,
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
);
let (plain, _html) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA");
assert_eq!(plain, "#42 42_bug_fix_thing \u{2014} Current \u{2192} QA");
}
#[test]
fn format_notification_non_numeric_id_uses_full_id() {
let (plain, _html) = format_stage_notification(
"abc_story_thing",
Some("Some Story"),
"QA",
"Merge",
);
let (plain, _html) =
format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge");
assert_eq!(
plain,
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
@@ -967,15 +966,21 @@ mod tests {
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_suppress".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_suppress".to_string(),
agent_name: "coder-1".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 0, "RateLimitWarning should be suppressed when rate_limit_notifications = false");
assert_eq!(
calls.len(),
0,
"RateLimitWarning should be suppressed when rate_limit_notifications = false"
);
}
/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
@@ -994,11 +999,13 @@ mod tests {
);
let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
watcher_tx.send(WatcherEvent::RateLimitHardBlock {
story_id: "42_story_hard_block".to_string(),
agent_name: "coder-1".to_string(),
reset_at,
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitHardBlock {
story_id: "42_story_hard_block".to_string(),
agent_name: "coder-1".to_string(),
reset_at,
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -1028,10 +1035,12 @@ mod tests {
tmp.path().to_path_buf(),
);
watcher_tx.send(WatcherEvent::StoryBlocked {
story_id: "42_story_blocked".to_string(),
reason: "retry limit exceeded".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::StoryBlocked {
story_id: "42_story_blocked".to_string(),
reason: "retry limit exceeded".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -1064,10 +1073,12 @@ mod tests {
);
// First warning is sent.
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_reload".to_string(),
agent_name: "coder-1".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_reload".to_string(),
agent_name: "coder-1".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Disable notifications and trigger hot-reload.
@@ -1080,14 +1091,20 @@ mod tests {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Second warning (different agent to bypass debounce) should be suppressed.
watcher_tx.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_reload".to_string(),
agent_name: "coder-2".to_string(),
}).unwrap();
watcher_tx
.send(WatcherEvent::RateLimitWarning {
story_id: "42_story_reload".to_string(),
agent_name: "coder-2".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "Only the first warning should be sent; second should be suppressed after hot-reload");
assert_eq!(
calls.len(),
1,
"Only the first warning should be sent; second should be suppressed after hot-reload"
);
}
// ── Bug 549: synthetic events with from_stage=None must not notify ──────
@@ -1111,19 +1128,22 @@ mod tests {
);
// Synthetic reassign event within 4_merge — no actual stage change.
watcher_tx.send(WatcherEvent::WorkItem {
stage: "4_merge".to_string(),
item_id: "549_story_skip_qa".to_string(),
action: "reassign".to_string(),
commit_msg: String::new(),
from_stage: None,
}).unwrap();
watcher_tx
.send(WatcherEvent::WorkItem {
stage: "4_merge".to_string(),
item_id: "549_story_skip_qa".to_string(),
action: "reassign".to_string(),
commit_msg: String::new(),
from_stage: None,
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let calls = calls.lock().unwrap();
assert_eq!(
calls.len(), 0,
calls.len(),
0,
"Synthetic events with from_stage=None must not generate notifications"
);
}
@@ -1152,13 +1172,15 @@ mod tests {
);
// Story skips QA: from_stage is 2_current, not 3_qa.
watcher_tx.send(WatcherEvent::WorkItem {
stage: "4_merge".to_string(),
item_id: "549_story_skip_qa".to_string(),
action: "merge".to_string(),
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
from_stage: Some("2_current".to_string()),
}).unwrap();
watcher_tx
.send(WatcherEvent::WorkItem {
stage: "4_merge".to_string(),
item_id: "549_story_skip_qa".to_string(),
action: "merge".to_string(),
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
from_stage: Some("2_current".to_string()),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
+2 -5
View File
@@ -73,11 +73,8 @@ mod tests {
#[test]
fn extract_with_full_user_id() {
let cmd = extract_rebuild_command(
"@timmy:home.local rebuild",
"Timmy",
"@timmy:home.local",
);
let cmd =
extract_rebuild_command("@timmy:home.local rebuild", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(RebuildCommand));
}
+19 -12
View File
@@ -50,7 +50,9 @@ pub async fn handle_reset(
) -> String {
{
let mut guard = history.lock().await;
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
let conv = guard
.entry(room_id.clone())
.or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
@@ -75,8 +77,7 @@ mod tests {
#[test]
fn extract_with_full_user_id() {
let cmd =
extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
let cmd = extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
assert_eq!(cmd, Some(ResetCommand));
}
@@ -115,21 +116,27 @@ mod tests {
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
let history: ConversationHistory = Arc::new(TokioMutex::new({
let mut m = HashMap::new();
m.insert(room_id.clone(), RoomConversation {
session_id: Some("old-session-id".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: "@alice:example.com".to_string(),
content: "previous message".to_string(),
}],
});
m.insert(
room_id.clone(),
RoomConversation {
session_id: Some("old-session-id".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: "@alice:example.com".to_string(),
content: "previous message".to_string(),
}],
},
);
m
}));
let tmp = tempfile::tempdir().unwrap();
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
assert!(response.contains("reset"), "response should mention reset: {response}");
assert!(
response.contains("reset"),
"response should mention reset: {response}"
);
let guard = history.lock().await;
let conv = guard.get(&room_id).unwrap();
+3 -8
View File
@@ -107,9 +107,7 @@ pub async fn handle_rmtree(
return format!("Failed to remove worktree for story {story_number}: {e}");
}
crate::slog!(
"[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})"
);
crate::slog!("[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})");
let mut response = format!("Removed worktree for **{story_id}**.");
if !stopped_agents.is_empty() {
@@ -131,11 +129,8 @@ mod tests {
#[test]
fn extract_with_full_user_id() {
let cmd = extract_rmtree_command(
"@timmy:home.local rmtree 42",
"Timmy",
"@timmy:home.local",
);
let cmd =
extract_rmtree_command("@timmy:home.local rmtree 42", "Timmy", "@timmy:home.local");
assert_eq!(
cmd,
Some(RmtreeCommand::Rmtree {
+18 -6
View File
@@ -84,9 +84,7 @@ pub async fn handle_start(
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
@@ -115,7 +113,13 @@ pub async fn handle_start(
);
match agents
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None, None)
.start_agent(
project_root,
&story_id,
resolved_agent.as_deref(),
None,
None,
)
.await
{
Ok(info) => {
@@ -231,7 +235,14 @@ mod tests {
async fn handle_start_returns_not_found_for_unknown_number() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
for stage in &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
] {
std::fs::create_dir_all(project_root.join(".huskies").join("work").join(stage))
.unwrap();
}
@@ -276,7 +287,8 @@ mod tests {
"response must not say 'Failed' when coders are busy: {response}"
);
assert!(
response.to_lowercase().contains("queue") || response.to_lowercase().contains("available"),
response.to_lowercase().contains("queue")
|| response.to_lowercase().contains("available"),
"response must mention queued/available state: {response}"
);
}
+74 -44
View File
@@ -1,21 +1,21 @@
//! Slack incoming message dispatch and slash command handling.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::sync::{Mutex as TokioMutex, oneshot};
use serde::{Deserialize, Serialize};
use super::format::markdown_to_slack;
use super::history::{SlackConversationHistory, save_slack_history};
use super::meta::SlackTransport;
use crate::agents::AgentPool;
use crate::chat::ChatTransport;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::slog;
use crate::chat::ChatTransport;
use crate::http::context::{PermissionDecision, PermissionForward};
use super::meta::SlackTransport;
use super::history::{SlackConversationHistory, save_slack_history};
use super::format::markdown_to_slack;
use crate::slog;
// ── Slash command types ─────────────────────────────────────────────────
@@ -81,8 +81,7 @@ pub struct SlackWebhookContext {
/// Permission requests from the MCP `prompt_permission` tool arrive here.
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
/// Pending permission replies keyed by channel ID.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
/// Seconds before an unanswered permission prompt is auto-denied.
pub permission_timeout_secs: u64,
}
@@ -154,8 +153,11 @@ pub(super) async fn handle_incoming_message(
}
HtopCommand::Start { duration_secs } => {
// On Slack, htop uses native message editing for live updates.
let snapshot =
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
let snapshot = crate::chat::transport::matrix::htop::build_htop_message(
&ctx.agents,
0,
duration_secs,
);
let snapshot = markdown_to_slack(&snapshot);
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
Ok(id) => id,
@@ -179,9 +181,7 @@ pub(super) async fn handle_incoming_message(
duration_secs,
);
let updated = markdown_to_slack(&updated);
if let Err(e) =
transport.edit_message(&ch, &msg_id, &updated, "").await
{
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
slog!("[slack] Failed to edit htop message: {e}");
break;
}
@@ -245,7 +245,9 @@ pub(super) async fn handle_incoming_message(
) {
let response = match rmtree_cmd {
crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => {
slog!("[slack] Handling rmtree command from {user} in {channel}: story {story_number}");
slog!(
"[slack] Handling rmtree command from {user} in {channel}: story {story_number}"
);
crate::chat::transport::matrix::rmtree::handle_rmtree(
&ctx.bot_name,
&story_number,
@@ -273,7 +275,9 @@ pub(super) async fn handle_incoming_message(
slog!("[slack] Handling reset command from {user} in {channel}");
{
let mut guard = ctx.history.lock().await;
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
let conv = guard
.entry(channel.to_string())
.or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
save_slack_history(&ctx.project_root, &guard);
@@ -295,7 +299,9 @@ pub(super) async fn handle_incoming_message(
story_number,
agent_hint,
} => {
slog!("[slack] Handling start command from {user} in {channel}: story {story_number}");
slog!(
"[slack] Handling start command from {user} in {channel}: story {story_number}"
);
crate::chat::transport::matrix::start::handle_start(
&ctx.bot_name,
&story_number,
@@ -320,8 +326,13 @@ pub(super) async fn handle_incoming_message(
&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::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,
@@ -352,17 +363,15 @@ async fn handle_llm_message(
user: &str,
user_message: &str,
) {
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::chat::util::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
// Look up existing session ID for this channel.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard
.get(channel)
.and_then(|conv| conv.session_id.clone())
guard.get(channel).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.bot_name;
@@ -383,7 +392,9 @@ async fn handle_llm_message(
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
let formatted = markdown_to_slack(&chunk);
let _ = post_transport.send_message(&post_channel, &formatted, "").await;
let _ = post_transport
.send_message(&post_channel, &formatted, "")
.await;
}
});
@@ -472,9 +483,7 @@ async fn handle_llm_message(
let last_text = messages
.iter()
.rev()
.find(|m| {
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
})
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
@@ -559,7 +568,10 @@ mod tests {
#[test]
fn slash_command_maps_status() {
assert_eq!(slash_command_to_bot_keyword("/huskies-status"), Some("status"));
assert_eq!(
slash_command_to_bot_keyword("/huskies-status"),
Some("status")
);
}
#[test]
@@ -600,9 +612,8 @@ mod tests {
response_type: "ephemeral",
text: "hello".to_string(),
};
let json: serde_json::Value = serde_json::from_str(
&serde_json::to_string(&resp).unwrap()
).unwrap();
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
assert_eq!(json["response_type"], "ephemeral");
assert_eq!(json["text"], "hello");
}
@@ -642,7 +653,10 @@ mod tests {
};
let result = try_handle_command(&dispatch, &synthetic);
assert!(result.is_some(), "status slash command should produce output via registry");
assert!(
result.is_some(),
"status slash command should produce output via registry"
);
assert!(result.unwrap().contains("Pipeline Status"));
}
@@ -671,7 +685,10 @@ mod tests {
let result = try_handle_command(&dispatch, &synthetic);
assert!(result.is_some(), "show slash command should produce output");
let output = result.unwrap();
assert!(output.contains("999"), "show output should reference the story number: {output}");
assert!(
output.contains("999"),
"show output should reference the story number: {output}"
);
}
// ── rebuild command extraction ─────────────────────────────────────
@@ -704,7 +721,10 @@ mod tests {
"Huskies",
"slack-bot",
);
assert!(result.is_none(), "'status' should not be recognised as rebuild");
assert!(
result.is_none(),
"'status' should not be recognised as rebuild"
);
}
// ── reset command extraction ───────────────────────────────────────
@@ -731,21 +751,26 @@ mod tests {
#[tokio::test]
async fn reset_command_clears_slack_session() {
use crate::chat::transport::matrix::{
ConversationEntry, ConversationRole, RoomConversation,
};
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
let channel = "C01ABCDEF";
let history: SlackConversationHistory = Arc::new(TokioMutex::new({
let mut m = HashMap::new();
m.insert(channel.to_string(), RoomConversation {
session_id: Some("old-session".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: "U01GHIJKL".to_string(),
content: "previous message".to_string(),
}],
});
m.insert(
channel.to_string(),
RoomConversation {
session_id: Some("old-session".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: "U01GHIJKL".to_string(),
content: "previous message".to_string(),
}],
},
);
m
}));
@@ -755,7 +780,9 @@ mod tests {
{
let mut guard = history.lock().await;
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
let conv = guard
.entry(channel.to_string())
.or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
save_slack_history(tmp.path(), &guard);
@@ -862,6 +889,9 @@ mod tests {
"Timmy",
"@timmy:home.local",
);
assert!(result.is_none(), "'status' should not be recognised as assign on Slack");
assert!(
result.is_none(),
"'status' should not be recognised as assign on Slack"
);
}
}
+10 -6
View File
@@ -20,10 +20,8 @@ pub fn markdown_to_slack(text: &str) -> String {
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
static RE_BOLD_ITALIC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
static RE_BOLD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
@@ -105,8 +103,14 @@ mod tests {
fn slack_fenced_code_block_preserved() {
let input = "```rust\nlet x = 1;\n```";
let output = markdown_to_slack(input);
assert!(output.contains("let x = 1;"), "code block content must be preserved");
assert!(output.contains("```"), "fenced code delimiters must be preserved");
assert!(
output.contains("let x = 1;"),
"code block content must be preserved"
);
assert!(
output.contains("```"),
"fenced code delimiters must be preserved"
);
}
#[test]
+8 -26
View File
@@ -104,9 +104,8 @@ impl ChatTransport for SlackTransport {
return Err(format!("Slack API returned {status}: {resp_text}"));
}
let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| {
format!("Failed to parse Slack API response: {e} — body: {resp_text}")
})?;
let parsed: SlackApiResponse = serde_json::from_str(&resp_text)
.map_err(|e| format!("Failed to parse Slack API response: {e} — body: {resp_text}"))?;
if !parsed.ok {
return Err(format!(
@@ -190,10 +189,7 @@ mod tests {
.create_async()
.await;
let transport = SlackTransport::with_api_base(
"xoxb-test-token".to_string(),
server.url(),
);
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
let result = transport
.send_message("C01ABCDEF", "hello", "<p>hello</p>")
@@ -212,14 +208,9 @@ mod tests {
.create_async()
.await;
let transport = SlackTransport::with_api_base(
"xoxb-test-token".to_string(),
server.url(),
);
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
let result = transport
.send_message("C_INVALID", "hello", "")
.await;
let result = transport.send_message("C_INVALID", "hello", "").await;
assert!(result.is_err());
assert!(
result.unwrap_err().contains("channel_not_found"),
@@ -237,10 +228,7 @@ mod tests {
.create_async()
.await;
let transport = SlackTransport::with_api_base(
"xoxb-test-token".to_string(),
server.url(),
);
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
let result = transport
.edit_message("C01ABCDEF", "1234567890.123456", "updated", "")
@@ -258,10 +246,7 @@ mod tests {
.create_async()
.await;
let transport = SlackTransport::with_api_base(
"xoxb-test-token".to_string(),
server.url(),
);
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
let result = transport
.edit_message("C01ABCDEF", "bad-ts", "updated", "")
@@ -287,10 +272,7 @@ mod tests {
.create_async()
.await;
let transport = SlackTransport::with_api_base(
"xoxb-test-token".to_string(),
server.url(),
);
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
let result = transport.send_message("C01ABCDEF", "hello", "").await;
assert!(result.is_err());
+17 -29
View File
@@ -12,15 +12,15 @@ pub mod history;
pub mod meta;
pub mod verify;
pub use commands::SlackWebhookContext;
pub use format::markdown_to_slack;
pub use history::load_slack_history;
pub use meta::SlackTransport;
pub use format::markdown_to_slack;
pub use commands::SlackWebhookContext;
use serde::Deserialize;
use poem::{Request, Response, handler, http::StatusCode};
use crate::slog;
use poem::{Request, Response, handler, http::StatusCode};
// ── Slack Events API types ──────────────────────────────────────────────
@@ -71,10 +71,7 @@ pub async fn webhook_receive(
.header("X-Slack-Request-Timestamp")
.unwrap_or("")
.to_string();
let signature = req
.header("X-Slack-Signature")
.unwrap_or("")
.to_string();
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
let bytes = match body.into_bytes().await {
Ok(b) => b,
@@ -98,9 +95,7 @@ pub async fn webhook_receive(
Ok(e) => e,
Err(e) => {
slog!("[slack] Failed to parse webhook payload: {e}");
return Response::builder()
.status(StatusCode::OK)
.body("ok");
return Response::builder().status(StatusCode::OK).body("ok");
}
};
@@ -124,8 +119,7 @@ pub async fn webhook_receive(
&& event.r#type.as_deref() == Some("message")
&& event.subtype.is_none()
&& event.bot_id.is_none()
&& let (Some(channel), Some(user), Some(text)) =
(event.channel, event.user, event.text)
&& let (Some(channel), Some(user), Some(text)) = (event.channel, event.user, event.text)
&& ctx.channel_ids.contains(&channel)
{
let ctx = Arc::clone(*ctx);
@@ -135,9 +129,7 @@ pub async fn webhook_receive(
});
}
Response::builder()
.status(StatusCode::OK)
.body("ok")
Response::builder().status(StatusCode::OK).body("ok")
}
/// POST /webhook/slack/command — receive incoming Slack slash commands.
@@ -155,10 +147,7 @@ pub async fn slash_command_receive(
.header("X-Slack-Request-Timestamp")
.unwrap_or("")
.to_string();
let signature = req
.header("X-Slack-Signature")
.unwrap_or("")
.to_string();
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
let bytes = match body.into_bytes().await {
Ok(b) => b,
@@ -178,16 +167,15 @@ pub async fn slash_command_receive(
.body("Invalid signature");
}
let payload: commands::SlackSlashCommandPayload =
match serde_urlencoded::from_bytes(&bytes) {
Ok(p) => p,
Err(e) => {
slog!("[slack] Failed to parse slash command payload: {e}");
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Bad request");
}
};
let payload: commands::SlackSlashCommandPayload = match serde_urlencoded::from_bytes(&bytes) {
Ok(p) => p,
Err(e) => {
slog!("[slack] Failed to parse slash command payload: {e}");
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Bad request");
}
};
slog!(
"[slack] Slash command from {}: {} {}",
+6 -1
View File
@@ -215,7 +215,12 @@ mod tests {
let body = b"test body";
let sig = compute_test_signature("correct-secret", timestamp, body);
assert!(!verify_slack_signature("wrong-secret", timestamp, body, &sig));
assert!(!verify_slack_signature(
"wrong-secret",
timestamp,
body,
&sig
));
}
/// Helper to compute a test signature using our sha256 + HMAC implementation.
+60 -35
View File
@@ -1,22 +1,24 @@
//! WhatsApp command handling — processes incoming WhatsApp messages as bot commands.
use std::sync::Arc;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::http::context::{PermissionDecision};
use crate::slog;
use super::WhatsAppWebhookContext;
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
use super::history::save_whatsapp_history;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::http::context::PermissionDecision;
use crate::slog;
/// Dispatch an incoming WhatsApp message to bot commands.
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
pub(super) async fn handle_incoming_message(
ctx: &WhatsAppWebhookContext,
sender: &str,
message: &str,
) {
use crate::chat::commands::{CommandDispatch, try_handle_command};
// Allowlist check: when configured, silently ignore unauthorized senders.
if !ctx.allowed_phones.is_empty()
&& !ctx.allowed_phones.iter().any(|p| p == sender)
{
if !ctx.allowed_phones.is_empty() && !ctx.allowed_phones.iter().any(|p| p == sender) {
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
return;
}
@@ -173,7 +175,9 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
slog!("[whatsapp] Handling reset command from {sender}");
{
let mut guard = ctx.history.lock().await;
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
let conv = guard
.entry(sender.to_string())
.or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
save_whatsapp_history(&ctx.project_root, &guard);
@@ -219,8 +223,13 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
&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::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,
@@ -385,9 +394,7 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
Err(e) => {
slog!("[whatsapp] LLM error: {e}");
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. Log in to Claude here: {url}"
)
format!("Authentication required. Log in to Claude here: {url}")
} else {
format!("Error processing your request: {e}")
};
@@ -434,20 +441,18 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use crate::io::watcher::WatcherEvent;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
use super::super::WhatsAppWebhookContext;
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
use super::*;
use crate::agents::AgentPool;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::io::watcher::WatcherEvent;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
fn make_ctx_with_allowlist(
allowed_phones: Vec<String>,
) -> Arc<WhatsAppWebhookContext> {
fn make_ctx_with_allowlist(allowed_phones: Vec<String>) -> Arc<WhatsAppWebhookContext> {
struct NullTransport;
#[async_trait::async_trait]
@@ -505,9 +510,15 @@ mod tests {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
let msg = format!(
"Authentication required. Log in to Claude here: {}",
url.unwrap()
);
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
assert!(
!msg.contains('['),
"WhatsApp message should not use Markdown link syntax"
);
}
#[test]
@@ -594,7 +605,10 @@ mod tests {
"Timmy",
"@timmy:home.local",
);
assert!(result.is_none(), "'status' should not be recognised as rebuild");
assert!(
result.is_none(),
"'status' should not be recognised as rebuild"
);
}
// ── reset command extraction ───────────────────────────────────────
@@ -624,14 +638,17 @@ mod tests {
let sender = "+15555550100";
let history: WhatsAppConversationHistory = Arc::new(TokioMutex::new({
let mut m = HashMap::new();
m.insert(sender.to_string(), RoomConversation {
session_id: Some("old-session".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: sender.to_string(),
content: "previous message".to_string(),
}],
});
m.insert(
sender.to_string(),
RoomConversation {
session_id: Some("old-session".to_string()),
entries: vec![ConversationEntry {
role: ConversationRole::User,
sender: sender.to_string(),
content: "previous message".to_string(),
}],
},
);
m
}));
@@ -641,7 +658,9 @@ mod tests {
{
let mut guard = history.lock().await;
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
let conv = guard
.entry(sender.to_string())
.or_insert_with(RoomConversation::default);
conv.session_id = None;
conv.entries.clear();
save_whatsapp_history(tmp.path(), &guard);
@@ -748,7 +767,10 @@ mod tests {
"Timmy",
"@timmy:home.local",
);
assert!(result.is_none(), "'status' should not be recognised as rmtree");
assert!(
result.is_none(),
"'status' should not be recognised as rmtree"
);
}
// ── assign command extraction ──────────────────────────────────────
@@ -805,6 +827,9 @@ mod tests {
"Timmy",
"@timmy:home.local",
);
assert!(result.is_none(), "'status' should not be recognised as assign");
assert!(
result.is_none(),
"'status' should not be recognised as assign"
);
}
}
+3 -6
View File
@@ -66,14 +66,11 @@ pub fn markdown_to_whatsapp(text: &str) -> String {
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
static RE_BOLD_ITALIC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
static RE_BOLD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
static RE_HR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
static RE_HR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
// 1. Protect fenced code blocks by replacing them with placeholders.
let mut code_blocks: Vec<String> = Vec::new();
+6 -2
View File
@@ -2,9 +2,9 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::history::MessagingWindowTracker;
use crate::chat::{ChatTransport, MessageId};
use crate::slog;
use super::history::MessagingWindowTracker;
// ── API base URLs (overridable for tests) ────────────────────────────────
@@ -55,7 +55,11 @@ impl WhatsAppTransport {
}
#[cfg(test)]
pub(crate) fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
pub(crate) fn with_api_base(
phone_number_id: String,
access_token: String,
api_base: String,
) -> Self {
Self {
phone_number_id,
access_token,
+3 -4
View File
@@ -13,9 +13,9 @@ pub mod history;
pub mod meta;
pub mod twilio;
pub use history::{load_whatsapp_history, MessagingWindowTracker, WhatsAppConversationHistory};
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
pub use meta::WhatsAppTransport;
pub use twilio::{extract_twilio_text_messages, TwilioWhatsAppTransport};
pub use twilio::{TwilioWhatsAppTransport, extract_twilio_text_messages};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
@@ -132,8 +132,7 @@ pub struct WhatsAppWebhookContext {
/// Permission requests from the MCP `prompt_permission` tool arrive here.
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
/// Pending permission replies keyed by sender phone number.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
/// Seconds before an unanswered permission prompt is auto-denied.
pub permission_timeout_secs: u64,
}