Files
huskies/server/src/chat/transport/matrix/bot/context.rs
T

320 lines
14 KiB
Rust
Raw Normal View History

//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
use crate::agents::AgentPool;
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::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{RwLock, mpsc, oneshot};
use super::history::ConversationHistory;
/// Shared context injected into Matrix event handlers.
#[derive(Clone)]
pub struct BotContext {
pub bot_user_id: OwnedUserId,
/// All room IDs the bot listens in.
pub target_room_ids: Vec<OwnedRoomId>,
pub project_root: PathBuf,
pub allowed_users: Vec<String>,
/// Shared, per-room rolling conversation history.
pub history: ConversationHistory,
/// Maximum number of entries to keep per room before trimming the oldest.
pub history_size: usize,
/// Event IDs of messages the bot has sent. Used to detect replies to the
/// bot so it can continue a conversation thread without requiring an
/// explicit `@mention` on every follow-up.
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
/// Receiver for permission requests from the MCP `prompt_permission` tool.
/// During an active chat the bot locks this to poll for incoming requests.
pub perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
/// Per-room pending permission reply senders. When a permission prompt is
/// posted to a room the oneshot sender is stored here; when the user
/// replies (yes/no) the event handler resolves it.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<OwnedRoomId, oneshot::Sender<PermissionDecision>>>>,
/// How long to wait for a user to respond to a permission prompt before
/// denying (fail-closed).
pub permission_timeout_secs: u64,
/// The name the bot uses to refer to itself. Derived from `display_name`
/// in bot.toml; defaults to "Assistant" when unset.
pub bot_name: String,
/// Set of room IDs where ambient mode is active. In ambient mode the bot
/// responds to all messages rather than only addressed ones.
/// Uses a sync mutex since locks are never held across await points.
/// Room IDs are stored as plain strings (platform-agnostic).
pub ambient_rooms: Arc<std::sync::Mutex<HashSet<String>>>,
/// Agent pool for checking agent availability.
pub agents: Arc<AgentPool>,
/// Per-room htop monitoring sessions. Keyed by room ID; each entry holds
/// a stop-signal sender that the background task watches.
pub htop_sessions: super::super::htop::HtopSessions,
/// Chat transport used for sending and editing messages.
///
/// All message I/O goes through this abstraction so the bot logic works
/// with any platform, not just Matrix.
pub transport: Arc<dyn ChatTransport>,
/// Persistent store for pending deferred-start timers.
pub timer_store: Arc<TimerStore>,
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
/// `None` in standalone single-project mode.
pub gateway_active_project: Option<Arc<RwLock<String>>>,
/// In gateway mode: valid project names accepted by the `switch` command.
/// Empty in standalone mode.
pub gateway_projects: Vec<String>,
/// In gateway mode: mapping of project name → base URL (e.g. `"http://localhost:3001"`).
/// Used to proxy bot commands to the active project's `/api/bot/command` endpoint.
/// Empty in standalone mode.
pub gateway_project_urls: BTreeMap<String, String>,
}
impl BotContext {
/// Resolve the effective project root for command dispatch.
///
/// In gateway mode the bot's `project_root` is the gateway config directory.
/// Each project lives in a subdirectory named after the project, so the
/// effective root for commands is `project_root / active_project_name`.
/// In standalone (single-project) mode this returns `project_root` unchanged.
pub async fn effective_project_root(&self) -> PathBuf {
if let Some(ref ap) = self.gateway_active_project {
let name = ap.read().await.clone();
self.project_root.join(&name)
} else {
self.project_root.clone()
}
}
/// Returns `true` if the bot is running in gateway mode.
pub fn is_gateway(&self) -> bool {
self.gateway_active_project.is_some()
}
/// Return the base URL for the currently active project, if in gateway mode.
pub async fn active_project_url(&self) -> Option<String> {
let ap = self.gateway_active_project.as_ref()?;
let name = ap.read().await.clone();
self.gateway_project_urls.get(&name).cloned()
}
/// Proxy a bot command to the active project's `/api/bot/command` endpoint.
///
/// Returns the Markdown response from the project server, or an error
/// message if the request failed.
pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option<String> {
let base_url = self.active_project_url().await?;
let url = format!("{base_url}/api/bot/command");
let client = reqwest::Client::new();
let body = serde_json::json!({
"command": command,
"args": args,
});
match client.post(&url).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(json) => json
.get("response")
.and_then(|v| v.as_str())
.map(String::from),
Err(e) => Some(format!("Failed to parse response from project server: {e}")),
}
}
Ok(resp) => Some(format!(
"Project server returned HTTP {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
)),
Err(e) => Some(format!("Failed to reach project server at {url}: {e}")),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tokio::sync::mpsc;
fn make_user_id(s: &str) -> OwnedUserId {
s.parse().unwrap()
}
#[test]
fn bot_context_is_clone() {
// BotContext must be Clone for the Matrix event handler injection.
fn assert_clone<T: Clone>() {}
assert_clone::<BotContext>();
}
#[tokio::test]
async fn effective_project_root_standalone_returns_project_root() {
// In standalone mode (gateway_active_project is None), the effective root
// must equal the project_root exactly.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/projects/myapp"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: None,
gateway_projects: vec![],
gateway_project_urls: BTreeMap::new(),
};
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/projects/myapp")
);
}
#[tokio::test]
async fn effective_project_root_gateway_uses_active_project_subdir() {
// In gateway mode, the effective root must be config_dir / active_project_name.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let active = Arc::new(RwLock::new("huskies".to_string()));
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/gateway"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: Some(Arc::clone(&active)),
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
gateway_project_urls: BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
]),
};
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/gateway/huskies")
);
}
#[tokio::test]
async fn effective_project_root_gateway_reflects_project_switch() {
// Switching the active project must change the effective root.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let active = Arc::new(RwLock::new("huskies".to_string()));
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/gateway"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: Some(Arc::clone(&active)),
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
gateway_project_urls: BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
]),
};
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/gateway/huskies")
);
// Simulate switch_project changing the active project.
*active.write().await = "robot-studio".to_string();
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/gateway/robot-studio")
);
}
#[test]
fn bot_context_has_no_require_verified_devices_field() {
// Verification is always on — BotContext no longer has a toggle field.
// This test verifies the struct can be constructed and cloned without it.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/tmp"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: None,
gateway_projects: vec![],
gateway_project_urls: BTreeMap::new(),
};
// Clone must work (required by Matrix SDK event handler injection).
let _cloned = ctx.clone();
}
}