story-kit: merge 183_story_refactor_matrix_bot_to_use_claude_code_provider_instead_of_direct_anthropic_api
This commit is contained in:
219
server/src/matrix/bot.rs
Normal file
219
server/src/matrix/bot.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
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 client = Client::builder()
|
||||
.homeserver_url(&config.homeserver)
|
||||
.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))?;
|
||||
|
||||
client
|
||||
.join_room_by_id(&target_room_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to join room '{}': {e}", config.room_id))?;
|
||||
|
||||
slog!("[matrix-bot] Joined room {target_room_id}");
|
||||
|
||||
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<BotContext>,
|
||||
) {
|
||||
// Only handle messages in the configured room
|
||||
if room.room_id() != &*ctx.target_room_id {
|
||||
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<String, String> {
|
||||
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<T: Clone>() {}
|
||||
assert_clone::<BotContext>();
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user