use axum::{ extract::{ConnectInfo, Form, Path, State}, response::Html, }; use chrono::Utc; use std::{net::SocketAddr, sync::Arc}; use uuid::Uuid; use crate::{ models::{ChatSession, Message, MessageSender, NewSessionForm, SendMessageForm, SessionStatus}, state::{AppState, MESSAGE_LIMIT_PER_WINDOW, SESSION_LIMIT_PER_WINDOW}, }; const MAX_MESSAGE_CHARS: usize = 1_000; const MAX_NAME_CHARS: usize = 40; #[derive(Clone, Copy)] enum MessageView { User, Supporter, } fn render_message(msg: &Message) -> String { render_message_for(msg, MessageView::User) } fn render_supporter_message(msg: &Message) -> String { render_message_for(msg, MessageView::Supporter) } fn render_message_for(msg: &Message, view: MessageView) -> String { let bubble_class = match msg.sender { MessageSender::User => "msg-user", MessageSender::Supporter => "msg-supporter", MessageSender::System => "msg-system", }; let label = match (view, &msg.sender) { (_, MessageSender::System) => "", (MessageView::User, MessageSender::User) => "You", (MessageView::User, MessageSender::Supporter) => "Support", (MessageView::Supporter, MessageSender::User) => "User", (MessageView::Supporter, MessageSender::Supporter) => "You", }; if msg.sender == MessageSender::System { render_system_notice(&msg.content) } else { let time = msg.timestamp.format("%H:%M").to_string(); format!( "
\
\ {label}\

{content}

\ {time}\
\
", bclass = bubble_class, content = html_escape(&msg.content), ) } } fn render_system_notice(content: &str) -> String { format!( "
{}
", html_escape(content) ) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn limit_key(kind: &str, addr: SocketAddr) -> String { format!("{}:{}", kind, addr.ip()) } fn clean_text(input: &str, max_chars: usize) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Ok(String::new()); } if trimmed.chars().count() > max_chars { return Err(format!("Please keep this under {max_chars} characters.")); } Ok(trimmed.to_string()) } /// GET /messages/:session_id pub async fn get_messages( Path(session_id): Path, State(state): State>, ) -> Html { let Some(session) = state.sessions.get(&session_id) else { return Html(render_system_notice( "Session not found. Chat history may have been cleared; please start a new chat.", )); }; let html: String = session.messages.iter().map(render_message).collect(); Html(html) } /// GET /supporter/messages/:session_id pub async fn get_supporter_messages( Path(session_id): Path, State(state): State>, ) -> Html { let Some(session) = state.sessions.get(&session_id) else { return Html(render_system_notice( "Session not found. Chat history may have been cleared.", )); }; let html: String = session.messages.iter().map(render_supporter_message).collect(); Html(html) } /// POST /send/:session_id — user sends a message pub async fn send_message( ConnectInfo(addr): ConnectInfo, Path(session_id): Path, State(state): State>, Form(form): Form, ) -> Html { if state.is_rate_limited(limit_key("message", addr), MESSAGE_LIMIT_PER_WINDOW) { return Html(render_system_notice( "Too many messages from this network. Please wait until the 3-minute window resets.", )); } let content = match clean_text(&form.content, MAX_MESSAGE_CHARS) { Ok(content) => content, Err(message) => return Html(render_system_notice(&message)), }; if content.is_empty() { return Html(String::new()); } let Some(mut session) = state.sessions.get_mut(&session_id) else { return Html(render_system_notice( "Session not found. Chat history may have been cleared; please start a new chat.", )); }; let msg = Message { id: Uuid::new_v4().to_string(), session_id: session_id.clone(), sender: MessageSender::User, content, timestamp: Utc::now(), }; let rendered = render_message(&msg); session.messages.push(msg); if let Some(tx) = state.notifiers.get(&session_id) { let _ = tx.send(session_id.clone()); } let _ = state.supporter_notifier.send(session_id.clone()); Html(rendered) } /// POST /supporter/send/:session_id — supporter sends a message pub async fn supporter_send_message( ConnectInfo(addr): ConnectInfo, Path(session_id): Path, State(state): State>, Form(form): Form, ) -> Html { if state.is_rate_limited(limit_key("supporter-message", addr), MESSAGE_LIMIT_PER_WINDOW) { return Html(render_system_notice( "Too many messages from this network. Please wait until the 3-minute window resets.", )); } let content = match clean_text(&form.content, MAX_MESSAGE_CHARS) { Ok(content) => content, Err(message) => return Html(render_system_notice(&message)), }; if content.is_empty() { return Html(String::new()); } let Some(mut session) = state.sessions.get_mut(&session_id) else { return Html(render_system_notice( "Session not found. Chat history may have been cleared.", )); }; let msg = Message { id: Uuid::new_v4().to_string(), session_id: session_id.clone(), sender: MessageSender::Supporter, content, timestamp: Utc::now(), }; let rendered = render_supporter_message(&msg); session.messages.push(msg); session.status = SessionStatus::Active; if let Some(tx) = state.notifiers.get(&session_id) { let _ = tx.send(session_id.clone()); } let _ = state.supporter_notifier.send(session_id.clone()); Html(rendered) } /// GET /sessions — session list for supporter dashboard pub async fn get_sessions(State(state): State>) -> Html { let mut sessions: Vec<_> = state .sessions .iter() .filter(|s| s.status != SessionStatus::Closed) .map(|s| { ( s.id.clone(), s.user_name.clone(), s.status.clone(), s.messages.len(), ) }) .collect(); sessions.sort_by(|a, b| a.0.cmp(&b.0)); if sessions.is_empty() { return Html("

No active conversations yet.

".to_string()); } let mut html = String::new(); for (id, name, status, count) in &sessions { let status_class = match status { SessionStatus::Waiting => "status-waiting", SessionStatus::Active => "status-active", SessionStatus::Closed => "status-closed", }; let status_label = match status { SessionStatus::Waiting => "Waiting", SessionStatus::Active => "Active", SessionStatus::Closed => "Closed", }; let short_id = &id[..8]; let escaped_name = html_escape(name); html.push_str(&format!( "" )); } Html(html) } /// POST /sessions/new — start a new user session pub async fn new_session( ConnectInfo(addr): ConnectInfo, State(state): State>, Form(form): Form, ) -> Html { if state.is_rate_limited(limit_key("session", addr), SESSION_LIMIT_PER_WINDOW) { return Html( "" .to_string(), ); } let name = match clean_text(&form.name, MAX_NAME_CHARS) { Ok(name) if !name.is_empty() => name, Ok(_) => "Anonymous".to_string(), Err(message) => { return Html(format!( "", html_escape(&message) )); } }; let session_id = Uuid::new_v4().to_string(); let short = session_id[..8].to_string(); let session = ChatSession::new(session_id.clone(), name.clone()); state.sessions.insert(session_id.clone(), session); state.get_or_create_notifier(&session_id); let _ = state.supporter_notifier.send(session_id.clone()); // The response swaps out #start-form and fills #chat-container via OOB. let html = format!( "
\
\ Support chat\ Session #{short}\
\
\
\
\ \ \ \
\
\ \ \
\
\ " ); Html(html) }