use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::slog; use matrix_sdk::{ Client, config::SyncSettings, event_handler::Ctx, room::Room, ruma::{ OwnedRoomId, OwnedUserId, events::room::message::{ MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent, }, }, }; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::watch; use super::config::BotConfig; /// Shared context injected into Matrix event handlers. #[derive(Clone)] pub struct BotContext { pub bot_user_id: OwnedUserId, pub target_room_id: OwnedRoomId, pub project_root: PathBuf, } /// Connect to the Matrix homeserver, join the configured room, and start /// listening for messages. Runs the full Matrix sync loop — call from a /// `tokio::spawn` task so it doesn't block the main thread. pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), String> { let store_path = project_root.join(".story_kit").join("matrix_store"); let client = Client::builder() .homeserver_url(&config.homeserver) .sqlite_store(&store_path, None) .build() .await .map_err(|e| format!("Failed to build Matrix client: {e}"))?; // Login client .matrix_auth() .login_username(&config.username, &config.password) .initial_device_display_name("Story Kit Bot") .await .map_err(|e| format!("Matrix login failed: {e}"))?; let bot_user_id = client .user_id() .ok_or_else(|| "No user ID after login".to_string())? .to_owned(); slog!("[matrix-bot] Logged in as {bot_user_id}"); // Parse and join the configured room let target_room_id: OwnedRoomId = config .room_id .parse() .map_err(|_| format!("Invalid room ID '{}'", config.room_id))?; // Try to join the room with a timeout. Conduit sometimes hangs or // returns errors on join if the bot is already a member. match tokio::time::timeout( std::time::Duration::from_secs(10), client.join_room_by_id(&target_room_id), ) .await { Ok(Ok(_)) => slog!("[matrix-bot] Joined room {target_room_id}"), Ok(Err(e)) => slog!("[matrix-bot] Join room error (may already be a member): {e}"), Err(_) => slog!("[matrix-bot] Join room timed out (may already be a member)"), } let ctx = BotContext { bot_user_id, target_room_id, project_root, }; // Register event handler and inject shared context client.add_event_handler_context(ctx); client.add_event_handler(on_room_message); slog!("[matrix-bot] Starting Matrix sync loop"); // This blocks until the connection is terminated or an error occurs. client .sync(SyncSettings::default()) .await .map_err(|e| format!("Matrix sync error: {e}"))?; Ok(()) } /// Matrix event handler for room messages. Each invocation spawns an /// independent task so the sync loop is not blocked by LLM calls. async fn on_room_message( ev: OriginalSyncRoomMessageEvent, room: Room, Ctx(ctx): Ctx, ) { slog!( "[matrix-bot] Event received: room={} sender={} target={}", room.room_id(), ev.sender, ctx.target_room_id ); // Only handle messages in the configured room if room.room_id() != &*ctx.target_room_id { slog!("[matrix-bot] Ignoring message from wrong room"); return; } // Ignore the bot's own messages to prevent echo loops if ev.sender == ctx.bot_user_id { return; } // Only handle plain text messages let MessageType::Text(text_content) = ev.content.msgtype else { return; }; let user_message = text_content.body.clone(); slog!("[matrix-bot] Message from {}: {user_message}", ev.sender); // Spawn a separate task so the Matrix sync loop is not blocked while we // wait for the LLM response (which can take several seconds). tokio::spawn(async move { handle_message(room, ctx, user_message).await; }); } async fn handle_message(room: Room, ctx: BotContext, user_message: String) { match call_claude_code(&ctx.project_root, &user_message).await { Ok(response) => { let _ = room .send(RoomMessageEventContent::text_plain(response)) .await; } Err(e) => { slog!("[matrix-bot] LLM error: {e}"); let _ = room .send(RoomMessageEventContent::text_plain(format!( "Error processing your request: {e}" ))) .await; } } } /// Call Claude Code with the user's message. /// /// Uses the same `ClaudeCodeProvider` as the web UI chat. Claude Code manages /// its own tools (including MCP tools) natively — no separate tool schemas or /// HTTP self-calls needed. async fn call_claude_code( project_root: &Path, user_message: &str, ) -> Result { let provider = ClaudeCodeProvider::new(); // Create a cancel channel that never fires — the bot doesn't support // mid-request cancellation (Matrix messages are fire-and-forget). let (cancel_tx, mut cancel_rx) = watch::channel(false); // Keep the sender alive for the duration of the call. let _cancel_tx = cancel_tx; // Collect text tokens into the final response. We don't stream to Matrix // (each message is posted as a single reply), so we just accumulate. let response_text = Arc::new(std::sync::Mutex::new(String::new())); let response_clone = Arc::clone(&response_text); let ClaudeCodeResult { messages, .. } = provider .chat_stream( user_message, &project_root.to_string_lossy(), None, // No session resumption for now (see story 182) &mut cancel_rx, move |token| { response_clone.lock().unwrap().push_str(token); }, |_thinking| {}, // Discard thinking tokens |_activity| {}, // Discard activity signals ) .await?; // Prefer the accumulated streamed text. If nothing was streamed (e.g. // Claude Code returned only tool calls with no final text), fall back to // extracting the last assistant message from the structured result. let streamed = response_text.lock().unwrap().clone(); if !streamed.is_empty() { return Ok(streamed); } // Fallback: find the last assistant message let last_text = messages .iter() .rev() .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() { Err("Claude Code returned no response text".to_string()) } else { Ok(last_text) } } #[cfg(test)] mod tests { use super::*; #[test] fn bot_context_is_clone() { // BotContext must be Clone for the Matrix event handler injection. fn assert_clone() {} assert_clone::(); } #[tokio::test] async fn call_claude_code_returns_error_when_claude_not_installed() { // When `claude` binary is not in PATH (or returns an error), the // provider should return an Err rather than panic. let fake_root = PathBuf::from("/tmp/nonexistent_project_root"); let result = call_claude_code(&fake_root, "hello").await; // We expect either an error (claude not found) or a valid response // if claude happens to be installed. Both are acceptable — the key // property is that it doesn't panic. let _ = result; } }