2026-02-25 12:42:11 +00:00
|
|
|
use serde::Deserialize;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
2026-02-25 15:25:13 +00:00
|
|
|
fn default_history_size() -> usize {
|
|
|
|
|
20
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 12:42:11 +00:00
|
|
|
/// 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,
|
2026-02-25 15:25:13 +00:00
|
|
|
/// 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>,
|
2026-02-25 12:42:11 +00:00
|
|
|
/// Set to `true` to enable the bot (default: false)
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub enabled: bool,
|
2026-02-25 14:59:20 +00:00
|
|
|
/// 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>,
|
2026-02-25 15:25:13 +00:00
|
|
|
/// 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,
|
2026-02-25 12:42:11 +00:00
|
|
|
/// 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`.
|
|
|
|
|
///
|
2026-02-25 15:25:13 +00:00
|
|
|
/// Returns `None` if the file does not exist, fails to parse, has
|
|
|
|
|
/// `enabled = false`, or specifies no room IDs.
|
2026-02-25 12:42:11 +00:00
|
|
|
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()?;
|
2026-02-25 15:25:13 +00:00
|
|
|
let mut config: BotConfig = toml::from_str(&content)
|
2026-02-25 12:42:11 +00:00
|
|
|
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
|
|
|
|
|
.ok()?;
|
|
|
|
|
if !config.enabled {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
2026-02-25 15:25:13 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-25 12:42:11 +00:00
|
|
|
Some(config)
|
|
|
|
|
}
|
2026-02-25 15:25:13 +00:00
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
}
|
2026-02-25 12:42:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"
|
2026-02-25 15:25:13 +00:00
|
|
|
room_ids = ["!abc:example.com"]
|
2026-02-25 12:42:11 +00:00
|
|
|
enabled = false
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = BotConfig::load(tmp.path());
|
|
|
|
|
assert!(result.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-02-25 15:25:13 +00:00
|
|
|
fn load_returns_config_when_enabled_with_room_ids() {
|
2026-02-25 12:42:11 +00:00
|
|
|
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"
|
2026-02-25 15:25:13 +00:00
|
|
|
room_ids = ["!abc:example.com", "!def:example.com"]
|
2026-02-25 12:42:11 +00:00
|
|
|
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");
|
2026-02-25 15:25:13 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
config.effective_room_ids(),
|
|
|
|
|
&["!abc:example.com", "!def:example.com"]
|
|
|
|
|
);
|
2026-02-25 12:42:11 +00:00
|
|
|
assert!(config.model.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 15:25:13 +00:00
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 12:42:11 +00:00
|
|
|
#[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"
|
2026-02-25 15:25:13 +00:00
|
|
|
room_ids = ["!abc:example.com"]
|
2026-02-25 12:42:11 +00:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-02-25 15:25:13 +00:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-02-25 12:42:11 +00:00
|
|
|
}
|