Files
baibot-withmcp/src/entity/cfg/config.rs
Taylor Southwick 4852d1fe92 Add support for access tokens using MAS (#83)
* Add support for access tokens using MAS

* use 1.13.0

* Update dependencies

* Harden auth credential selection in matrix link init

Use the same non-empty access-token criterion for auth mode selection and bind the token directly from the branch condition.
Return explicit configuration errors for missing or empty `device_id`/`password` instead of panicking, so invalid auth config fails gracefully.

* Centralize and harden user auth config handling

Move authentication-mode resolution into typed config parsing with ConfigUserAuth,
so downstream login setup consumes validated credentials instead of re-checking raw optional fields.

Enforce explicit password-vs-token selection, validate token/device/user-id requirements in one place,
and normalize empty auth env overrides to unset values for consistent behavior across YAML and environment input.

* Add auth config unit tests

Move auth_config tests into a dedicated cfg test module file to keep production config code compact while preserving behavior coverage. The tests cover password/token mode selection, missing/both auth method rejection, missing device_id, and empty-value handling.

* Use conventional mxlink version requirement

Replace the unconventional wildcard lower-bound expression with a standard semver lower bound for readability and tooling consistency.

---------

Co-authored-by: Slavi Pantaleev <slavi@devture.com>
2026-03-07 10:26:40 +02:00

543 lines
16 KiB
Rust

use std::path::PathBuf;
use mxlink::helpers::encryption::EncryptionKey;
use mxlink::matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};
use serde::{Deserialize, Deserializer, Serialize};
use crate::{
agent::{AgentDefinition, AgentPurpose, PublicIdentifier},
entity::{globalconfig::GlobalConfig, roomconfig::RoomSettingsHandler},
};
#[derive(Debug, Deserialize)]
pub struct Config {
pub homeserver: ConfigHomeserver,
pub user: ConfigUser,
pub persistence: PersistenceConfig,
#[serde(default = "super::defaults::command_prefix")]
pub command_prefix: String,
#[serde(default)]
pub room: ConfigRoom,
pub access: ConfigAccess,
pub agents: ConfigAgents,
// Contains the initial global configuration values.
// Not all properties of the object make sense to be configured statically,
// so not all of them will be reflected onto the actual global configuration.
pub initial_global_config: ConfigInitialGlobalConfig,
#[serde(default = "super::defaults::logging")]
pub logging: String,
}
impl Config {
pub fn validate(&self) -> anyhow::Result<()> {
self.homeserver.validate()?;
self.user.validate(&self.homeserver.server_name)?;
self.persistence.validate()?;
self.room.validate()?;
self.access.validate()?;
if self.command_prefix.is_empty() {
return Err(anyhow::anyhow!(
"The command_prefix ({}) configuration must be set",
super::env::BAIBOT_COMMAND_PREFIX
));
}
self.agents.validate()?;
self.initial_global_config.clone().validate()?;
Ok(())
}
}
#[derive(Debug)]
pub enum ConfigUserAuth {
UserPassword {
username: String,
password: String,
},
AccessToken {
user_id: OwnedUserId,
device_id: OwnedDeviceId,
access_token: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigHomeserver {
pub server_name: String,
pub url: String,
}
impl ConfigHomeserver {
pub fn validate(&self) -> anyhow::Result<()> {
if self.server_name.is_empty() {
return Err(anyhow::anyhow!(
"The homeserver.server_name ({}) configuration must be set",
super::env::BAIBOT_HOMESERVER_SERVER_NAME
));
}
if self.url.is_empty() {
return Err(anyhow::anyhow!(
"The homeserver.url ({}) configuration must be set",
super::env::BAIBOT_HOMESERVER_URL
));
}
Ok(())
}
}
/// Configuration for the bot's avatar.
///
/// - `Default`: Use the built-in default avatar (null, empty string, or missing in config)
/// - `Keep`: Don't touch the avatar, keep whatever is already set ("keep" in config)
/// - `Custom(String)`: Use a custom avatar from the specified file path
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub enum Avatar {
/// Use the built-in default avatar
#[default]
Default,
/// Keep the current avatar, don't change it
Keep,
/// Use a custom avatar from the specified file path
Custom(String),
}
impl<'de> Deserialize<'de> for Avatar {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<String> = Option::deserialize(deserializer)?;
Ok(match value {
None => Avatar::Default,
Some(s) => Avatar::from_string(s),
})
}
}
impl Avatar {
pub fn from_string(value: String) -> Self {
if value.is_empty() {
Avatar::Default
} else if value.eq_ignore_ascii_case("keep") {
Avatar::Keep
} else {
Avatar::Custom(value)
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigUser {
pub mxid_localpart: String,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
#[serde(default = "super::defaults::name")]
pub name: String,
#[serde(default)]
pub encryption: ConfigUserEncryption,
#[serde(default)]
pub avatar: Avatar,
}
impl ConfigUser {
pub fn validate(&self, homeserver_server_name: &str) -> anyhow::Result<()> {
if self.mxid_localpart.is_empty() {
return Err(anyhow::anyhow!(
"The user.mxid_localpart ({}) configuration must be set",
super::env::BAIBOT_USER_MXID_LOCALPART
));
}
self.auth_config(homeserver_server_name)?;
if self.name.is_empty() {
return Err(anyhow::anyhow!(
"The name ({}) configuration must be set",
super::env::BAIBOT_USER_NAME
));
}
self.encryption.validate()?;
Ok(())
}
pub fn auth_config(&self, homeserver_server_name: &str) -> anyhow::Result<ConfigUserAuth> {
let password = self.password.as_deref().filter(|value| !value.is_empty());
let access_token = self
.access_token
.as_deref()
.filter(|value| !value.is_empty());
match (password, access_token) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"Set exactly one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
super::env::BAIBOT_USER_PASSWORD,
super::env::BAIBOT_USER_ACCESS_TOKEN,
super::env::BAIBOT_USER_DEVICE_ID
)),
(None, None) => Err(anyhow::anyhow!(
"Set one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
super::env::BAIBOT_USER_PASSWORD,
super::env::BAIBOT_USER_ACCESS_TOKEN,
super::env::BAIBOT_USER_DEVICE_ID
)),
(Some(password), None) => Ok(ConfigUserAuth::UserPassword {
username: self.mxid_localpart.to_owned(),
password: password.to_owned(),
}),
(None, Some(access_token)) => {
let device_id = self
.device_id
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"user.device_id ({}) must be set when using access token authentication",
super::env::BAIBOT_USER_DEVICE_ID
)
})?;
let user_id = OwnedUserId::try_from(format!(
"@{}:{}",
self.mxid_localpart, homeserver_server_name
))
.map_err(|e| anyhow::anyhow!("Invalid user ID: {e}"))?;
Ok(ConfigUserAuth::AccessToken {
user_id,
device_id: OwnedDeviceId::from(device_id),
access_token: access_token.to_owned(),
})
}
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ConfigUserEncryption {
pub recovery_passphrase: Option<String>,
pub recovery_reset_allowed: bool,
}
impl ConfigUserEncryption {
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(passphrase) = &self.recovery_passphrase
&& passphrase.is_empty()
{
return Err(anyhow::anyhow!(
"The user.encryption.recovery_passphrase ({}) configuration must either be null or set to a non-empty passphrase",
super::env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE
));
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PersistenceConfig {
#[serde(default = "super::defaults::persistence_data_dir_path")]
pub data_dir_path: Option<String>,
#[serde(default = "super::defaults::persistence_session_file_name")]
session_file_name: String,
#[serde(default = "super::defaults::persistence_db_dir_name")]
db_dir_name: String,
pub session_encryption_key: Option<String>,
pub config_encryption_key: Option<String>,
}
impl PersistenceConfig {
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(data_dir_path) = &self.data_dir_path {
let path = PathBuf::from(data_dir_path);
if !path.exists() {
return Err(anyhow::anyhow!(
"The persistence.data_dir_path ({}) directory ({}) must exist",
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH,
data_dir_path,
));
}
}
self.config_encryption_key()
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
}
pub fn session_file_path(&self) -> anyhow::Result<PathBuf> {
let Some(data_dir_path) = &self.data_dir_path else {
return Err(anyhow::anyhow!(
"The persistence.data_dir_path ({}) directory must be set",
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH
));
};
let mut path = PathBuf::from(data_dir_path);
path.push(&self.session_file_name);
Ok(path)
}
pub fn db_dir_path(&self) -> anyhow::Result<PathBuf> {
let Some(data_dir_path) = &self.data_dir_path else {
return Err(anyhow::anyhow!(
"The persistence.data_dir_path ({}) directory must be set",
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH
));
};
let mut path = PathBuf::from(data_dir_path);
path.push(&self.db_dir_name);
Ok(path)
}
pub fn session_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {
self.parse_encryption_key(&self.session_encryption_key).map_err(|err| {
anyhow::anyhow!(
"Encryption key specified in persistence.session_encryption_key ({}) is not valid: {}",
super::env::BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY,
err
)
})
}
pub fn config_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {
self.parse_encryption_key(&self.config_encryption_key).map_err(|err| {
anyhow::anyhow!(
"Encryption key specified in persistence.config_encryption_key ({}) is not valid: {}",
super::env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY,
err
)
})
}
fn parse_encryption_key(
&self,
value: &Option<String>,
) -> anyhow::Result<Option<EncryptionKey>, String> {
let key = match value {
Some(key) => {
if key.is_empty() {
None
} else {
Some(EncryptionKey::from_hex_str(key)?)
}
}
None => None,
};
Ok(key)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigRoom {
#[serde(default = "super::defaults::room_post_join_self_introduction_enabled")]
pub post_join_self_introduction_enabled: bool,
}
impl ConfigRoom {
pub fn validate(&self) -> anyhow::Result<()> {
Ok(())
}
}
impl Default for ConfigRoom {
fn default() -> Self {
Self {
post_join_self_introduction_enabled:
super::defaults::room_post_join_self_introduction_enabled(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigAccess {
// Contains the admin whitelist patterns before parsing into regex.
// Example: `["@*:example.com"]`
pub admin_patterns: Vec<String>,
}
impl ConfigAccess {
// Returns the the mxidwc-parsed regexes for the admin whitelist.
// Example: `["^@\.*:example\.com$"]`
pub fn admin_pattern_regexes(&self) -> anyhow::Result<Vec<regex::Regex>> {
mxidwc::parse_patterns_vector(&self.admin_patterns).map_err(|e| {
anyhow::anyhow!(
"Failed parsing access.admin_patterns ({}): {:?}",
super::env::BAIBOT_ACCESS_ADMIN_PATTERNS,
e
)
})
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.admin_patterns.is_empty() {
return Err(anyhow::anyhow!(
"The access.admin_patterns ({}) configuration must contain at least one pattern",
super::env::BAIBOT_ACCESS_ADMIN_PATTERNS
));
}
self.admin_pattern_regexes()?;
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigAgents {
pub static_definitions: Vec<AgentDefinition>,
}
impl ConfigAgents {
pub fn validate(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigInitialGlobalConfig {
#[serde(default)]
pub handler: RoomSettingsHandler,
pub user_patterns: Option<Vec<String>>,
}
impl ConfigInitialGlobalConfig {
fn user_pattern_regexes(&self) -> anyhow::Result<Option<Vec<regex::Regex>>> {
match &self.user_patterns {
Some(user_patterns) => {
let user_patterns = mxidwc::parse_patterns_vector(user_patterns).map_err(|e| {
anyhow::anyhow!(
"Failed parsing initial_global_config.user_patterns ({}): {}",
super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,
e
)
})?;
Ok(Some(user_patterns))
}
None => Ok(None),
}
}
pub fn validate(self) -> anyhow::Result<()> {
self.user_pattern_regexes()?;
for purpose in AgentPurpose::choices() {
let agent_id = self.handler.get_by_purpose(*purpose);
let Some(agent_id) = agent_id else {
// None is OK
continue;
};
let config_key = format!(
"initial_global_config.handler.{}",
purpose.as_str().replace("-", "_")
);
if agent_id.is_empty() {
return Err(anyhow::anyhow!(
"The {} configuration key must be pointing to a valid agent id or be set to null",
config_key,
));
}
let agent_identifier = PublicIdentifier::from_str(&agent_id);
let Some(agent_identifier) = agent_identifier else {
return Err(anyhow::anyhow!(
"The {} configuration key specifies an agent id (`{}`) that cannot be parsed. {}",
config_key,
agent_id,
crate::strings::agent::invalid_id_generic()
));
};
// We only allow statically-defined agents for now, although DynamicGlobal may make sense too.
let PublicIdentifier::Static(_) = agent_identifier else {
return Err(anyhow::anyhow!(
"The {} configuration key specifies an agent id (`{}`) which does not refer to a static agent.",
config_key,
agent_id,
));
};
}
let _: GlobalConfig = self.try_into()?;
Ok(())
}
}
impl TryInto<GlobalConfig> for ConfigInitialGlobalConfig {
type Error = anyhow::Error;
fn try_into(self) -> anyhow::Result<GlobalConfig> {
let mut entity = GlobalConfig::default();
if let Some(user_patterns) = self.user_patterns {
// We'd rather fail parsing this during startup than at runtime
let _ = mxidwc::parse_patterns_vector(&user_patterns).map_err(|err| {
anyhow::anyhow!(
"Bad initial_global_config.user_patterns ({}): {}",
super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,
err
)
})?;
entity.access.user_patterns = if user_patterns.is_empty() {
None
} else {
Some(user_patterns)
};
}
for purpose in AgentPurpose::choices() {
let agent_id = self.handler.get_by_purpose(*purpose);
entity
.fallback_room_settings
.handler
.set_by_purpose(*purpose, agent_id);
}
Ok(entity)
}
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod config_tests;