use askama::Template; use askama_web::WebTemplate; use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect, Response}; use axum::Form; use serde::Deserialize; use crate::auth_guard::AuthUser; use crate::flash; use crate::htmx; use crate::page::PageContext; use crate::state::AppState; // --- API response types --- #[derive(Deserialize)] pub struct BotApiResponse { pub id: String, pub name: String, pub nick: String, pub network_addr: String, pub sasl_username: Option, pub channels: Vec, pub enabled: bool, pub status: String, pub status_message: Option, pub running: bool, pub created_at: String, } #[derive(Deserialize)] pub struct ScriptApiResponse { pub id: String, pub name: String, pub source: String, pub enabled: bool, } #[derive(Deserialize)] pub struct LogApiResponse { pub level: String, pub message: String, pub created_at: String, } #[derive(Deserialize)] pub struct KvApiResponse { pub key: String, pub value: String, } // --- Form types --- #[derive(Deserialize)] pub struct CreateBotForm { pub name: String, pub nick: String, pub network_addr: String, pub sasl_username: Option, pub sasl_password: Option, pub channels: Option, } #[derive(Deserialize)] pub struct UpdateBotForm { pub name: String, pub nick: String, pub network_addr: String, pub sasl_username: Option, pub sasl_password: Option, pub channels: Option, } #[derive(Deserialize)] pub struct CreateScriptForm { pub name: String, pub source: String, } #[derive(Deserialize)] pub struct UpdateScriptForm { pub name: String, pub source: String, pub enabled: Option, } #[derive(Deserialize)] pub struct KvSetForm { pub key: String, pub value: String, } // --- Templates --- #[derive(Template, WebTemplate)] #[template(path = "bots.html")] pub struct BotsTemplate { pub page: PageContext, pub bots: Vec, } #[derive(Template, WebTemplate)] #[template(path = "bot_create.html")] pub struct BotCreateTemplate { pub page: PageContext, } #[derive(Template, WebTemplate)] #[template(path = "bot_detail.html")] pub struct BotDetailTemplate { pub page: PageContext, pub bot: BotApiResponse, pub scripts: Vec, pub channels_str: String, } #[derive(Template, WebTemplate)] #[template(path = "bot_script_edit.html")] pub struct BotScriptEditTemplate { pub page: PageContext, pub bot_id: String, pub bot_name: String, pub script: Option, } #[derive(Template, WebTemplate)] #[template(path = "bot_logs.html")] pub struct BotLogsTemplate { pub page: PageContext, pub bot_id: String, pub bot_name: String, pub logs: Vec, } #[derive(Template, WebTemplate)] #[template(path = "bot_kv.html")] pub struct BotKvTemplate { pub page: PageContext, pub bot_id: String, pub bot_name: String, pub kvs: Vec, } // --- Handlers --- pub async fn list( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let bots: Vec = async { let resp = state.http_client .get(format!("{}/api/bots?user_id={}", state.bot_url, user.sub)) .send().await.ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() }.await.unwrap_or_default(); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BotsTemplate { page, bots }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn create_form( State(state): State, headers: HeaderMap, AuthUser(_user): AuthUser, ) -> impl IntoResponse { let page = PageContext::from_request(&headers, &state.announcement); BotCreateTemplate { page } } pub async fn create( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(form): Form, ) -> Response { let channels_str = form.channels.unwrap_or_default(); let channels_vec: Vec = channels_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let nick = form.nick.trim().to_string(); let network_addr = form.network_addr.trim().to_string(); let (sasl_username, sasl_password) = if form.sasl_username.as_ref().is_some_and(|s| !s.trim().is_empty()) { (form.sasl_username, form.sasl_password) } else if network_addr.contains("irc-now-net") { let password = crate::ergo_admin::generate_password(); if let Err(e) = crate::ergo_admin::ensure_ergo_account_with_vhost( &state.kube, &state.namespace, &nick, &password, Some("bot.irc.now"), ).await { tracing::warn!("failed to register ergo account for bot {nick}: {e}"); } (Some(nick.clone()), Some(password)) } else { (None, None) }; let body = serde_json::json!({ "user_id": user.sub, "plan": user.plan.as_deref().unwrap_or("free"), "name": form.name.trim(), "nick": nick, "network_addr": network_addr, "sasl_username": sasl_username, "sasl_password": sasl_password, "channels": channels_vec, }); let resp = state .http_client .post(format!("{}/api/bots", state.bot_url)) .json(&body) .send() .await; match resp { Ok(r) if r.status().is_success() => { if let Ok(bot) = r.json::().await { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect(&format!("/bots/{}", bot.id))], "").into_response(); } return ( AppendHeaders([flash::set_flash("bot_created")]), Redirect::to(&format!("/bots/{}", bot.id)), ) .into_response(); } if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } (AppendHeaders([flash::set_flash("bot_created")]), Redirect::to("/bots")).into_response() } Ok(r) => { let code = if r.status().as_u16() == 403 { "bot_limit_reached" } else { "bot_create_failed" }; if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots/create")], "").into_response(); } (AppendHeaders([flash::set_flash(code)]), Redirect::to("/bots/create")).into_response() } Err(e) => { tracing::error!("bot api error: {e}"); if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots/create")], "").into_response(); } ( AppendHeaders([flash::set_flash("bot_create_failed")]), Redirect::to("/bots/create"), ) .into_response() } } } pub async fn detail( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let bot = match fetch_bot(&state, &id, &user.sub).await { Some(b) => b, None => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } return (AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to("/bots")) .into_response(); } }; let scripts: Vec = async { let resp = state.http_client .get(format!( "{}/api/bots/{}/scripts?user_id={}", state.bot_url, id, user.sub )) .send().await.ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() }.await.unwrap_or_default(); let channels_str = bot.channels.join(", "); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BotDetailTemplate { page, bot, scripts, channels_str, }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn update( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Response { let channels_str = form.channels.unwrap_or_default(); let channels_vec: Vec = channels_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let body = serde_json::json!({ "user_id": user.sub, "name": form.name.trim(), "nick": form.nick.trim(), "network_addr": form.network_addr.trim(), "sasl_username": form.sasl_username.filter(|s| !s.trim().is_empty()), "sasl_password": form.sasl_password.filter(|s| !s.trim().is_empty()), "channels": channels_vec, }); let resp = state .http_client .put(format!("{}/api/bots/{}", state.bot_url, id)) .json(&body) .send() .await; let (flash_code, redirect_to) = match resp { Ok(r) if r.status().is_success() => ("bot_updated", format!("/bots/{id}")), Ok(_) | Err(_) => ("bot_update_failed", format!("/bots/{id}")), }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&redirect_to)], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&redirect_to)).into_response() } } pub async fn delete( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let resp = state .http_client .delete(format!( "{}/api/bots/{}?user_id={}", state.bot_url, id, user.sub )) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "bot_deleted", _ => "bot_delete_failed", }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect("/bots")], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to("/bots")).into_response() } } pub async fn start( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let resp = state .http_client .post(format!( "{}/api/bots/{}/start?user_id={}", state.bot_url, id, user.sub )) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "bot_started", _ => "bot_start_failed", }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&format!("/bots/{id}"))], "").into_response() } else { ( AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&format!("/bots/{id}")), ) .into_response() } } pub async fn stop( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let resp = state .http_client .post(format!( "{}/api/bots/{}/stop?user_id={}", state.bot_url, id, user.sub )) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "bot_stopped", _ => "bot_stop_failed", }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&format!("/bots/{id}"))], "").into_response() } else { ( AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&format!("/bots/{id}")), ) .into_response() } } pub async fn script_create_form( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let bot = match fetch_bot(&state, &id, &user.sub).await { Some(b) => b, None => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } return (AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to("/bots")) .into_response(); } }; let page = PageContext::from_request(&headers, &state.announcement); BotScriptEditTemplate { page, bot_id: id, bot_name: bot.name, script: None, } .into_response() } pub async fn script_create( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Response { let body = serde_json::json!({ "user_id": user.sub, "name": form.name.trim(), "source": form.source, }); let resp = state .http_client .post(format!("{}/api/bots/{}/scripts", state.bot_url, id)) .json(&body) .send() .await; let (flash_code, redirect_to) = match resp { Ok(r) if r.status().is_success() => { if let Ok(script) = r.json::().await { ( "script_created", format!("/bots/{}/scripts/{}", id, script.id), ) } else { ("script_created", format!("/bots/{id}")) } } _ => ("script_create_failed", format!("/bots/{id}/scripts/create")), }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&redirect_to)], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&redirect_to)).into_response() } } pub async fn script_edit( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path((id, sid)): Path<(String, String)>, ) -> Response { let bot = match fetch_bot(&state, &id, &user.sub).await { Some(b) => b, None => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } return (AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to("/bots")) .into_response(); } }; let script: Option = async { let resp = state.http_client .get(format!( "{}/api/bots/{}/scripts/{}?user_id={}", state.bot_url, id, sid, user.sub )) .send().await.ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() }.await; if script.is_none() { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect(&format!("/bots/{id}"))], "").into_response(); } return ( AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to(&format!("/bots/{id}")), ) .into_response(); } let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BotScriptEditTemplate { page, bot_id: id, bot_name: bot.name, script, }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn script_update( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path((id, sid)): Path<(String, String)>, Form(form): Form, ) -> Response { let body = serde_json::json!({ "user_id": user.sub, "name": form.name.trim(), "source": form.source, "enabled": form.enabled.is_some(), }); let resp = state .http_client .put(format!( "{}/api/bots/{}/scripts/{}", state.bot_url, id, sid )) .json(&body) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "script_updated", _ => "script_update_failed", }; let redirect_to = format!("/bots/{}/scripts/{}", id, sid); if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&redirect_to)], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&redirect_to)).into_response() } } pub async fn script_delete( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path((id, sid)): Path<(String, String)>, ) -> Response { let resp = state .http_client .delete(format!( "{}/api/bots/{}/scripts/{}?user_id={}", state.bot_url, id, sid, user.sub )) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "script_deleted", _ => "script_delete_failed", }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&format!("/bots/{id}"))], "").into_response() } else { ( AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&format!("/bots/{id}")), ) .into_response() } } pub async fn logs( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let bot = match fetch_bot(&state, &id, &user.sub).await { Some(b) => b, None => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } return (AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to("/bots")) .into_response(); } }; let log_entries: Vec = async { let resp = state.http_client .get(format!( "{}/api/bots/{}/logs?user_id={}", state.bot_url, id, user.sub )) .send().await.ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() }.await.unwrap_or_default(); let page = PageContext::from_request(&headers, &state.announcement); BotLogsTemplate { page, bot_id: id, bot_name: bot.name, logs: log_entries, } .into_response() } pub async fn kv_list( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Response { let bot = match fetch_bot(&state, &id, &user.sub).await { Some(b) => b, None => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/bots")], "").into_response(); } return (AppendHeaders([flash::set_flash("bot_not_found")]), Redirect::to("/bots")) .into_response(); } }; let kvs: Vec = async { let resp = state.http_client .get(format!( "{}/api/bots/{}/kv?user_id={}", state.bot_url, id, user.sub )) .send().await.ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() }.await.unwrap_or_default(); let page = PageContext::from_request(&headers, &state.announcement); BotKvTemplate { page, bot_id: id, bot_name: bot.name, kvs, } .into_response() } pub async fn kv_set( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Response { let body = serde_json::json!({ "user_id": user.sub, "value": form.value, }); let resp = state .http_client .put(format!( "{}/api/bots/{}/kv/{}", state.bot_url, id, urlencoding::encode(&form.key) )) .json(&body) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "kv_set", _ => "kv_set_failed", }; let redirect_to = format!("/bots/{id}/kv"); if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&redirect_to)], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&redirect_to)).into_response() } } pub async fn kv_delete( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path((id, key)): Path<(String, String)>, ) -> Response { let resp = state .http_client .delete(format!( "{}/api/bots/{}/kv/{}?user_id={}", state.bot_url, id, urlencoding::encode(&key), urlencoding::encode(&user.sub) )) .send() .await; let flash_code = match resp { Ok(r) if r.status().is_success() => "kv_deleted", _ => "kv_delete_failed", }; let redirect_to = format!("/bots/{id}/kv"); if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&redirect_to)], "").into_response() } else { (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to(&redirect_to)).into_response() } } // --- Helpers --- async fn fetch_bot(state: &AppState, id: &str, user_id: &str) -> Option { state .http_client .get(format!( "{}/api/bots/{}?user_id={}", state.bot_url, id, user_id )) .send() .await .ok() .filter(|r| r.status().is_success())? .json::() .await .ok() }