huskies: merge 626_refactor_introduce_services_bundle_and_migrate_appcontext_matrix_transport
This commit is contained in:
@@ -1,24 +1,27 @@
|
||||
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use crate::service::timer::TimerStore;
|
||||
use crate::services::Services;
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::history::ConversationHistory;
|
||||
|
||||
/// Shared context injected into Matrix event handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct BotContext {
|
||||
pub bot_user_id: OwnedUserId,
|
||||
/// Shared services bundle (project root, agent pool, bot identity, permissions).
|
||||
pub services: Arc<Services>,
|
||||
/// Matrix-specific parsed user ID (e.g. `@timmy:homeserver.local`).
|
||||
/// Transport-specific — kept separate from `services.bot_user_id` (String)
|
||||
/// because Matrix SDK APIs require `OwnedUserId` for comparisons and
|
||||
/// `.localpart()` extraction.
|
||||
pub matrix_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,
|
||||
@@ -28,27 +31,6 @@ pub struct BotContext {
|
||||
/// 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,
|
||||
@@ -78,12 +60,12 @@ impl BotContext {
|
||||
/// 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 {
|
||||
pub async fn effective_project_root(&self) -> std::path::PathBuf {
|
||||
if let Some(ref ap) = self.gateway_active_project {
|
||||
let name = ap.read().await.clone();
|
||||
self.project_root.join(&name)
|
||||
self.services.project_root.join(&name)
|
||||
} else {
|
||||
self.project_root.clone()
|
||||
self.services.project_root.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +120,7 @@ impl BotContext {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -145,6 +128,52 @@ mod tests {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// Build a test `Services` bundle with the given project root.
|
||||
fn test_services(project_root: PathBuf) -> Arc<Services> {
|
||||
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||
Arc::new(Services {
|
||||
project_root,
|
||||
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
|
||||
bot_name: "Assistant".to_string(),
|
||||
bot_user_id: "@bot:example.com".to_string(),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
|
||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a minimal `BotContext` for testing with the given Services and
|
||||
/// optional gateway active project.
|
||||
fn test_bot_context(
|
||||
services: Arc<Services>,
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: BTreeMap<String, String>,
|
||||
) -> BotContext {
|
||||
BotContext {
|
||||
services,
|
||||
matrix_user_id: make_user_id("@bot:example.com"),
|
||||
target_room_ids: vec![],
|
||||
allowed_users: vec![],
|
||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
history_size: 20,
|
||||
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
|
||||
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::service::timer::TimerStore::load(
|
||||
std::path::PathBuf::from("/tmp/timers.json"),
|
||||
)),
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_context_is_clone() {
|
||||
// BotContext must be Clone for the Matrix event handler injection.
|
||||
@@ -154,36 +183,8 @@ mod tests {
|
||||
|
||||
#[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::service::timer::TimerStore::load(
|
||||
std::path::PathBuf::from("/tmp/timers.json"),
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects: vec![],
|
||||
gateway_project_urls: BTreeMap::new(),
|
||||
};
|
||||
let services = test_services(PathBuf::from("/projects/myapp"));
|
||||
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
|
||||
assert_eq!(
|
||||
ctx.effective_project_root().await,
|
||||
PathBuf::from("/projects/myapp")
|
||||
@@ -192,39 +193,17 @@ mod tests {
|
||||
|
||||
#[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 services = test_services(PathBuf::from("/gateway"));
|
||||
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::service::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([
|
||||
let ctx = test_bot_context(
|
||||
services,
|
||||
Some(Arc::clone(&active)),
|
||||
vec!["huskies".into(), "robot-studio".into()],
|
||||
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")
|
||||
@@ -233,46 +212,23 @@ mod tests {
|
||||
|
||||
#[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 services = test_services(PathBuf::from("/gateway"));
|
||||
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::service::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([
|
||||
let ctx = test_bot_context(
|
||||
services,
|
||||
Some(Arc::clone(&active)),
|
||||
vec!["huskies".into(), "robot-studio".into()],
|
||||
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!(
|
||||
@@ -283,37 +239,8 @@ mod tests {
|
||||
|
||||
#[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::service::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 services = test_services(PathBuf::from("/tmp"));
|
||||
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
|
||||
let _cloned = ctx.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user