From d620b8f8ae85f0c0b6c56d180df227091e56f683 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 4 Mar 2026 22:44:00 +0100 Subject: [PATCH] Add create room command and room management enhancements in yourchat2 Implemented the `create_room` command to allow users to create new chat rooms with customizable settings such as privacy, age restrictions, and ownership. Enhanced room management by introducing functions to mark rooms as occupied or possibly empty, and added cleanup logic for stale temporary rooms. Updated the `RoomMeta` structure to include new fields for room creation timestamps and temporary status, ensuring better room lifecycle management. --- src/commands.rs | 235 +++++++++++++++++++++++++++++++++++++++++++++++- src/db.rs | 3 + src/main.rs | 22 +++++ src/state.rs | 88 ++++++++++++++++-- src/types.rs | 3 + 5 files changed, 342 insertions(+), 9 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index a517603..0a6b94a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,6 +3,7 @@ use bcrypt::verify as bcrypt_verify; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use uuid::Uuid; @@ -100,6 +101,10 @@ pub async fn handle_command( handle_reload_rooms_command(client_id, ¤t, Arc::clone(&state), Arc::clone(&config)).await; return; } + "create_room" => { + handle_create_room_command(client_id, ¤t, Arc::clone(&state), Arc::clone(&config)).await; + return; + } _ => { handle_unknown_command(client_id, ¤t, Arc::clone(&state)).await; return; @@ -176,7 +181,7 @@ async fn handle_init_command( return; } - let (token, user_name, actual_room_name) = { + let (token, user_name, actual_room_name, old_room_name) = { let mut guard = state.write().await; if guard.logged_in_names.contains(&requested_user_name) && guard @@ -247,8 +252,12 @@ async fn handle_init_command( .entry(resolved_room_name.clone()) .or_default() .insert(client_id); - (token, user_name, resolved_room_name) + (token, user_name, resolved_room_name, old_room) }; + if !old_room_name.is_empty() { + state::mark_room_possibly_empty(&old_room_name, Arc::clone(&state)).await; + } + state::mark_room_occupied(&actual_room_name, Arc::clone(&state)).await; state::send_to_client( client_id, @@ -343,6 +352,10 @@ async fn handle_join_command( (from_room, user_name, resolved_room) }; + if !from_room.is_empty() { + state::mark_room_possibly_empty(&from_room, Arc::clone(&state)).await; + } + state::mark_room_occupied(&actual_room_name, Arc::clone(&state)).await; if !from_room.is_empty() && from_room != room { state::broadcast_room( &from_room, @@ -793,6 +806,98 @@ async fn handle_reload_rooms_command( .await; } +#[derive(Debug, Clone)] +struct CreateRoomArgs { + room_name: String, + is_public: bool, + gender_restriction_id: Option, + min_age: Option, + max_age: Option, + password: Option, + friends_of_owner_only: bool, + required_user_right_id: Option, + room_type_id: Option, +} + +async fn handle_create_room_command( + client_id: ClientId, + command: &Command, + state: Arc>, + _config: Arc, +) { + if !state::authorize(client_id, command, Arc::clone(&state)).await { + return; + } + let raw = command + .message + .clone() + .or_else(|| string_from_value(command.value.as_ref())) + .unwrap_or_default(); + let parsed = match parse_create_room_args(&raw) { + Ok(v) => v, + Err(msg) => { + send_error(client_id, Arc::clone(&state), msg).await; + return; + } + }; + + let owner_chat_user_id = { + let guard = state.read().await; + let Some(client) = guard.clients.get(&client_id) else { + return; + }; + let Some(chat_user_id) = client.chat_user_id else { + send_error(client_id, Arc::clone(&state), "missing_chat_user_for_room_creation").await; + return; + }; + chat_user_id + }; + { + let mut guard = state.write().await; + if resolve_room_name(&guard, &parsed.room_name).is_some() { + drop(guard); + send_error(client_id, Arc::clone(&state), "room_already_exists").await; + return; + } + guard.rooms.entry(parsed.room_name.clone()).or_default(); + guard.room_meta.insert( + parsed.room_name.clone(), + RoomMeta { + name: parsed.room_name.clone(), + password: parsed.password.clone(), + gender_restriction_id: parsed.gender_restriction_id, + required_user_right_id: parsed.required_user_right_id, + min_age: parsed.min_age, + max_age: parsed.max_age, + is_public: parsed.is_public, + owner_id: Some(owner_chat_user_id), + room_type_id: parsed.room_type_id, + friends_of_owner_only: parsed.friends_of_owner_only, + is_temporary: true, + created_at_unix: Some(now_unix()), + empty_since_unix: Some(now_unix()), + }, + ); + } + state::send_room_list(client_id, Arc::clone(&state)).await; + state::send_to_client( + client_id, + Arc::clone(&state), + json!({ + "type":"system", + "message":"room_created", + "room": parsed.room_name, + "is_public": parsed.is_public, + "friends_of_owner_only": parsed.friends_of_owner_only, + "gender": parsed.gender_restriction_id.map(|v| if v == 1 { "m" } else { "f" }), + "min_age": parsed.min_age, + "max_age": parsed.max_age, + "required_user_right_id": parsed.required_user_right_id + }), + ) + .await; +} + async fn handle_unknown_command(client_id: ClientId, command: &Command, state: Arc>) { if command.password.is_some() { send_error( @@ -960,6 +1065,120 @@ fn normalize_color(input: &str) -> String { } } +fn parse_create_room_args(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("missing_room_name"); + } + let mut parts = trimmed.split_whitespace(); + let room_name = parts.next().unwrap_or_default().trim().to_string(); + if room_name.is_empty() { + return Err("missing_room_name"); + } + if room_name.len() > 64 { + return Err("room_name_too_long"); + } + let mut parsed = CreateRoomArgs { + room_name, + is_public: true, + gender_restriction_id: None, + min_age: None, + max_age: None, + password: None, + friends_of_owner_only: false, + required_user_right_id: None, + room_type_id: None, + }; + + for token in parts { + let lower = token.to_lowercase(); + if lower == "public" { + parsed.is_public = true; + continue; + } + if lower == "private" { + parsed.is_public = false; + continue; + } + if lower == "friends" || lower == "friends_only" { + parsed.friends_of_owner_only = true; + continue; + } + if lower == "nofriends" || lower == "no_friends" { + parsed.friends_of_owner_only = false; + continue; + } + let Some((key, value_raw)) = token.split_once('=') else { + return Err("invalid_create_room_param"); + }; + let key = key.trim().to_lowercase(); + let value = value_raw.trim(); + if value.is_empty() { + continue; + } + match key.as_str() { + "public" | "is_public" => { + let b = parse_bool(value).ok_or("invalid_boolean_param")?; + parsed.is_public = b; + } + "friends_only" | "friends_of_owner_only" | "friends" => { + let b = parse_bool(value).ok_or("invalid_boolean_param")?; + parsed.friends_of_owner_only = b; + } + "gender" | "g" => { + parsed.gender_restriction_id = parse_gender_mf(value).ok_or("invalid_gender_param_use_m_or_f")?; + } + "min_age" | "min" => { + parsed.min_age = Some(value.parse::().map_err(|_| "invalid_min_age")?); + } + "max_age" | "max" => { + parsed.max_age = Some(value.parse::().map_err(|_| "invalid_max_age")?); + } + "password" | "pw" => { + parsed.password = Some(value.to_string()); + } + "right" | "right_id" | "required_right" | "required_user_right_id" => { + parsed.required_user_right_id = Some(value.parse::().map_err(|_| "invalid_required_right_id")?); + } + "type" | "type_id" | "room_type" | "room_type_id" => { + parsed.room_type_id = Some(value.parse::().map_err(|_| "invalid_room_type_id")?); + } + _ => return Err("unknown_create_room_param"), + } + } + + if let (Some(min_age), Some(max_age)) = (parsed.min_age, parsed.max_age) { + if min_age > max_age { + return Err("invalid_age_range"); + } + } + Ok(parsed) +} + +fn parse_bool(value: &str) -> Option { + match value.trim().to_lowercase().as_str() { + "1" | "true" | "yes" | "y" | "on" => Some(true), + "0" | "false" | "no" | "n" | "off" => Some(false), + _ => None, + } +} + +fn parse_gender_mf(value: &str) -> Option> { + match value.trim().to_lowercase().as_str() { + "m" | "male" => Some(Some(1)), + "f" | "female" => Some(Some(2)), + "any" | "none" | "-" => Some(None), + _ => None, + } +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + fn command_from_chat_line(message: &str, token: Option) -> Option { let trimmed = message.trim(); if !trimmed.starts_with('/') { @@ -1061,6 +1280,18 @@ fn command_from_chat_line(message: &str, token: Option) -> Option Some(Command { + cmd_type: Value::String("create_room".to_string()), + token, + name: None, + room: None, + message: Some(rest), + value: None, + password: None, + rounds: None, + to: None, + user_name: None, + }), "/do" => Some(Command { cmd_type: Value::String("do".to_string()), token, diff --git a/src/db.rs b/src/db.rs index c51ddb4..d775d83 100644 --- a/src/db.rs +++ b/src/db.rs @@ -221,6 +221,9 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result, & owner_id: row.get::<_, Option>("owner_id"), room_type_id: row.get::<_, Option>("room_type_id"), friends_of_owner_only: row.get::<_, bool>("friends_of_owner_only"), + is_temporary: false, + created_at_unix: None, + empty_since_unix: None, }); } diff --git a/src/main.rs b/src/main.rs index 8caf6dd..d76ea36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, UnixListener}; use tokio::sync::{mpsc, watch, RwLock}; +use tokio::time::{Duration, interval}; use tokio_rustls::TlsAcceptor; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_rustls::rustls::ServerConfig as RustlsServerConfig; @@ -58,6 +59,26 @@ async fn main() -> Result<(), Box> { } let next_client_id = Arc::new(AtomicU64::new(1)); let (shutdown_tx, shutdown_rx) = watch::channel(false); + let cleanup_state = Arc::clone(&state); + let mut cleanup_shutdown_rx = shutdown_rx.clone(); + let cleanup_task = tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(60)); + loop { + tokio::select! { + changed = cleanup_shutdown_rx.changed() => { + if changed.is_ok() && *cleanup_shutdown_rx.borrow() { + break; + } + } + _ = ticker.tick() => { + let removed = state::cleanup_stale_temporary_rooms(Arc::clone(&cleanup_state), 15 * 60).await; + if removed > 0 { + println!("[yourchat2] removed {removed} stale temporary room(s)"); + } + } + } + } + }); let ws_listener = TcpListener::bind(&ws_addr).await?; if ws_tls { @@ -205,6 +226,7 @@ async fn main() -> Result<(), Box> { let _ = ws_task.await; let _ = tcp_task.await; + let _ = cleanup_task.await; if let Some(task) = unix_task { let _ = task.await; if let Some(path) = unix_socket { diff --git a/src/state.rs b/src/state.rs index 55d1512..7ce2768 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,7 @@ use crate::types::{ChatState, ClientId, Command, RoomInfo}; use serde_json::{json, Value}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; pub async fn authorize(client_id: ClientId, command: &Command, state: Arc>) -> bool { @@ -31,12 +32,19 @@ pub async fn authorize(client_id: ClientId, command: &Command, state: Arc>) { let list = { let guard = state.read().await; - let mut rooms: Vec = guard - .rooms - .iter() - .map(|(name, members)| RoomInfo { - name: name.clone(), - users: members.len(), + let mut names = guard.room_meta.keys().cloned().collect::>(); + for name in guard.rooms.keys() { + if !guard.room_meta.contains_key(name) { + names.push(name.clone()); + } + } + names.sort(); + names.dedup(); + let mut rooms: Vec = names + .into_iter() + .map(|name| RoomInfo { + users: guard.rooms.get(&name).map(|members| members.len()).unwrap_or(0), + name, }) .collect(); rooms.sort_by(|a, b| a.name.cmp(&b.name)); @@ -126,7 +134,20 @@ pub async fn disconnect_client(client_id: ClientId, state: Arc if let Some(members) = guard.rooms.get_mut(&client.room) { members.remove(&client_id); if members.is_empty() { - guard.rooms.remove(&client.room); + let is_temporary = guard + .room_meta + .get(&client.room) + .map(|meta| meta.is_temporary) + .unwrap_or(false); + if is_temporary { + if let Some(meta) = guard.room_meta.get_mut(&client.room) { + if meta.empty_since_unix.is_none() { + meta.empty_since_unix = Some(now_unix()); + } + } + } else { + guard.rooms.remove(&client.room); + } } } } @@ -152,3 +173,56 @@ pub async fn disconnect_client(client_id: ClientId, state: Arc .await; } } + +pub async fn mark_room_occupied(room_name: &str, state: Arc>) { + let mut guard = state.write().await; + if let Some(meta) = guard.room_meta.get_mut(room_name) { + meta.empty_since_unix = None; + } +} + +pub async fn mark_room_possibly_empty(room_name: &str, state: Arc>) { + let mut guard = state.write().await; + let is_empty = guard.rooms.get(room_name).map(|members| members.is_empty()).unwrap_or(true); + if !is_empty { + return; + } + if let Some(meta) = guard.room_meta.get_mut(room_name) { + if meta.is_temporary && meta.empty_since_unix.is_none() { + meta.empty_since_unix = Some(now_unix()); + } + } +} + +pub async fn cleanup_stale_temporary_rooms(state: Arc>, max_empty_seconds: i64) -> usize { + let mut guard = state.write().await; + let now = now_unix(); + let to_remove = guard + .room_meta + .iter() + .filter_map(|(name, meta)| { + if !meta.is_temporary { + return None; + } + let empty_since = meta.empty_since_unix?; + if now - empty_since >= max_empty_seconds { + Some(name.clone()) + } else { + None + } + }) + .collect::>(); + for room in &to_remove { + guard.room_meta.remove(room); + guard.rooms.remove(room); + guard.dice_games.remove(room); + } + to_remove.len() +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/src/types.rs b/src/types.rs index f62fb1e..13cd98d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -95,4 +95,7 @@ pub(crate) struct RoomMeta { pub(crate) owner_id: Option, pub(crate) room_type_id: Option, pub(crate) friends_of_owner_only: bool, + pub(crate) is_temporary: bool, + pub(crate) created_at_unix: Option, + pub(crate) empty_since_unix: Option, }