story-kit: merge 183_story_refactor_matrix_bot_to_use_claude_code_provider_instead_of_direct_anthropic_api
This commit is contained in:
1423
Cargo.lock
generated
1423
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -29,3 +29,4 @@ uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
|||||||
tokio-tungstenite = "0.28.0"
|
tokio-tungstenite = "0.28.0"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
filetime = "0.2"
|
filetime = "0.2"
|
||||||
|
matrix-sdk = { version = "0.9", default-features = false, features = ["native-tls"] }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
|
matrix-sdk = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod http;
|
|||||||
mod io;
|
mod io;
|
||||||
mod llm;
|
mod llm;
|
||||||
pub mod log_buffer;
|
pub mod log_buffer;
|
||||||
|
mod matrix;
|
||||||
mod state;
|
mod state;
|
||||||
mod store;
|
mod store;
|
||||||
mod workflow;
|
mod workflow;
|
||||||
@@ -113,7 +114,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
let startup_reconciliation_tx = reconciliation_tx.clone();
|
let startup_reconciliation_tx = reconciliation_tx.clone();
|
||||||
// Clone for shutdown cleanup — kill orphaned PTY children before exiting.
|
// Clone for shutdown cleanup — kill orphaned PTY children before exiting.
|
||||||
let agents_for_shutdown = Arc::clone(&agents);
|
let agents_for_shutdown = Arc::clone(&agents);
|
||||||
|
|
||||||
let ctx = AppContext {
|
let ctx = AppContext {
|
||||||
state: app_state,
|
state: app_state,
|
||||||
store,
|
store,
|
||||||
@@ -142,6 +142,12 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
|
// messages if `.story_kit/bot.toml` is present and enabled.
|
||||||
|
if let Some(ref root) = startup_root {
|
||||||
|
matrix::spawn_bot(root);
|
||||||
|
}
|
||||||
|
|
||||||
// On startup:
|
// On startup:
|
||||||
// 1. Reconcile any stories whose agent work was committed while the server was
|
// 1. Reconcile any stories whose agent work was committed while the server was
|
||||||
// offline (worktree has commits ahead of master but pipeline didn't advance).
|
// offline (worktree has commits ahead of master but pipeline didn't advance).
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
server/src/matrix/config.rs
Normal file
135
server/src/matrix/config.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Configuration for the Matrix bot, read from `.story_kit/bot.toml`.
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct BotConfig {
|
||||||
|
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
|
||||||
|
pub homeserver: String,
|
||||||
|
/// Bot user ID, e.g. `@storykit:example.com`
|
||||||
|
pub username: String,
|
||||||
|
/// Bot password
|
||||||
|
pub password: String,
|
||||||
|
/// Matrix room ID to join, e.g. `!roomid:example.com`
|
||||||
|
pub room_id: String,
|
||||||
|
/// Set to `true` to enable the bot (default: false)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Previously used to select an Anthropic model. Now ignored — the bot
|
||||||
|
/// uses Claude Code which manages its own model selection. Kept for
|
||||||
|
/// backwards compatibility so existing bot.toml files still parse.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotConfig {
|
||||||
|
/// Load bot configuration from `.story_kit/bot.toml`.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the file does not exist, fails to parse, or has
|
||||||
|
/// `enabled = false`.
|
||||||
|
pub fn load(project_root: &Path) -> Option<Self> {
|
||||||
|
let path = project_root.join(".story_kit").join("bot.toml");
|
||||||
|
if !path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
let config: BotConfig = toml::from_str(&content)
|
||||||
|
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
if !config.enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_none_when_file_missing() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let result = BotConfig::load(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_none_when_disabled() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_id = "!abc:example.com"
|
||||||
|
enabled = false
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = BotConfig::load(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_config_when_enabled() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_id = "!abc:example.com"
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = BotConfig::load(tmp.path());
|
||||||
|
assert!(result.is_some());
|
||||||
|
let config = result.unwrap();
|
||||||
|
assert_eq!(config.homeserver, "https://matrix.example.com");
|
||||||
|
assert_eq!(config.username, "@bot:example.com");
|
||||||
|
assert_eq!(config.room_id, "!abc:example.com");
|
||||||
|
assert!(config.model.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_none_when_toml_invalid() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(sk.join("bot.toml"), "not valid toml {{{").unwrap();
|
||||||
|
let result = BotConfig::load(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_respects_optional_model() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_id = "!abc:example.com"
|
||||||
|
enabled = true
|
||||||
|
model = "claude-sonnet-4-6"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
server/src/matrix/mod.rs
Normal file
50
server/src/matrix/mod.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! Matrix bot integration for Story Kit.
|
||||||
|
//!
|
||||||
|
//! When a `.story_kit/bot.toml` file is present with `enabled = true`, the
|
||||||
|
//! server spawns a Matrix bot that:
|
||||||
|
//!
|
||||||
|
//! 1. Connects to the configured homeserver and joins the configured room.
|
||||||
|
//! 2. Listens for messages from other users in the room.
|
||||||
|
//! 3. Passes each message to Claude Code (the same provider as the web UI),
|
||||||
|
//! which has native access to Story Kit MCP tools.
|
||||||
|
//! 4. Posts Claude Code's response back to the room.
|
||||||
|
//!
|
||||||
|
//! The bot is optional — if `bot.toml` is missing or `enabled = false`, the
|
||||||
|
//! server starts normally with no Matrix connection.
|
||||||
|
|
||||||
|
mod bot;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
pub use config::BotConfig;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Attempt to start the Matrix bot.
|
||||||
|
///
|
||||||
|
/// Reads the bot configuration from `.story_kit/bot.toml`. If the file is
|
||||||
|
/// absent or `enabled = false`, this function returns immediately without
|
||||||
|
/// spawning anything — the server continues normally.
|
||||||
|
///
|
||||||
|
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
||||||
|
pub fn spawn_bot(project_root: &Path) {
|
||||||
|
let config = match BotConfig::load(project_root) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::slog!(
|
||||||
|
"[matrix-bot] Starting Matrix bot → homeserver={} room={}",
|
||||||
|
config.homeserver,
|
||||||
|
config.room_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let root = project_root.to_path_buf();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = bot::run_bot(config, root).await {
|
||||||
|
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user