diff --git a/src/channels.rs b/src/channels.rs index a24306b..a2f226e 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use crate::module::Hook; use crate::numeric::*; -use crate::server::{now, Server}; +use crate::server::{iso_time, now, HistMsg, Server}; use crate::Uid; /// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. @@ -123,29 +123,30 @@ pub struct Rate { /// Channel modes other than the per-member prefixes. #[derive(Default)] pub struct ChanModes { - pub moderated: bool, // +m — only +o/+v may speak - pub topic_ops: bool, // +t — only ops may set the topic - pub no_external: bool, // +n — must be a member to message it - pub invite_only: bool, // +i - pub secret: bool, // +s - pub key: Option, // +k - pub limit: Option, // +l - pub secure_only: bool, // +z — only TLS-connected users may join - pub private: bool, // +p — private (hidden from WHOIS channel list) - pub oper_only: bool, // +O — only IRC operators may join - pub no_nick: bool, // +N — members can't change nick while here - pub no_ctcp: bool, // +C — block CTCP to the channel - pub no_notice: bool, // +T — block NOTICEs to the channel - pub no_color: bool, // +c — reject messages with formatting/colour - pub strip_color: bool, // +S — strip formatting/colour from messages - pub reg_only: bool, // +R — only logged-in (account) users may join - pub reg_moderated: bool, // +M — only logged-in users may speak - pub censor: bool, // +G — replace configured bad words - pub auditorium: bool, // +u — hide non-ops from non-ops - pub flood: Option, // +f - pub joinflood: Option, // +j - pub nickflood: Option, // +F - pub redirect: Option, // +L <#target> — when full, send there + pub moderated: bool, // +m — only +o/+v may speak + pub topic_ops: bool, // +t — only ops may set the topic + pub no_external: bool, // +n — must be a member to message it + pub invite_only: bool, // +i + pub secret: bool, // +s + pub key: Option, // +k + pub limit: Option, // +l + pub secure_only: bool, // +z — only TLS-connected users may join + pub private: bool, // +p — private (hidden from WHOIS channel list) + pub oper_only: bool, // +O — only IRC operators may join + pub no_nick: bool, // +N — members can't change nick while here + pub no_ctcp: bool, // +C — block CTCP to the channel + pub no_notice: bool, // +T — block NOTICEs to the channel + pub no_color: bool, // +c — reject messages with formatting/colour + pub strip_color: bool, // +S — strip formatting/colour from messages + pub reg_only: bool, // +R — only logged-in (account) users may join + pub reg_moderated: bool, // +M — only logged-in users may speak + pub censor: bool, // +G — replace configured bad words + pub auditorium: bool, // +u — hide non-ops from non-ops + pub flood: Option, // +f + pub joinflood: Option, // +j + pub nickflood: Option, // +F + pub redirect: Option, // +L <#target> — when full, send there + pub history: Option<(u32, u64)>, // +H : — replay recent messages to joiners } impl ChanModes { @@ -217,6 +218,9 @@ impl ChanModes { if self.redirect.is_some() { s.push('L'); } + if self.history.is_some() { + s.push('H'); + } if params { if let Some(k) = &self.key { s.push(' '); @@ -240,6 +244,9 @@ impl ChanModes { s.push(' '); s.push_str(t); } + if let Some((n, t)) = &self.history { + s.push_str(&format!(" {n}:{t}")); + } } s } @@ -518,10 +525,64 @@ impl Server { self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}")); } self.send_names(uid, &key); + self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner self.propagate_join(uid, name); // tell linked servers this user joined self.events.push_back(Hook::Join(uid, key)); } + /// +H chanhistory: replay a channel's recent messages to a user who just + /// joined — the last `` (within ``, 0 = no limit) from the store, + /// wrapped in a `chathistory` batch for batch-capable clients. + fn replay_chanhistory(&mut self, uid: Uid, key: &str) { + let Some((lines, secs)) = self.channels.get(key).and_then(|c| c.modes.history) else { + return; + }; + let Some(name) = self.channels.get(key).map(|c| c.name.clone()) else { + return; + }; + let batch = self.users.get(&uid).map(|u| u.caps.batch).unwrap_or(false); + let cutoff = if secs > 0 { + now().saturating_sub(secs) + } else { + 0 + }; + let bref = if batch { + Some(self.next_msgid().replace('-', "")) + } else { + None + }; + let out: Vec = match self.history.get(key) { + Some(buf) => { + let mut recent: Vec<&HistMsg> = buf.iter().filter(|m| m.ts >= cutoff).collect(); + let start = recent.len().saturating_sub(lines as usize); + recent.drain(..start); + recent + .iter() + .map(|m| { + let mut tags = format!("time={};msgid={}", iso_time(m.ts), m.msgid); + if let Some(b) = &bref { + tags.push_str(&format!(";batch={b}")); + } + format!("@{tags} :{} {} {name} :{}", m.prefix, m.verb, m.text) + }) + .collect() + } + None => return, + }; + if out.is_empty() { + return; + } + if let Some(b) = &bref { + self.send(uid, format!(":{} BATCH +{b} chathistory {name}", self.name)); + } + for l in out { + self.send(uid, l); + } + if let Some(b) = &bref { + self.send(uid, format!(":{} BATCH -{b}", self.name)); + } + } + pub fn send_names(&self, uid: Uid, key: &str) { let Some(ch) = self.channels.get(key) else { self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list")); diff --git a/src/mode.rs b/src/mode.rs index 79e84dd..408f4bf 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -83,6 +83,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &JOINFLOOD, &NICKFLOOD, &REDIRECT, + &CHANHISTORY, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -390,6 +391,49 @@ impl ChanMode for Limit { } } +// --- +H chanhistory: replay recent messages to joiners ---------------------- + +struct ChanHistory; +static CHANHISTORY: ChanHistory = ChanHistory; +impl ChanMode for ChanHistory { + fn letter(&self) -> char { + 'H' + } + fn wants_param(&self, adding: bool) -> bool { + adding // +H [:]; -H takes nothing + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(p) = param else { + return Applied::No; + }; + let (lines_s, secs_s) = p.split_once(':').unwrap_or((p, "0")); + let Some(lines) = lines_s.parse::().ok().filter(|&n| n > 0) else { + return Applied::No; + }; + let lines = lines.min(crate::server::HISTORY_CAP as u32); + let secs = secs_s.parse::().unwrap_or(0); + if let Some(c) = s.channels.get_mut(key) { + c.modes.history = Some((lines, secs)); + } + Applied::Yes(Some(format!("{lines}:{secs}"))) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.history = None; + } + Applied::Yes(None) + } + } +} + // --- list modes (+b bans, +e ban exceptions, +I invite exceptions) ---------- #[derive(Clone, Copy)] diff --git a/src/users.rs b/src/users.rs index 6c725c3..8039f84 100644 --- a/src/users.rs +++ b/src/users.rs @@ -416,7 +416,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLH,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );