use axum::{ extract::{Form, Path, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use askama::Template; use askama_web::WebTemplate; use serde::Deserialize; use crate::auth_guard::AuthUser; use crate::db; use crate::state::AppState; const FREE_BOT_LIMIT: i64 = 1; const PRO_BOT_LIMIT: i64 = 5; fn format_time(t: time::OffsetDateTime) -> String { format!( "{:04}-{:02}-{:02} {:02}:{:02} UTC", t.year(), t.month() as u8, t.day(), t.hour(), t.minute(), ) } pub struct BotEntry { pub id: String, pub name: String, pub nick: String, pub status: String, pub network_addr: String, pub created_at: String, } #[derive(Template, WebTemplate)] #[template(path = "bots.html")] pub struct BotsTemplate { pub bots: Vec, } pub async fn list( State(state): State, AuthUser(user): AuthUser, ) -> Result { let rows = db::list_bots(&state.db, &user.sub) .await .map_err(|e| { tracing::error!("bot list query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; let bots = rows .into_iter() .map(|b| BotEntry { id: b.id, name: b.name, nick: b.nick, status: b.status, network_addr: b.network_addr, created_at: format_time(b.created_at), }) .collect(); Ok(BotsTemplate { bots }) } #[derive(Template, WebTemplate)] #[template(path = "bot_create.html")] pub struct CreateTemplate; pub async fn create_form(AuthUser(_user): AuthUser) -> CreateTemplate { CreateTemplate } #[derive(Deserialize)] pub struct CreateForm { name: String, nick: String, network_addr: String, sasl_username: Option, sasl_password: Option, channels: Option, } pub async fn create( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { if form.name.trim().is_empty() || form.nick.trim().is_empty() { return Err((StatusCode::BAD_REQUEST, "name and nick are required").into_response()); } let is_pro = user.is_pro(); let limit = if is_pro { PRO_BOT_LIMIT } else { FREE_BOT_LIMIT }; let count = db::count_bots(&state.db, &user.sub) .await .map_err(|e| { tracing::error!("bot count query failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })?; if count >= limit { let msg = if is_pro { "pro plan limited to 5 bots" } else { "free plan limited to 1 bot -- upgrade to pro for more" }; return Err((StatusCode::FORBIDDEN, msg).into_response()); } let id = nanoid::nanoid!(8); let channels: Vec = form .channels .unwrap_or_default() .split(|c: char| c == ',' || c == ' ') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let sasl_user = form.sasl_username.as_deref().filter(|s| !s.trim().is_empty()); let sasl_pass = form.sasl_password.as_deref().filter(|s| !s.trim().is_empty()); db::insert_bot( &state.db, &id, &user.sub, form.name.trim(), form.nick.trim(), form.network_addr.trim(), sasl_user, sasl_pass, &channels, ) .await .map_err(|e| { tracing::error!("bot insert failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })?; Ok(Redirect::to(&format!("/{id}"))) } pub struct ScriptEntry { pub id: String, pub name: String, pub enabled: bool, } #[derive(Template, WebTemplate)] #[template(path = "bot_detail.html")] pub struct DetailTemplate { pub bot: BotEntry, pub bot_id: String, pub channels: String, pub sasl_username: String, pub scripts: Vec, pub running: bool, pub status_message: String, } pub async fn detail( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { let bot = db::get_bot_owned(&state.db, &id, &user.sub) .await .map_err(|e| { tracing::error!("bot query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let scripts = db::list_scripts(&state.db, &id) .await .map_err(|e| { tracing::error!("scripts query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; let running = state.bot_manager.is_running(&id).await; Ok(DetailTemplate { bot_id: bot.id.clone(), channels: bot.channels.join(", "), sasl_username: bot.sasl_username.clone().unwrap_or_default(), status_message: bot.status_message.clone().unwrap_or_default(), running, bot: BotEntry { id: bot.id, name: bot.name, nick: bot.nick, status: bot.status, network_addr: bot.network_addr, created_at: format_time(bot.created_at), }, scripts: scripts .into_iter() .map(|s| ScriptEntry { id: s.id, name: s.name, enabled: s.enabled, }) .collect(), }) } #[derive(Deserialize)] pub struct UpdateForm { name: String, nick: String, network_addr: String, sasl_username: Option, sasl_password: Option, channels: Option, } pub async fn update( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Result { let channels: Vec = form .channels .unwrap_or_default() .split(|c: char| c == ',' || c == ' ') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let sasl_user = form.sasl_username.as_deref().filter(|s| !s.trim().is_empty()); let sasl_pass = form.sasl_password.as_deref().filter(|s| !s.trim().is_empty()); let updated = db::update_bot( &state.db, &id, &user.sub, form.name.trim(), form.nick.trim(), form.network_addr.trim(), sasl_user, sasl_pass, &channels, ) .await .map_err(|e| { tracing::error!("bot update failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })?; if !updated { return Err(StatusCode::NOT_FOUND.into_response()); } Ok(Redirect::to(&format!("/{id}"))) } pub async fn delete( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { if state.bot_manager.is_running(&id).await { let _ = state.bot_manager.stop_bot(&id).await; } let deleted = db::delete_bot(&state.db, &id, &user.sub) .await .map_err(|e| { tracing::error!("bot delete failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; if !deleted { return Err(StatusCode::NOT_FOUND); } Ok(Redirect::to("/my")) } pub async fn start( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { let _bot = db::get_bot_owned(&state.db, &id, &user.sub) .await .map_err(|e| { tracing::error!("bot query failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; state.bot_manager.start_bot(&id).await.map_err(|e| { (StatusCode::BAD_REQUEST, e).into_response() })?; Ok(Redirect::to(&format!("/{id}"))) } pub async fn stop( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { let _bot = db::get_bot_owned(&state.db, &id, &user.sub) .await .map_err(|e| { tracing::error!("bot query failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; state.bot_manager.stop_bot(&id).await.map_err(|e| { (StatusCode::BAD_REQUEST, e).into_response() })?; Ok(Redirect::to(&format!("/{id}"))) }