* 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>
543 lines
16 KiB
Rust
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;
|