From ad8d542e4e6bbc47f7a1b0572b63010c3f6d2246 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 20:00:23 +0000 Subject: [PATCH] chathistory: in-memory per-channel history + CHATHISTORY latest/before/after in a batch (draft/chathistory) --- src/coremods/core_message.rs | 101 ++++++++++++++++++++++++++++++++++- src/server.rs | 61 ++++++++++++++++++++- src/users.rs | 6 ++- 3 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 0c1e56d..46668b5 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -3,7 +3,7 @@ use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE}; use crate::command::{CmdResult, Command}; use crate::numeric::*; -use crate::server::Server; +use crate::server::{iso_time, parse_iso, HistMsg, Server, HISTORY_CAP}; use crate::Uid; /// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …). @@ -119,7 +119,103 @@ fn apply_censor(body: &str, censor: &[(String, String)]) -> Option { } pub fn commands() -> Vec> { - vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)] + vec![ + Box::new(PrivMsg), + Box::new(Notice), + Box::new(TagMsg), + Box::new(ChatHistory), + ] +} + +/// CHATHISTORY — replay recent channel messages (draft/chathistory), leveraging +/// BATCH. `CHATHISTORY <#chan> `, where +/// `` is `*`, `timestamp=` or `msgid=`. Only members get a +/// channel's history; the reply is a `chathistory` batch of the original +/// PRIVMSG/NOTICE lines, each carrying its stored server-time + msgid. +struct ChatHistory; +impl Command for ChatHistory { + fn name(&self) -> &'static str { + "CHATHISTORY" + } + fn min_params(&self) -> usize { + 4 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let sub = params[0].to_ascii_uppercase(); + let target = params[1].clone(); + let key = target.to_ascii_lowercase(); + let sel = params[2].as_str(); + let limit = params[3] + .parse::() + .unwrap_or(50) + .clamp(1, HISTORY_CAP); + + let bref = s.next_msgid().replace('-', ""); + let mut lines: Vec = Vec::new(); + if s.is_member(uid, &key) { + if let Some(buf) = s.history.get(&key) { + // Resolve the selector to a reference position. `msgid=` matches an + // exact buffer index (so same-second messages aren't lost); + // `timestamp=` and `*` fall back to a ts bound. + let ref_idx = sel + .strip_prefix("msgid=") + .and_then(|id| buf.iter().position(|m| m.msgid == id)); + let ref_ts = sel.strip_prefix("timestamp=").and_then(parse_iso); + let picked: Vec<&HistMsg> = match sub.as_str() { + "BEFORE" => { + let end = ref_idx.unwrap_or_else(|| { + let b = ref_ts.unwrap_or(u64::MAX); + buf.iter().position(|m| m.ts >= b).unwrap_or(buf.len()) + }); + let start = end.saturating_sub(limit); + buf.iter().take(end).skip(start).collect() + } + "AFTER" => { + let begin = match ref_idx { + Some(i) => i + 1, + None => { + let b = ref_ts.unwrap_or(0); + buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) + } + }; + buf.iter().skip(begin).take(limit).collect() + } + _ => { + // LATEST: newest `limit`, optionally bounded below by the selector + let begin = match (ref_idx, ref_ts) { + (Some(i), _) => i + 1, + (None, Some(b)) => { + buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) + } + _ => 0, + }; + let n = buf.len() - begin; + let start = begin + n.saturating_sub(limit); + buf.iter().skip(start).collect() + } + }; + for m in picked { + lines.push(format!( + "@time={};msgid={};batch={bref} :{} {} {target} :{}", + iso_time(m.ts), + m.msgid, + m.prefix, + m.verb, + m.text + )); + } + } + } + s.send( + uid, + format!(":{} BATCH +{bref} chathistory {target}", s.name), + ); + for l in lines { + s.send(uid, l); + } + s.send(uid, format!(":{} BATCH -{bref}", s.name)); + CmdResult::Ok + } } /// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies. @@ -308,6 +404,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu let line = format!(":{prefix} {cmd} {target} :{body}"); let ctags = s.line_ctags.clone(); let msgid = s.next_msgid(); // one id shared by every recipient of this message + s.store_history(&key, &prefix, cmd, &body, &msgid); // for CHATHISTORY let members: Vec = s .channels .get(&key) diff --git a/src/server.rs b/src/server.rs index 3bf0725..fe3a6c5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -32,6 +32,8 @@ pub const TICK_SECS: u64 = 15; pub const PING_AFTER: u64 = 90; pub const PING_TIMEOUT: u64 = 60; pub const REG_TIMEOUT: u64 = 60; +/// Recent messages CHATHISTORY keeps per channel. +pub const HISTORY_CAP: usize = 256; pub fn now() -> u64 { SystemTime::now() @@ -59,6 +61,39 @@ pub fn iso_time(secs: u64) -> String { format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z") } +/// Parse an IRCv3 `server-time` value (`2026-08-08T19:52:42.000Z`) back to unix +/// seconds — the inverse of [`iso_time`], for CHATHISTORY `timestamp=` selectors. +pub fn parse_iso(s: &str) -> Option { + let (date, time) = s.split_once('T')?; + let mut d = date.split('-'); + let y: i64 = d.next()?.parse().ok()?; + let mo: i64 = d.next()?.parse().ok()?; + let da: i64 = d.next()?.parse().ok()?; + let time = time.trim_end_matches('Z').split('.').next()?; + let mut t = time.split(':'); + let h: i64 = t.next()?.parse().ok()?; + let mi: i64 = t.next()?.parse().ok()?; + let se: i64 = t.next().unwrap_or("0").parse().ok()?; + // civil date -> days since 1970-01-01 (inverse Howard Hinnant) + let yy = y - i64::from(mo <= 2); + let era = if yy >= 0 { yy } else { yy - 399 } / 400; + let yoe = yy - era * 400; + let mp = if mo > 2 { mo - 3 } else { mo + 9 }; + let doy = (153 * mp + 2) / 5 + da - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + Some((days * 86400 + h * 3600 + mi * 60 + se).max(0) as u64) +} + +/// One stored channel message, replayed by CHATHISTORY. +pub struct HistMsg { + pub ts: u64, + pub msgid: String, + pub prefix: String, // sender's nick!user@host at send time + pub verb: &'static str, // "PRIVMSG" or "NOTICE" + pub text: String, +} + /// A recently-departed identity, kept for WHOWAS. pub struct WhowasEntry { pub nick: String, @@ -111,7 +146,8 @@ pub struct Server { // the command's `label` (single tag, BATCH, or ACK). RefCell because the // output primitives are `&self`. pub label_capture: RefCell)>>, - pub event_tx: Sender, // self-inject events (DNS results) + pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) + pub event_tx: Sender, // self-inject events (DNS results) } impl Server { @@ -153,6 +189,7 @@ impl Server { sasl_server: cfg.sasl_server, webirc: cfg.webirc, label_capture: RefCell::new(None), + history: HashMap::new(), event_tx, } } @@ -182,6 +219,28 @@ impl Server { } } + /// Record a channel message for CHATHISTORY replay (capped ring per channel). + pub fn store_history( + &mut self, + key: &str, + prefix: &str, + verb: &'static str, + text: &str, + msgid: &str, + ) { + let buf = self.history.entry(key.to_string()).or_default(); + buf.push_back(HistMsg { + ts: now(), + msgid: msgid.to_string(), + prefix: prefix.to_string(), + verb, + text: text.to_string(), + }); + while buf.len() > HISTORY_CAP { + buf.pop_front(); + } + } + // --- connection lifecycle ------------------------------------------------ pub fn add_conn( diff --git a/src/users.rs b/src/users.rs index 4e796c2..93bd559 100644 --- a/src/users.rs +++ b/src/users.rs @@ -97,6 +97,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "standard-replies", "labeled-response", "batch", + "draft/chathistory", "cap-notify", ]; @@ -122,6 +123,7 @@ pub struct Caps { pub standard_replies: bool, // understands FAIL/WARN/NOTE structured replies pub labeled_response: bool, // tag responses to a labeled command with its label pub batch: bool, // understands BATCH framing + pub chathistory: bool, // draft/chathistory — can request message history pub cap_notify: bool, } @@ -169,6 +171,7 @@ impl Caps { "standard-replies" => self.standard_replies, "labeled-response" => self.labeled_response, "batch" => self.batch, + "draft/chathistory" => self.chathistory, "cap-notify" => self.cap_notify, _ => false, } @@ -194,6 +197,7 @@ impl Caps { "standard-replies" => &mut self.standard_replies, "labeled-response" => &mut self.labeled_response, "batch" => &mut self.batch, + "draft/chathistory" => &mut self.chathistory, "cap-notify" => &mut self.cap_notify, _ => return false, }; @@ -392,7 +396,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 CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );