Skip to content

Commit aa133e4

Browse files
authored
Merge pull request #343 from Rustmail/332-make-it-possible-to-change-the-banner-avatar-or-name-of-the-bot-from-the-panel
feat(panel): make it possible to change the banner avatar or name of the bot from the panel
2 parents e83f764 + 6b1bea1 commit aa133e4

9 files changed

Lines changed: 775 additions & 76 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/rustmail/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ rand = "0.9.2"
2727
subtle = "2.6.1"
2828
sha2 = "0.10.9"
2929
hex = "0.4.3"
30+
base64 = "0.22"
3031
moka = { version = "0.12.12", features = ["future"] }
3132
tower-http = { version = "0.6.8", features = ["compression-gzip", "compression-br"] }
3233
strum = { version = "0.27.2", features = ["derive"] }

crates/rustmail/src/api/handler/bot/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod config;
2+
pub mod profile;
23
pub mod restart;
34
pub mod start;
45
pub mod statistics;
@@ -7,6 +8,7 @@ pub mod stop;
78
pub mod tickets;
89

910
pub use config::*;
11+
pub use profile::*;
1012
pub use restart::*;
1113
pub use start::*;
1214
pub use statistics::*;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use crate::prelude::types::*;
2+
use axum::Json;
3+
use axum::extract::State;
4+
use axum::http::StatusCode;
5+
use axum::response::IntoResponse;
6+
use base64::Engine;
7+
use base64::engine::general_purpose::STANDARD as BASE64;
8+
use serenity::all::EditProfile;
9+
use std::sync::Arc;
10+
use tokio::sync::Mutex;
11+
12+
#[derive(serde::Deserialize)]
13+
pub struct UpdateProfileRequest {
14+
#[serde(default)]
15+
pub username: Option<String>,
16+
#[serde(default)]
17+
pub avatar: Option<String>,
18+
#[serde(default)]
19+
pub banner: Option<String>,
20+
}
21+
22+
pub async fn handle_get_profile(
23+
State(bot_state): State<Arc<Mutex<BotState>>>,
24+
) -> impl IntoResponse {
25+
let state_lock = bot_state.lock().await;
26+
27+
let ctx_guard = state_lock.bot_context.read().await;
28+
let ctx = match ctx_guard.as_ref() {
29+
Some(c) => c,
30+
None => {
31+
return (
32+
StatusCode::SERVICE_UNAVAILABLE,
33+
Json(serde_json::json!({"error": "Bot is not running"})),
34+
);
35+
}
36+
};
37+
38+
let current_user = ctx.cache.current_user().clone();
39+
40+
let avatar_url = current_user.avatar_url();
41+
let banner_url = current_user.banner_url();
42+
43+
(
44+
StatusCode::OK,
45+
Json(serde_json::json!({
46+
"username": current_user.name,
47+
"avatar_url": avatar_url,
48+
"banner_url": banner_url,
49+
"discriminator": current_user.discriminator.map(|d| d.to_string()).unwrap_or_default(),
50+
"id": current_user.id.to_string()
51+
})),
52+
)
53+
}
54+
55+
pub async fn handle_update_profile(
56+
State(bot_state): State<Arc<Mutex<BotState>>>,
57+
Json(payload): Json<UpdateProfileRequest>,
58+
) -> impl IntoResponse {
59+
let state_lock = bot_state.lock().await;
60+
61+
let http = match &state_lock.bot_http {
62+
Some(h) => h.clone(),
63+
None => {
64+
return (
65+
StatusCode::SERVICE_UNAVAILABLE,
66+
Json(serde_json::json!({"error": "Bot HTTP client not available"})),
67+
);
68+
}
69+
};
70+
71+
let mut edit_profile = EditProfile::new();
72+
let mut changes_made = false;
73+
74+
if let Some(ref username) = payload.username {
75+
if username.len() < 2 || username.len() > 32 {
76+
return (
77+
StatusCode::BAD_REQUEST,
78+
Json(serde_json::json!({"error": "Username must be between 2 and 32 characters"})),
79+
);
80+
}
81+
edit_profile = edit_profile.username(username);
82+
changes_made = true;
83+
}
84+
85+
if let Some(ref avatar_base64) = payload.avatar {
86+
match parse_base64_image(avatar_base64) {
87+
Ok(create_attachment) => {
88+
edit_profile = edit_profile.avatar(&create_attachment);
89+
changes_made = true;
90+
}
91+
Err(e) => {
92+
return (
93+
StatusCode::BAD_REQUEST,
94+
Json(serde_json::json!({"error": format!("Invalid avatar: {}", e)})),
95+
);
96+
}
97+
}
98+
}
99+
100+
if let Some(ref banner_base64) = payload.banner {
101+
match parse_base64_image(banner_base64) {
102+
Ok(create_attachment) => {
103+
edit_profile = edit_profile.banner(&create_attachment);
104+
changes_made = true;
105+
}
106+
Err(e) => {
107+
return (
108+
StatusCode::BAD_REQUEST,
109+
Json(serde_json::json!({"error": format!("Invalid banner: {}", e)})),
110+
);
111+
}
112+
}
113+
}
114+
115+
if !changes_made {
116+
return (
117+
StatusCode::BAD_REQUEST,
118+
Json(serde_json::json!({"error": "No changes specified"})),
119+
);
120+
}
121+
122+
match http.edit_profile(&edit_profile).await {
123+
Ok(user) => (
124+
StatusCode::OK,
125+
Json(serde_json::json!({
126+
"success": true,
127+
"username": user.name,
128+
"avatar_url": user.avatar_url(),
129+
"banner_url": user.banner_url()
130+
})),
131+
),
132+
Err(e) => (
133+
StatusCode::INTERNAL_SERVER_ERROR,
134+
Json(serde_json::json!({"error": format!("Failed to update profile: {}", e)})),
135+
),
136+
}
137+
}
138+
139+
fn parse_base64_image(data: &str) -> Result<serenity::all::CreateAttachment, String> {
140+
let (content_type, base64_data) = if data.starts_with("data:") {
141+
let parts: Vec<&str> = data.splitn(2, ',').collect();
142+
if parts.len() != 2 {
143+
return Err("Invalid data URI format".to_string());
144+
}
145+
146+
let metadata = parts[0];
147+
let base64_content = parts[1];
148+
149+
let ct = if metadata.contains("image/png") {
150+
"image/png"
151+
} else if metadata.contains("image/jpeg") || metadata.contains("image/jpg") {
152+
"image/jpeg"
153+
} else if metadata.contains("image/gif") {
154+
"image/gif"
155+
} else if metadata.contains("image/webp") {
156+
"image/webp"
157+
} else {
158+
return Err("Unsupported image format. Use PNG, JPEG, GIF, or WebP".to_string());
159+
};
160+
161+
(ct, base64_content)
162+
} else {
163+
("image/png", data)
164+
};
165+
166+
let image_bytes = BASE64
167+
.decode(base64_data)
168+
.map_err(|e| format!("Failed to decode base64: {}", e))?;
169+
170+
let filename = match content_type {
171+
"image/png" => "image.png",
172+
"image/jpeg" => "image.jpg",
173+
"image/gif" => "image.gif",
174+
"image/webp" => "image.webp",
175+
_ => "image.png",
176+
};
177+
178+
Ok(serenity::all::CreateAttachment::bytes(
179+
image_bytes,
180+
filename,
181+
))
182+
}

crates/rustmail/src/api/routes/bot.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub fn create_bot_router(bot_state: Arc<Mutex<BotState>>) -> Router<Arc<Mutex<Bo
1212
.route("/stop", post(handle_stop_bot))
1313
.route("/restart", post(handle_restart_bot))
1414
.route("/presence", post(handle_set_presence))
15+
.route("/profile", put(handle_update_profile))
1516
.layer(axum::middleware::from_fn_with_state(
1617
bot_state.clone(),
1718
move |state, jar, req, next| {
@@ -33,6 +34,7 @@ pub fn create_bot_router(bot_state: Arc<Mutex<BotState>>) -> Router<Arc<Mutex<Bo
3334
.route("/tickets", get(handle_tickets_bot))
3435
.route("/config", get(handle_get_config))
3536
.route("/statistics", get(handle_statistics))
37+
.route("/profile", get(handle_get_profile))
3638
.layer(axum::middleware::from_fn_with_state(
3739
bot_state.clone(),
3840
move |state, jar, req, next| {

crates/rustmail_panel/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ yew-router = "0.19.0"
1111
wasm-bindgen-futures = "0.4.56"
1212
gloo-net = "0.6.0"
1313
gloo-utils = "0.2.0"
14-
web-sys = { version = "0.3.83", features = ["HtmlSelectElement"] }
14+
web-sys = { version = "0.3.83", features = ["HtmlSelectElement", "File", "FileList", "FileReader", "HtmlInputElement", "DragEvent", "DataTransfer"] }
1515
serde_json = "1.0.149"
1616
serde = { version = "1.0.228", features = ["derive"] }
1717
js-sys = "0.3.83"

0 commit comments

Comments
 (0)