From 4b4d221d6cb4f804a36ea8d9d036c4e71cf4ee26 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 25 Feb 2026 15:25:13 +0000 Subject: [PATCH] story-kit: merge 182_story_matrix_bot_conversation_context_and_multi_room --- .story_kit/bot.toml.example | 11 +- server/src/matrix/bot.rs | 399 +++++++++++++++++++++++++++++++----- server/src/matrix/config.rs | 147 +++++++++++-- server/src/matrix/mod.rs | 7 +- 4 files changed, 492 insertions(+), 72 deletions(-) diff --git a/.story_kit/bot.toml.example b/.story_kit/bot.toml.example index f2679c1..2d1ca6a 100644 --- a/.story_kit/bot.toml.example +++ b/.story_kit/bot.toml.example @@ -1,6 +1,15 @@ homeserver = "https://matrix.example.com" username = "@botname:example.com" password = "your-bot-password" -room_id = "!roomid:example.com" + +# List one or more rooms to listen in. Use a single-element list for one room. +room_ids = ["!roomid:example.com"] + +# Optional: the deprecated single-room key is still accepted for backwards compat. +# room_id = "!roomid:example.com" + allowed_users = ["@youruser:example.com"] enabled = false + +# Maximum conversation turns to remember per room (default: 20). +# history_size = 20 diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 59ee2df..213ddec 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -12,23 +12,66 @@ use matrix_sdk::{ }, }, }; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::watch; +use tokio::sync::Mutex as TokioMutex; use super::config::BotConfig; +// --------------------------------------------------------------------------- +// Conversation history types +// --------------------------------------------------------------------------- + +/// Role of a participant in the conversation history. +#[derive(Clone, Debug, PartialEq)] +pub enum ConversationRole { + /// A message sent by a Matrix room participant. + User, + /// A response generated by the bot / LLM. + Assistant, +} + +/// A single turn in the per-room conversation history. +#[derive(Clone, Debug)] +pub struct ConversationEntry { + pub role: ConversationRole, + /// Matrix user ID (e.g. `@alice:example.com`). Empty for assistant turns. + pub sender: String, + pub content: String, +} + +/// Per-room conversation history, keyed by room ID. +/// +/// Wrapped in `Arc>` so it can be shared across concurrent +/// event-handler tasks without blocking the sync loop. +pub type ConversationHistory = Arc>>>; + +// --------------------------------------------------------------------------- +// Bot context +// --------------------------------------------------------------------------- + /// Shared context injected into Matrix event handlers. #[derive(Clone)] pub struct BotContext { pub bot_user_id: OwnedUserId, - pub target_room_id: OwnedRoomId, + /// All room IDs the bot listens in. + pub target_room_ids: Vec, pub project_root: PathBuf, pub allowed_users: Vec, + /// 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, } -/// Connect to the Matrix homeserver, join the configured room, and start +// --------------------------------------------------------------------------- +// Bot entry point +// --------------------------------------------------------------------------- + +/// Connect to the Matrix homeserver, join all configured rooms, 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> { @@ -55,25 +98,6 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str 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)"), - } - if config.allowed_users.is_empty() { return Err( "allowed_users is empty in bot.toml — refusing to start (fail-closed). \ @@ -87,11 +111,48 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str config.allowed_users ); + // Parse and join all configured rooms. + let mut target_room_ids: Vec = Vec::new(); + for room_id_str in config.effective_room_ids() { + let room_id: OwnedRoomId = room_id_str + .parse() + .map_err(|_| format!("Invalid room ID '{room_id_str}'"))?; + + // Try to join 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(&room_id), + ) + .await + { + Ok(Ok(_)) => slog!("[matrix-bot] Joined room {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)"), + } + + target_room_ids.push(room_id); + } + + if target_room_ids.is_empty() { + return Err("No valid room IDs configured — cannot start".to_string()); + } + + slog!( + "[matrix-bot] Listening in {} room(s): {:?}", + target_room_ids.len(), + target_room_ids + ); + let ctx = BotContext { bot_user_id, - target_room_id, + target_room_ids, project_root, allowed_users: config.allowed_users, + history: Arc::new(TokioMutex::new(HashMap::new())), + history_size: config.history_size, }; // Register event handler and inject shared context @@ -109,6 +170,10 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str Ok(()) } +// --------------------------------------------------------------------------- +// Event handler +// --------------------------------------------------------------------------- + /// 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( @@ -116,25 +181,30 @@ async fn on_room_message( room: Room, Ctx(ctx): Ctx, ) { + let incoming_room_id = room.room_id().to_owned(); + slog!( - "[matrix-bot] Event received: room={} sender={} target={}", - room.room_id(), + "[matrix-bot] Event received: room={} sender={}", + incoming_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"); + // Only handle messages from rooms we are configured to listen in. + if !ctx + .target_room_ids + .iter() + .any(|r| r == &incoming_room_id) + { + slog!("[matrix-bot] Ignoring message from unconfigured room {incoming_room_id}"); return; } - // Ignore the bot's own messages to prevent echo loops + // Ignore the bot's own messages to prevent echo loops. if ev.sender == ctx.bot_user_id { return; } - // Only respond to users on the allowlist (fail-closed) + // Only respond to users on the allowlist (fail-closed). if !ctx.allowed_users.iter().any(|u| u == ev.sender.as_str()) { slog!( "[matrix-bot] Ignoring message from unauthorised user: {}", @@ -143,39 +213,72 @@ async fn on_room_message( return; } - // Only handle plain text messages + // Only handle plain text messages. let MessageType::Text(text_content) = ev.content.msgtype else { return; }; + let sender = ev.sender.to_string(); let user_message = text_content.body.clone(); - slog!("[matrix-bot] Message from {}: {user_message}", ev.sender); + slog!("[matrix-bot] Message from {sender}: {user_message}"); // 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; + handle_message(room, incoming_room_id, ctx, sender, user_message).await; }); } -/// Drain all complete paragraphs from `buffer` and return them. -/// -/// A paragraph boundary is a double newline (`\n\n`). Each drained paragraph -/// is trimmed of surrounding whitespace; empty paragraphs are discarded. -/// The buffer is left with only the remaining incomplete text. -pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec { - let mut paragraphs = Vec::new(); - while let Some(pos) = buffer.find("\n\n") { - let chunk = buffer[..pos].trim().to_string(); - *buffer = buffer[pos + 2..].to_string(); - if !chunk.is_empty() { - paragraphs.push(chunk); +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- + +/// Build a context string from the room's conversation history to prepend to +/// the user's current message. Returns an empty string when history is empty. +fn build_context_prefix( + history: &[ConversationEntry], + current_sender: &str, + current_message: &str, +) -> String { + if history.is_empty() { + return format!("{current_sender}: {current_message}"); + } + + let mut out = String::from("[Conversation history for this room]\n"); + for entry in history { + match entry.role { + ConversationRole::User => { + out.push_str(&format!("User ({}): {}\n", entry.sender, entry.content)); + } + ConversationRole::Assistant => { + out.push_str(&format!("Assistant: {}\n", entry.content)); + } } } - paragraphs + out.push('\n'); + out.push_str(&format!( + "Current message from {current_sender}: {current_message}" + )); + out } -async fn handle_message(room: Room, ctx: BotContext, user_message: String) { +async fn handle_message( + room: Room, + room_id: OwnedRoomId, + ctx: BotContext, + sender: String, + user_message: String, +) { + // Read current history for this room before calling the LLM. + let history_snapshot: Vec = { + let guard = ctx.history.lock().await; + guard.get(&room_id).cloned().unwrap_or_default() + }; + + // Build the prompt with conversation context. + let prompt_with_context = + build_context_prefix(&history_snapshot, &sender, &user_message); + let provider = ClaudeCodeProvider::new(); let (cancel_tx, mut cancel_rx) = watch::channel(false); // Keep the sender alive for the duration of the call. @@ -204,9 +307,9 @@ async fn handle_message(room: Room, ctx: BotContext, user_message: String) { let result = provider .chat_stream( - &user_message, + &prompt_with_context, &ctx.project_root.to_string_lossy(), - None, // No session resumption for now (see story 182) + None, // Each Matrix conversation turn is independent at the Claude Code session level. &mut cancel_rx, move |token| { let mut buf = buffer_for_callback.lock().unwrap(); @@ -227,10 +330,11 @@ async fn handle_message(room: Room, ctx: BotContext, user_message: String) { let remaining = buffer.lock().unwrap().trim().to_string(); let did_send_any = sent_any_chunk.load(Ordering::Relaxed); - match result { + let assistant_reply = match result { Ok(ClaudeCodeResult { messages, .. }) => { if !remaining.is_empty() { - let _ = msg_tx.send(remaining); + let _ = msg_tx.send(remaining.clone()); + remaining } else if !did_send_any { // Nothing was streamed at all (e.g. only tool calls with no // final text) — fall back to the last assistant message from @@ -242,23 +346,73 @@ async fn handle_message(room: Room, ctx: BotContext, user_message: String) { .map(|m| m.content.clone()) .unwrap_or_default(); if !last_text.is_empty() { - let _ = msg_tx.send(last_text); + let _ = msg_tx.send(last_text.clone()); } + last_text + } else { + remaining } } Err(e) => { slog!("[matrix-bot] LLM error: {e}"); - // Discard any partial buffered text and send the error as one message. - let _ = msg_tx.send(format!("Error processing your request: {e}")); + let err_msg = format!("Error processing your request: {e}"); + let _ = msg_tx.send(err_msg.clone()); + err_msg } - } + }; // Drop the sender to signal the posting task that no more messages will // arrive, then wait for all pending Matrix sends to complete. drop(msg_tx); let _ = post_task.await; + + // Record this exchange in the per-room conversation history. + if !assistant_reply.starts_with("Error processing") { + let mut guard = ctx.history.lock().await; + let entries = guard.entry(room_id).or_default(); + entries.push(ConversationEntry { + role: ConversationRole::User, + sender: sender.clone(), + content: user_message, + }); + entries.push(ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: assistant_reply, + }); + // Trim to the configured maximum, dropping the oldest entries first. + if entries.len() > ctx.history_size { + let excess = entries.len() - ctx.history_size; + entries.drain(..excess); + } + } } +// --------------------------------------------------------------------------- +// Paragraph buffering helper +// --------------------------------------------------------------------------- + +/// Drain all complete paragraphs from `buffer` and return them. +/// +/// A paragraph boundary is a double newline (`\n\n`). Each drained paragraph +/// is trimmed of surrounding whitespace; empty paragraphs are discarded. +/// The buffer is left with only the remaining incomplete text. +pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec { + let mut paragraphs = Vec::new(); + while let Some(pos) = buffer.find("\n\n") { + let chunk = buffer[..pos].trim().to_string(); + *buffer = buffer[pos + 2..].to_string(); + if !chunk.is_empty() { + paragraphs.push(chunk); + } + } + paragraphs +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -270,6 +424,8 @@ mod tests { assert_clone::(); } + // -- drain_complete_paragraphs ------------------------------------------ + #[test] fn drain_complete_paragraphs_no_boundary_returns_empty() { let mut buf = "Hello World".to_string(); @@ -341,4 +497,137 @@ mod tests { assert_eq!(all_paragraphs, vec!["First para.", "Second para."]); assert_eq!(buf, "Third."); } + + // -- build_context_prefix ----------------------------------------------- + + #[test] + fn build_context_prefix_empty_history() { + let prefix = build_context_prefix(&[], "@alice:example.com", "Hello!"); + assert_eq!(prefix, "@alice:example.com: Hello!"); + } + + #[test] + fn build_context_prefix_includes_history_entries() { + let history = vec![ + ConversationEntry { + role: ConversationRole::User, + sender: "@alice:example.com".to_string(), + content: "What is story 42?".to_string(), + }, + ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: "Story 42 is about…".to_string(), + }, + ]; + let prefix = build_context_prefix(&history, "@bob:example.com", "Tell me more."); + assert!(prefix.contains("[Conversation history for this room]")); + assert!(prefix.contains("User (@alice:example.com): What is story 42?")); + assert!(prefix.contains("Assistant: Story 42 is about…")); + assert!(prefix.contains("Current message from @bob:example.com: Tell me more.")); + } + + #[test] + fn build_context_prefix_attributes_multiple_users() { + let history = vec![ + ConversationEntry { + role: ConversationRole::User, + sender: "@alice:example.com".to_string(), + content: "First question".to_string(), + }, + ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: "First answer".to_string(), + }, + ConversationEntry { + role: ConversationRole::User, + sender: "@bob:example.com".to_string(), + content: "Follow-up".to_string(), + }, + ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: "Second answer".to_string(), + }, + ]; + let prefix = build_context_prefix(&history, "@alice:example.com", "Another question"); + assert!(prefix.contains("User (@alice:example.com): First question")); + assert!(prefix.contains("User (@bob:example.com): Follow-up")); + } + + // -- conversation history trimming -------------------------------------- + + #[tokio::test] + async fn history_trims_to_configured_size() { + let history: ConversationHistory = + Arc::new(TokioMutex::new(HashMap::new())); + let room_id: OwnedRoomId = "!test:example.com".parse().unwrap(); + let history_size = 4usize; // keep at most 4 entries + + // Add 6 entries (3 user + 3 assistant turns). + { + let mut guard = history.lock().await; + let entries = guard.entry(room_id.clone()).or_default(); + for i in 0..3usize { + entries.push(ConversationEntry { + role: ConversationRole::User, + sender: "@user:example.com".to_string(), + content: format!("msg {i}"), + }); + entries.push(ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: format!("reply {i}"), + }); + if entries.len() > history_size { + let excess = entries.len() - history_size; + entries.drain(..excess); + } + } + } + + let guard = history.lock().await; + let entries = guard.get(&room_id).unwrap(); + assert_eq!( + entries.len(), + history_size, + "history must be trimmed to history_size" + ); + // The oldest entries (msg 0 / reply 0) should have been dropped. + assert!( + entries.iter().all(|e| !e.content.contains("msg 0")), + "oldest entries must be dropped" + ); + } + + #[tokio::test] + async fn each_room_has_independent_history() { + let history: ConversationHistory = + Arc::new(TokioMutex::new(HashMap::new())); + let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap(); + let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap(); + + { + let mut guard = history.lock().await; + guard.entry(room_a.clone()).or_default().push(ConversationEntry { + role: ConversationRole::User, + sender: "@alice:example.com".to_string(), + content: "Room A message".to_string(), + }); + guard.entry(room_b.clone()).or_default().push(ConversationEntry { + role: ConversationRole::User, + sender: "@bob:example.com".to_string(), + content: "Room B message".to_string(), + }); + } + + let guard = history.lock().await; + let entries_a = guard.get(&room_a).unwrap(); + let entries_b = guard.get(&room_b).unwrap(); + assert_eq!(entries_a.len(), 1); + assert_eq!(entries_b.len(), 1); + assert_eq!(entries_a[0].content, "Room A message"); + assert_eq!(entries_b[0].content, "Room B message"); + } } diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index a27cf4f..80a97ca 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -1,6 +1,10 @@ use serde::Deserialize; use std::path::Path; +fn default_history_size() -> usize { + 20 +} + /// Configuration for the Matrix bot, read from `.story_kit/bot.toml`. #[derive(Deserialize, Clone, Debug)] pub struct BotConfig { @@ -10,8 +14,15 @@ pub struct BotConfig { pub username: String, /// Bot password pub password: String, - /// Matrix room ID to join, e.g. `!roomid:example.com` - pub room_id: String, + /// Matrix room IDs to join, e.g. `["!roomid:example.com"]`. + /// Use an array for multiple rooms; a single string is accepted via the + /// deprecated `room_id` key for backwards compatibility. + #[serde(default)] + pub room_ids: Vec, + /// Deprecated: use `room_ids` (list) instead. Still accepted so existing + /// `bot.toml` files continue to work without modification. + #[serde(default)] + pub room_id: Option, /// Set to `true` to enable the bot (default: false) #[serde(default)] pub enabled: bool, @@ -19,6 +30,11 @@ pub struct BotConfig { /// If empty or omitted, the bot ignores ALL messages (fail-closed). #[serde(default)] pub allowed_users: Vec, + /// Maximum number of conversation turns (user + assistant pairs) to keep + /// per room. When the history exceeds this limit the oldest messages are + /// dropped. Defaults to 20. + #[serde(default = "default_history_size")] + pub history_size: usize, /// 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. @@ -29,8 +45,8 @@ pub struct BotConfig { 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`. + /// Returns `None` if the file does not exist, fails to parse, has + /// `enabled = false`, or specifies no room IDs. pub fn load(project_root: &Path) -> Option { let path = project_root.join(".story_kit").join("bot.toml"); if !path.exists() { @@ -39,14 +55,33 @@ impl BotConfig { 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) + let mut config: BotConfig = toml::from_str(&content) .map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}")) .ok()?; if !config.enabled { return None; } + // Merge deprecated `room_id` (single string) into `room_ids` (list). + if let Some(single) = config.room_id.take() + && !config.room_ids.contains(&single) + { + config.room_ids.push(single); + } + if config.room_ids.is_empty() { + eprintln!( + "[matrix-bot] bot.toml has no room_ids configured — \ + add `room_ids = [\"!roomid:example.com\"]` to bot.toml" + ); + return None; + } Some(config) } + + /// Returns all configured room IDs as a flat list. Combines `room_ids` + /// and (after loading) any merged `room_id` value. + pub fn effective_room_ids(&self) -> &[String] { + &self.room_ids + } } #[cfg(test)] @@ -72,7 +107,7 @@ mod tests { homeserver = "https://matrix.example.com" username = "@bot:example.com" password = "secret" -room_id = "!abc:example.com" +room_ids = ["!abc:example.com"] enabled = false "#, ) @@ -82,7 +117,7 @@ enabled = false } #[test] - fn load_returns_config_when_enabled() { + fn load_returns_config_when_enabled_with_room_ids() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); fs::create_dir_all(&sk).unwrap(); @@ -92,18 +127,61 @@ enabled = false homeserver = "https://matrix.example.com" username = "@bot:example.com" password = "secret" +room_ids = ["!abc:example.com", "!def: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.effective_room_ids(), + &["!abc:example.com", "!def:example.com"] + ); + assert!(config.model.is_none()); + } + + #[test] + fn load_merges_deprecated_room_id_into_room_ids() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + // Old-style single room_id key — should still work. + 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 config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.effective_room_ids(), &["!abc:example.com"]); + } + + #[test] + fn load_returns_none_when_no_room_ids() { + 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" +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()); + assert!(result.is_none()); } #[test] @@ -127,7 +205,7 @@ enabled = true homeserver = "https://matrix.example.com" username = "@bot:example.com" password = "secret" -room_id = "!abc:example.com" +room_ids = ["!abc:example.com"] enabled = true model = "claude-sonnet-4-6" "#, @@ -136,4 +214,45 @@ model = "claude-sonnet-4-6" let config = BotConfig::load(tmp.path()).unwrap(); assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6")); } + + #[test] + fn load_uses_default_history_size() { + 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_ids = ["!abc:example.com"] +enabled = true +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.history_size, 20); + } + + #[test] + fn load_respects_custom_history_size() { + 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_ids = ["!abc:example.com"] +enabled = true +history_size = 50 +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.history_size, 50); + } } diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 6adb859..2699d10 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -11,6 +11,9 @@ //! //! The bot is optional — if `bot.toml` is missing or `enabled = false`, the //! server starts normally with no Matrix connection. +//! +//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in +//! `bot.toml`. Each room maintains its own independent conversation history. mod bot; mod config; @@ -36,9 +39,9 @@ pub fn spawn_bot(project_root: &Path) { }; crate::slog!( - "[matrix-bot] Starting Matrix bot → homeserver={} room={}", + "[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}", config.homeserver, - config.room_id + config.effective_room_ids() ); let root = project_root.to_path_buf();