From ed0aa2a2bff424bb889828e61c5a2c0ee25b5803 Mon Sep 17 00:00:00 2001 From: "Brian J. Tarricone" Date: Sun, 10 Aug 2025 00:06:22 -0700 Subject: [PATCH] Allow setting an avatar image for the bot's account --- Cargo.lock | 27 +++++++++++++++++++++++++++ Cargo.toml | 3 +++ sample-config.yaml | 2 ++ src/config.rs | 1 + src/main.rs | 18 +++++++++++++++--- src/matrix.rs | 35 +++++++++++++++++++++++++++++++++-- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d657e08..fe18219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,6 +371,9 @@ dependencies = [ "http", "log", "matrix-sdk", + "mime", + "mime-sniffer", + "mime_guess2", "quick-xml", "regex", "reqwest", @@ -1826,12 +1829,34 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime-sniffer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8b2a64cd735f1d5f17ff6701ced3cc3c54851f9448caf454cd9c923d812408" +dependencies = [ + "mime", + "url", +] + [[package]] name = "mime2ext" version = "0.1.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc" +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2034,6 +2059,7 @@ dependencies = [ "proc-macro2", "quote", "syn", + "unicase", ] [[package]] @@ -2043,6 +2069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", + "unicase", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bf16fc2..f9a0e8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,9 @@ futures = "0.3" http = "1.3" log = { version = "0.4", features = ["std"] } matrix-sdk = { version = "0.13", features = ["anyhow", "markdown", "rustls-tls"], default-features = false } +mime = "0.3" +mime-sniffer = "0.1" +mime_guess2 = "2" quick-xml = { version = "0.38", features = ["serialize"] } regex = "1" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "gzip", "rustls-tls-native-roots", "system-proxy"] } diff --git a/sample-config.yaml b/sample-config.yaml index ffca934..4f23093 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -6,6 +6,8 @@ bind_port: 3000 user_id: "@mybebot:example.com" # Password for Matrix user. password: "secret-matrix-account-password" +# Optional path to an image file to use as the bot account's avatar. +avatar_image_path: "/path/to/avatar.jpg" # All Gitlab-specific settings are under here. gitlab_webhook: # Optional prefix to serve the webhook path under (default is empty string). diff --git a/src/config.rs b/src/config.rs index 9432cca..6f60e98 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,7 @@ pub struct Config { #[serde(deserialize_with = "crate::matrix::deser_user_id")] pub user_id: OwnedUserId, pub password: String, + pub avatar_image_path: Option, pub gitlab_webhook: Option, pub mail_archive: Option, } diff --git a/src/main.rs b/src/main.rs index 28cdbfc..5f64ed3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,13 +41,25 @@ async fn run() -> anyhow::Result<()> { .ok_or_else(|| anyhow!("Config file should be passed as only parameter"))?; let mut config = config::load(config_path).await?; - let matrix_client = matrix::connect(&config).await.context("Failed to connect to Matrix")?; + let mut join_handles = Vec::default(); - let handles = if let Some(mail_archive) = config.mail_archive.take() { + let matrix_client = matrix::connect(&config).await.context("Failed to connect to Matrix")?; + if let Some(avatar_path) = config.avatar_image_path { + let matrix_client = matrix_client.clone(); + let handle = tokio::spawn(async move { + if let Err(err) = matrix::set_avatar_if_needed(&matrix_client, avatar_path).await { + warn!("Failed to set matrix avatar: {err}"); + } + }); + join_handles.push(handle); + } + + let mail_join_handles = if let Some(mail_archive) = config.mail_archive.take() { mail_archive::start_polling(mail_archive, matrix_client.clone())? } else { vec![] }; + join_handles.extend(mail_join_handles); if let Some(gitlab_webhook) = config.gitlab_webhook.take() { let gitlab = gitlab_webhook::build_route(gitlab_webhook, matrix_client.clone()); @@ -60,7 +72,7 @@ async fn run() -> anyhow::Result<()> { axum::serve(listener, gitlab).await?; } - join_all(handles).await; + join_all(join_handles).await; error!("No functionality is configured; exiting"); exit(1); diff --git a/src/matrix.rs b/src/matrix.rs index 02fe026..599d169 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -14,16 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{fmt, process::exit, time::Duration}; +use std::{fmt, path::Path, process::exit, time::Duration}; +use futures::TryFutureExt; use matrix_sdk::{ config::SyncSettings, + media::MediaFormat, room::Room, ruma::{OwnedRoomOrAliasId, OwnedUserId, RoomOrAliasId, UserId}, BaseRoom, Client, }; +use mime_sniffer::MimeTypeSnifferExt; use serde::de; -use tokio::time::sleep; +use tokio::{fs, time::sleep}; use crate::config::Config; @@ -60,6 +63,34 @@ pub async fn connect(config: &Config) -> anyhow::Result { Ok(client) } +pub async fn set_avatar_if_needed>(client: &Client, avatar_path: P) -> anyhow::Result<()> { + let new_avatar = fs::read(&avatar_path).await?; + + let account = client.account(); + let cur_avatar = account.get_avatar(MediaFormat::File).await?; + + if cur_avatar.as_ref() != Some(&new_avatar) { + // Falling back to extension matching because for some reason mime-sniffer can't handle SVG + // files, even though it seems like it should be able to. + if let Some(media_type) = new_avatar + .as_slice() + .sniff_mime_type_ext() + .or_else(|| mime_guess2::from_path(&avatar_path).first()) + { + info!("Setting avatar image from '{}'", avatar_path.as_ref().to_string_lossy()); + account + .upload_avatar(&media_type, new_avatar) + .map_err(Into::into) + .map_ok(|_| ()) + .await + } else { + Err(anyhow!("Cannot determine media type of avatar image")) + } + } else { + Ok(()) + } +} + pub async fn ensure_room_joined(matrix_client: &Client, room_id: &OwnedRoomOrAliasId) -> anyhow::Result { fn room_matches(a_room: &BaseRoom, our_room: &OwnedRoomOrAliasId) -> bool { let our_room_str = our_room.as_str();