Files
storkit/server/src/matrix/config.rs
Dave 81a5660f11 story-kit: merge 277 - bot uses configured display_name
Adds system_prompt parameter to chat_stream so the Matrix bot
passes "Your name is {name}" to Claude Code. Reads display_name
from bot.toml config. Resolved conflicts by integrating bot_name
into master's permission-handling code structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:23:50 +00:00

382 lines
12 KiB
Rust

use serde::Deserialize;
use std::path::Path;
fn default_history_size() -> usize {
20
}
fn default_permission_timeout_secs() -> u64 {
120
}
/// 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 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<String>,
/// Deprecated: use `room_ids` (list) instead. Still accepted so existing
/// `bot.toml` files continue to work without modification.
#[serde(default)]
pub room_id: Option<String>,
/// Set to `true` to enable the bot (default: false)
#[serde(default)]
pub enabled: bool,
/// Matrix user IDs allowed to interact with the bot.
/// If empty or omitted, the bot ignores ALL messages (fail-closed).
#[serde(default)]
pub allowed_users: Vec<String>,
/// 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,
/// Timeout in seconds for permission prompts surfaced to the Matrix room.
/// If the user does not respond within this window the permission is denied
/// (fail-closed). Defaults to 120 seconds.
#[serde(default = "default_permission_timeout_secs")]
pub permission_timeout_secs: u64,
/// 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>,
/// Display name the bot uses to identify itself in conversations.
/// If unset, the bot falls back to "Assistant".
#[serde(default)]
pub display_name: Option<String>,
}
impl BotConfig {
/// Load bot configuration from `.story_kit/bot.toml`.
///
/// 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<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 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)]
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_ids = ["!abc:example.com"]
enabled = false
"#,
)
.unwrap();
let result = BotConfig::load(tmp.path());
assert!(result.is_none());
}
#[test]
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();
fs::write(
sk.join("bot.toml"),
r#"
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_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_ids = ["!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"));
}
#[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);
}
#[test]
fn load_reads_display_name() {
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
display_name = "Timmy"
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.display_name.as_deref(), Some("Timmy"));
}
#[test]
fn load_display_name_defaults_to_none_when_absent() {
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!(config.display_name.is_none());
}
#[test]
fn load_uses_default_permission_timeout() {
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.permission_timeout_secs, 120);
}
#[test]
fn load_respects_custom_permission_timeout() {
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
permission_timeout_secs = 60
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.permission_timeout_secs, 60);
}
#[test]
fn load_ignores_legacy_require_verified_devices_key() {
// Old bot.toml files that still have `require_verified_devices = true`
// must parse successfully — the field is simply ignored now that
// verification is always enforced unconditionally.
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
require_verified_devices = true
"#,
)
.unwrap();
// Should still load successfully despite the unknown field.
let config = BotConfig::load(tmp.path());
assert!(
config.is_some(),
"bot.toml with legacy require_verified_devices key must still load"
);
}
}