use net_backend_protocol::chat::{
is_valid_room_key, DEFAULT_HISTORY_RETENTION_DAYS, DEFAULT_MAX_ROOM_MEMBERS, DEFAULT_MAX_TEXT_CHARS, DEFAULT_PRESENCE_MAX_MEMBERS,
DEFAULT_PRESENCE_PER_SECOND, DEFAULT_RATE_MESSAGES, DEFAULT_RATE_WINDOW_SECS,
};
use serde::Deserialize;
use super::migrations::BODY_CHARS;
use crate::error::Error;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct RoomSpec {
pub key: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub max_members: Option<u32>,
}
impl RoomSpec {
pub fn new(key: impl Into<String>) -> Self {
Self { key: key.into(), name: None, max_members: None }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_max_members(mut self, max: u32) -> Self {
self.max_members = Some(max);
self
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct ChatConfig {
pub rooms: Vec<RoomSpec>,
pub max_text_chars: u32,
pub rate_messages: u32,
pub rate_window_secs: u32,
pub dm_open_rate: u32,
pub dm_open_window_secs: u32,
pub history_retention_days: u32,
pub purge_interval_secs: u64,
pub max_room_members: u32,
pub max_group_members: u32,
pub presence: bool,
pub presence_max_members: u32,
pub presence_per_second: u32,
pub allow_self_delete: bool,
pub moderator_roles: Vec<String>,
}
impl Default for ChatConfig {
fn default() -> Self {
Self {
rooms: Vec::new(),
max_text_chars: DEFAULT_MAX_TEXT_CHARS as u32,
rate_messages: DEFAULT_RATE_MESSAGES,
rate_window_secs: DEFAULT_RATE_WINDOW_SECS,
dm_open_rate: 20,
dm_open_window_secs: 600,
history_retention_days: DEFAULT_HISTORY_RETENTION_DAYS,
purge_interval_secs: 3600,
max_room_members: DEFAULT_MAX_ROOM_MEMBERS,
max_group_members: DEFAULT_MAX_ROOM_MEMBERS,
presence: true,
presence_max_members: DEFAULT_PRESENCE_MAX_MEMBERS,
presence_per_second: DEFAULT_PRESENCE_PER_SECOND,
allow_self_delete: true,
moderator_roles: vec!["admin".into(), "moderator".into()],
}
}
}
impl ChatConfig {
pub fn validate(&self) -> Result<(), Error> {
let mut problems = Vec::new();
let mut need = |ok: bool, problem: String| {
if !ok {
problems.push(format!("modules.chat.{problem}"));
}
};
need((1..=BODY_CHARS).contains(&self.max_text_chars), format!("max_text_chars must be between 1 and {BODY_CHARS}"));
need((1..=10_000).contains(&self.rate_messages), "rate_messages must be between 1 and 10000".into());
need((1..=86_400).contains(&self.rate_window_secs), "rate_window_secs must be between 1 and 86400".into());
need(self.dm_open_rate <= 100_000, "dm_open_rate must be at most 100000 (0 = no limit)".into());
need((1..=86_400).contains(&self.dm_open_window_secs), "dm_open_window_secs must be between 1 and 86400".into());
need(self.history_retention_days <= 36_500, "history_retention_days must be at most 36500".into());
need(self.purge_interval_secs <= 7 * 86_400, "purge_interval_secs must be at most 604800".into());
need((1..=10_000_000).contains(&self.max_room_members), "max_room_members must be between 1 and 10000000".into());
need((1..=10_000_000).contains(&self.max_group_members), "max_group_members must be between 1 and 10000000".into());
need((1..=100_000).contains(&self.presence_max_members), "presence_max_members must be between 1 and 100000".into());
need((1..=10_000).contains(&self.presence_per_second), "presence_per_second must be between 1 and 10000".into());
for role in &self.moderator_roles {
need(net_backend_protocol::admin::is_valid_role(role), format!("moderator_roles: `{role}` is not a role name"));
}
let mut keys = std::collections::HashSet::new();
for room in &self.rooms {
need(is_valid_room_key(&room.key), format!("rooms: `{}` is not a room key (1-64 bytes of a-z, 0-9, _ - .)", room.key));
need(keys.insert(room.key.as_str()), format!("rooms: `{}` is listed twice", room.key));
if let Some(name) = &room.name {
need(is_valid_room_name(name), format!("rooms: the name of `{}` must be 1-64 characters", room.key));
}
if let Some(max) = room.max_members {
need((1..=10_000_000).contains(&max), format!("rooms: max_members of `{}` must be between 1 and 10000000", room.key));
}
}
if problems.is_empty() {
Ok(())
} else {
Err(Error::Config(problems))
}
}
pub(crate) fn cutoff(&self, now: i64) -> Option<i64> {
(self.history_retention_days > 0).then(|| now.saturating_sub(i64::from(self.history_retention_days).saturating_mul(86_400_000)))
}
}
pub(crate) fn is_valid_room_name(name: &str) -> bool {
!name.trim().is_empty() && name.chars().count() <= 64 && net_backend_protocol::text::name_problem(name).is_none()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_problems() {
assert!(ChatConfig::default().validate().is_ok());
let bad = ChatConfig {
max_text_chars: 5000,
rooms: vec![RoomSpec::new("World"), RoomSpec::new("a"), RoomSpec::new("a").with_max_members(0)],
moderator_roles: vec!["Mod".into()],
..ChatConfig::default()
};
let error = bad.validate().err().map(|e| e.to_string()).unwrap_or_default();
for part in ["max_text_chars", "`World` is not a room key", "`a` is listed twice", "max_members of `a`", "`Mod` is not a role name"] {
assert!(error.contains(part), "{part}: {error}");
}
let config = ChatConfig::default();
assert_eq!(config.cutoff(31 * 86_400_000), Some(86_400_000));
assert!(is_valid_room_name("Guild Hall") && !is_valid_room_name(" ") && !is_valid_room_name(&"x".repeat(65)) && !is_valid_room_name("a\u{202E}b"));
let bad = ChatConfig { dm_open_window_secs: 0, ..ChatConfig::default() };
assert!(bad.validate().err().map(|e| e.to_string()).unwrap_or_default().contains("dm_open_window_secs"));
let forever = ChatConfig { history_retention_days: 0, ..ChatConfig::default() };
assert_eq!(forever.cutoff(5), None);
}
}