diff --git a/Cargo.lock b/Cargo.lock index f0b671a..0c8ddcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,9 +166,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.34" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e86f6d3dc9dc4352edeea6b8e499e13e3f5dc3b964d7ca5fd411415a3498473" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" dependencies = [ "compression-codecs", "compression-core", @@ -191,7 +191,7 @@ dependencies = [ "eventsource-stream", "futures", "rand 0.9.2", - "reqwest 0.12.24", + "reqwest 0.12.25", "reqwest-eventsource 0.6.0", "secrecy", "serde", @@ -297,6 +297,7 @@ dependencies = [ "chrono", "etke_openai_api_rust", "matrix-sdk", + "mime_guess", "mxidwc", "mxlink", "quick_cache", @@ -332,9 +333,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bit-set" @@ -458,9 +459,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -529,9 +530,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.33" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302266479cb963552d11bd042013a58ef1adc56768016c8b82b4199488f2d4ad" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" dependencies = [ "compression-core", "flate2", @@ -1156,9 +1157,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "flate2" -version = "1.1.7" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2152dbcb980c05735e2a651d96011320a949eb31a0c8b38b72645ce97dec676" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -1771,9 +1772,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1785,9 +1786,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -2217,7 +2218,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "reqwest 0.12.24", + "reqwest 0.12.25", "ruma", "serde", "serde_html_form", @@ -2610,7 +2611,7 @@ dependencies = [ "getrandom 0.2.16", "http 1.4.0", "rand 0.8.5", - "reqwest 0.12.24", + "reqwest 0.12.25", "serde", "serde_json", "serde_path_to_error", @@ -3167,11 +3168,10 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ - "async-compression", "base64 0.22.1", "bytes", "futures-core", @@ -3240,7 +3240,7 @@ dependencies = [ "mime", "nom", "pin-project-lite", - "reqwest 0.12.24", + "reqwest 0.12.25", "thiserror 1.0.69", ] @@ -3875,9 +3875,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" @@ -4297,9 +4297,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.8" +version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9b7ac41d92f2d2803f233e297127bac397df7b337e0460a1cc39d6c006dee4" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ "indexmap", "toml_datetime", @@ -4333,17 +4333,22 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 1e9b35e..a44cb99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ chrono = { version = "0.4.*", default-features = false, features = ["std", "now" # We'd rather not depend on this, but we cannot use the ruma-events EventContent macro without it. # We add the `native-tls` feature, because of https://github.com/etkecc/rust-mxlink/issues/1 matrix-sdk = { version = "0.16.0", default-features = false, features = ["native-tls"] } +mime_guess = "2.0.*" mxidwc = "1.0.*" mxlink = ">=1.11.0" etke_openai_api_rust = "0.1.*" diff --git a/etc/app/config.yml.dist b/etc/app/config.yml.dist index 2495970..4be6585 100644 --- a/etc/app/config.yml.dist +++ b/etc/app/config.yml.dist @@ -11,6 +11,12 @@ user: # Leave empty to use the default (baibot). name: baibot + # An optional path to an image file to be used as a custom avatar image. + # - null or empty string: use the default avatar + # - "keep": don't touch the avatar, keep whatever is already set + # - any other value: path to a custom avatar image file + avatar: null + encryption: # An optional passphrase to use for backing up and recovering the bot's encryption keys. # You can use any string here. diff --git a/src/bot/implementation.rs b/src/bot/implementation.rs index c55a3a3..224d149 100644 --- a/src/bot/implementation.rs +++ b/src/bot/implementation.rs @@ -1,3 +1,4 @@ +use std::fs; use std::sync::Arc; use std::{future::Future, pin::Pin}; @@ -18,12 +19,13 @@ use mxlink::helpers::account_data_config::{ RoomConfigManager as AccountDataRoomConfigManager, }; use mxlink::helpers::encryption::Manager as EncryptionManager; +use mxlink::mime::Mime; use crate::agent::Manager as AgentManager; use crate::entity::catch_up_marker::{ CatchUpMarker, CatchUpMarkerManager, DelayedCatchUpMarkerManager, }; -use crate::entity::cfg::Config; +use crate::entity::cfg::{Avatar, Config}; use crate::entity::globalconfig::{GlobalConfig, GlobalConfigurationManager}; use crate::entity::roomconfig::{RoomConfig, RoomConfigurationManager}; @@ -316,34 +318,72 @@ impl Bot { } } - let should_update_avatar = match ¤t_avatar_url { - Some(avatar_url) => { - let request = MediaRequestParameters { - source: MediaSource::Plain(avatar_url.to_owned()), - format: MediaFormat::File, - }; - - let content = media - .get_media_content(&request, true) - .await - .map_err(|e| anyhow::anyhow!("Failed fetching existing avatar: {:?}", e))?; - - content.as_slice() != LOGO_BYTES + let desired_avatar: Option<(Vec, Mime)> = match &self.inner.config.user.avatar { + Avatar::Keep => { + tracing::info!("Avatar configured to keep current, skipping avatar management"); + None + } + Avatar::Default => { + tracing::info!("Avatar configured to use default"); + Some(( + LOGO_BYTES.to_vec(), + LOGO_MIME_TYPE + .parse() + .expect("Failed parsing mime type for logo"), + )) + } + Avatar::Custom(avatar_path) => { + tracing::info!(?avatar_path, "Avatar configured to use custom path"); + let bytes = fs::read(avatar_path).map_err(|e| { + anyhow::anyhow!("Failed reading avatar from {:?}: {:?}", avatar_path, e) + })?; + let mime = mime_guess::from_path(avatar_path).first_or_octet_stream(); + tracing::debug!(?mime, bytes_len = bytes.len(), "Loaded custom avatar"); + Some((bytes, mime)) } - None => true, }; - if should_update_avatar { - tracing::info!("Updating avatar.."); + if let Some((desired_bytes, mime_type)) = desired_avatar { + let should_update_avatar = match ¤t_avatar_url { + Some(avatar_url) => { + tracing::debug!(?avatar_url, "Fetching current avatar to compare"); + let request = MediaRequestParameters { + source: MediaSource::Plain(avatar_url.to_owned()), + format: MediaFormat::File, + }; - let mime_type = LOGO_MIME_TYPE - .parse() - .expect("Failed parsing mime type for logo"); + let content = media + .get_media_content(&request, true) + .await + .map_err(|e| anyhow::anyhow!("Failed fetching existing avatar: {:?}", e))?; - account - .upload_avatar(&mime_type, LOGO_BYTES.to_vec()) - .await - .map_err(|e| anyhow::anyhow!("Failed uploading avatar: {:?}", e))?; + let needs_update = content.as_slice() != desired_bytes; + + tracing::debug!( + current_bytes_len = content.len(), + desired_bytes_len = desired_bytes.len(), + ?needs_update, + "Compared current and desired avatar" + ); + + needs_update + } + None => { + tracing::debug!("No current avatar set, will upload"); + true + } + }; + + if should_update_avatar { + tracing::info!("Updating avatar.."); + account + .upload_avatar(&mime_type, desired_bytes) + .await + .map_err(|e| anyhow::anyhow!("Failed uploading avatar: {:?}", e))?; + tracing::info!("Avatar updated successfully"); + } else { + tracing::debug!("Avatar already up to date, skipping upload"); + } } Ok(()) diff --git a/src/bot/load_config.rs b/src/bot/load_config.rs index d56f6aa..58559d5 100644 --- a/src/bot/load_config.rs +++ b/src/bot/load_config.rs @@ -5,7 +5,7 @@ use anyhow::anyhow; use crate::agent::AgentPurpose; -pub use crate::entity::cfg::{Config, defaults as cfg_defaults, env as cfg_env}; +pub use crate::entity::cfg::{Avatar, Config, defaults as cfg_defaults, env as cfg_env}; pub fn load() -> anyhow::Result { let config_file_path = env::var(cfg_env::BAIBOT_CONFIG_FILE_PATH) @@ -37,6 +37,9 @@ pub fn load() -> anyhow::Result { config.user.encryption.recovery_reset_allowed = value.parse::()?; } cfg_env::BAIBOT_USER_NAME => config.user.name = value, + cfg_env::BAIBOT_USER_AVATAR => { + config.user.avatar = Avatar::from_string(value); + } cfg_env::BAIBOT_COMMAND_PREFIX => config.command_prefix = value, cfg_env::BAIBOT_ROOM_POST_JOIN_SELF_INTRODUCTION_ENABLED => { config.room.post_join_self_introduction_enabled = value.parse::()?; diff --git a/src/entity/cfg/config.rs b/src/entity/cfg/config.rs index 875e3ac..0e7d977 100644 --- a/src/entity/cfg/config.rs +++ b/src/entity/cfg/config.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use mxlink::helpers::encryption::EncryptionKey; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ agent::{AgentDefinition, AgentPurpose, PublicIdentifier}, @@ -83,6 +83,52 @@ impl ConfigHomeserver { } } +/// 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, PartialEq, Serialize)] +pub enum Avatar { + /// Use the built-in default avatar + Default, + /// Keep the current avatar, don't change it + Keep, + /// Use a custom avatar from the specified file path + Custom(String), +} + +impl Default for Avatar { + fn default() -> Self { + Avatar::Default + } +} + +impl<'de> Deserialize<'de> for Avatar { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value: Option = 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, @@ -93,6 +139,9 @@ pub struct ConfigUser { #[serde(default)] pub encryption: ConfigUserEncryption, + + #[serde(default)] + pub avatar: Avatar, } impl ConfigUser { diff --git a/src/entity/cfg/env.rs b/src/entity/cfg/env.rs index cfba7b6..101fd0e 100644 --- a/src/entity/cfg/env.rs +++ b/src/entity/cfg/env.rs @@ -6,6 +6,7 @@ pub const BAIBOT_HOMESERVER_URL: &str = "BAIBOT_HOMESERVER_URL"; pub const BAIBOT_USER_MXID_LOCALPART: &str = "BAIBOT_USER_MXID_LOCALPART"; pub const BAIBOT_USER_PASSWORD: &str = "BAIBOT_USER_PASSWORD"; pub const BAIBOT_USER_NAME: &str = "BAIBOT_USER_NAME"; +pub const BAIBOT_USER_AVATAR: &str = "BAIBOT_USER_AVATAR"; pub const BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE: &str = "BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE"; pub const BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED: &str = diff --git a/src/entity/cfg/mod.rs b/src/entity/cfg/mod.rs index c4917b6..b07a737 100644 --- a/src/entity/cfg/mod.rs +++ b/src/entity/cfg/mod.rs @@ -2,4 +2,4 @@ mod config; pub mod defaults; pub mod env; -pub use config::Config; +pub use config::{Avatar, Config};