Add support for custom avatars (via file path) and for not touching the already-set avatar

This is based on the work done in https://github.com/etkecc/baibot/pull/60 by https://github.com/Fmstrat (Ben Curtis),
with various changes on top to make the code more idiomatic and flexible.

This commit squashes the following patches (newest first):

- Improve handling of `user.avatar` configuration (null & empty string being the same now) and add support for a special `keep` value
- Minor import reordering
- Simplify avatar configuration (`user.avatar.source` -> `user.avatar`)
- Combine `logo_bytes` and `mime_type` determination logic and do not fall back to default avatar if reading the custom avatar file fails
- Switch from deprecated `mime_guess::guess_mime_type(avatar_path)` to `mime_guess::from_path(avatar_path).first_or_octet_stream()`
- Relax `mime_guess` constraint and order alphabetically
- Use `mime` from `mxlink`
- Add mime-type support and switch to user.avatar.source
- (Original work by Fmstrat) Add support for custom avatars

Co-authored-by: Fmstrat <nospam@nowsci.com>
This commit is contained in:
Slavi Pantaleev
2025-12-15 08:07:24 +02:00
parent 2801c78ad9
commit 062fbbb8ef
8 changed files with 159 additions and 54 deletions

59
Cargo.lock generated
View File

@@ -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",

View File

@@ -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.*"

View File

@@ -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.

View File

@@ -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 &current_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<u8>, 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 &current_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(())

View File

@@ -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<Config> {
let config_file_path = env::var(cfg_env::BAIBOT_CONFIG_FILE_PATH)
@@ -37,6 +37,9 @@ pub fn load() -> anyhow::Result<Config> {
config.user.encryption.recovery_reset_allowed = value.parse::<bool>()?;
}
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::<bool>()?;

View File

@@ -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<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,
@@ -93,6 +139,9 @@ pub struct ConfigUser {
#[serde(default)]
pub encryption: ConfigUserEncryption,
#[serde(default)]
pub avatar: Avatar,
}
impl ConfigUser {

View File

@@ -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 =

View File

@@ -2,4 +2,4 @@ mod config;
pub mod defaults;
pub mod env;
pub use config::Config;
pub use config::{Avatar, Config};