diff --git a/echoircd.conf.example b/echoircd.conf.example index f7096c7..10278fe 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -181,6 +181,8 @@ limits { # multiline_maxlines 24; # draft/multiline: max lines # chathistory_limit 256; # CHATHISTORY messages kept per conversation # chathistory_maxage 604800; # max age (secs) a client may request (7 days) + # event_playback yes; # record JOIN/PART/QUIT/NICK/MODE/TOPIC/KICK into + # # history for draft/event-playback clients (no = off) # dccallow_maxentries 20; # /DCCALLOW list entries per user # http_max_concurrent 32; # in-flight outbound HTTP requests (API modules) } diff --git a/src/channels.rs b/src/channels.rs index 27f5807..6e205aa 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1198,9 +1198,18 @@ impl Server { } else { None }; + // draft/event-playback: JOIN/PART/… events in the +H backlog only for cap clients. + let want_events = self + .users + .get(&uid) + .map(|u| u.caps.event_playback) + .unwrap_or(false); let out: Vec = match self.ext.get::().and_then(|h| h.0.get(key)) { Some(buf) => { - let mut recent: Vec<&HistMsg> = buf.iter().filter(|m| m.ts >= cutoff).collect(); + let mut recent: Vec<&HistMsg> = buf + .iter() + .filter(|m| m.ts >= cutoff && (want_events || !m.is_event())) + .collect(); let start = recent.len().saturating_sub(lines as usize); recent.drain(..start); recent @@ -1210,7 +1219,10 @@ impl Server { if let Some(b) = &bref { tags.push_str(&format!(";batch={b}")); } - format!("@{tags} :{} {} {name} :{}", m.prefix, m.verb, m.text) + match &m.raw { + Some(raw) => format!("@{tags} {raw}"), + None => format!("@{tags} :{} {} {name} :{}", m.prefix, m.verb, m.text), + } }) .collect() } diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index c36082f..1b41e70 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -622,7 +622,9 @@ impl Command for Kick { .get(&vuuid) .map(|r| r.nick.clone()) .unwrap_or_else(|| victim.to_string()); - s.to_channel(&key, &format!(":{prefix} KICK {chan} {vnick} :{reason}"), None); + let kline = format!(":{prefix} KICK {chan} {vnick} :{reason}"); + crate::modules::chathistory::record_event(s, &key, &kline); + s.to_channel(&key, &kline, None); s.propagate_kick(uid, chan, victim, &reason); if let Some(ch) = s.channels.get_mut(&key) { ch.rmembers.remove(&vuuid); @@ -760,7 +762,9 @@ impl Command for TopicCmd { ts: now(), }); } - s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None); + let tline = format!(":{prefix} TOPIC {target} :{text}"); + crate::modules::chathistory::record_event(s, &key, &tline); + s.to_channel(&key, &tline, None); s.propagate_topic(uid, target, &text); // tell links CmdResult::Ok } diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index ae6164d..d4e0e69 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -175,11 +175,9 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { { crate::modules::hidemode::broadcast(s, &key, target, uid, &prefix, &changes); } else { - s.to_channel( - &key, - &format!(":{prefix} MODE {target} {applied}{pstr}"), - None, - ); + let mline = format!(":{prefix} MODE {target} {applied}{pstr}"); + crate::modules::chathistory::record_event(s, &key, &mline); + s.to_channel(&key, &mline, None); } // links: a timestamped FMODE sourced from the acting user's uuid let src_uuid = s.users[&uid].uuid.clone(); diff --git a/src/modules/chathistory.rs b/src/modules/chathistory.rs index b25cff5..ca3535a 100644 --- a/src/modules/chathistory.rs +++ b/src/modules/chathistory.rs @@ -28,9 +28,19 @@ 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 verb: &'static str, // "PRIVMSG"/"NOTICE" for messages; the event verb otherwise pub target: String, // original target (channel, or the DM recipient) pub text: String, + /// `Some(full ":prefix VERB …" line)` for a channel *event* (JOIN/PART/QUIT/NICK/ + /// MODE/TOPIC/KICK) replayed under `draft/event-playback`; `None` for a message. + pub raw: Option, +} + +impl HistMsg { + /// An event (non-message) entry — filtered out for clients without event-playback. + pub fn is_event(&self) -> bool { + self.raw.is_some() + } } /// conversation key (`#chan` or a DM-pair key) -> capped ring. Stored in `Server.ext`. @@ -62,6 +72,43 @@ pub fn record( verb, target: target.to_string(), text: text.to_string(), + raw: None, + }); + while buf.len() > cap { + buf.pop_front(); + } +} + +/// Record a channel *event* (`line` = the full `:prefix VERB …` wire form) into the +/// same per-conversation ring, for replay under `draft/event-playback`. It carries a +/// fresh msgid and replays verbatim; clients without the cap never see it. Off when +/// `event_playback = no`. Called from the local JOIN/PART/QUIT/NICK/MODE/TOPIC/KICK +/// paths — see [`crate::modules::event_playback`] and the channel/mode command code. +pub fn record_event(s: &mut Server, key: &str, line: &str) { + if !s.conf_bool("event_playback", true) { + return; + } + let cap = limit(s); + let msgid = s.next_msgid(); + let prefix = line + .strip_prefix(':') + .and_then(|l| l.split(' ').next()) + .unwrap_or("") + .to_string(); + let buf = s + .ext + .get_or_insert_with::(History::default) + .0 + .entry(key.to_string()) + .or_default(); + buf.push_back(HistMsg { + ts: now(), + msgid, + prefix, + verb: "*", + target: key.to_string(), + text: String::new(), + raw: Some(line.to_string()), }); while buf.len() > cap { buf.pop_front(); @@ -230,6 +277,12 @@ impl Command for ChatHistory { .and_then(|l| l.parse::().ok()) .unwrap_or(50) .clamp(1, limit(s)); + // draft/event-playback: include JOIN/PART/… events only for clients that asked. + let want_events = s + .users + .get(&uid) + .map(|u| u.caps.event_playback) + .unwrap_or(false); let bref = s.next_msgid().replace('-', ""); let mut lines: Vec = Vec::new(); @@ -303,15 +356,24 @@ impl Command for ChatHistory { } }; for m in picked { - lines.push(format!( - "@time={};msgid={};batch={bref} :{} {} {} :{}", - iso_time(m.ts), - m.msgid, - m.prefix, - m.verb, - m.target, - m.text - )); + if m.is_event() && !want_events { + continue; + } + lines.push(match &m.raw { + // an event replays as its exact wire line + history tags + Some(raw) => { + format!("@time={};msgid={};batch={bref} {raw}", iso_time(m.ts), m.msgid) + } + None => format!( + "@time={};msgid={};batch={bref} :{} {} {} :{}", + iso_time(m.ts), + m.msgid, + m.prefix, + m.verb, + m.target, + m.text + ), + }); } } } @@ -437,6 +499,7 @@ mod tests { verb: "PRIVMSG", target: "#c".into(), text: "hi".into(), + raw: None, }; { let h = s.ext.get_or_insert_with::(History::default); @@ -448,4 +511,20 @@ mod tests { assert!(h.0.contains_key("#fresh"), "recent conversation kept"); assert!(!h.0.contains_key("#stale"), "stale conversation GC'd"); } + + // draft/event-playback: record_event stores a verbatim event entry flagged so + // CHATHISTORY/+H can filter it out for clients without the cap; messages aren't. + #[test] + fn event_playback_records_and_flags_events() { + let (tx, _rx) = mpsc::channel(); + let mut s = Server::new(Config::default(), tx, Arc::new(AtomicU64::new(1))); + record(&mut s, "#c", "a!u@h", "PRIVMSG", "#c", "hi", "m1"); + record_event(&mut s, "#c", ":a!u@h JOIN #c"); + let buf = &s.ext.get::().unwrap().0["#c"]; + assert_eq!(buf.len(), 2, "one message + one event stored"); + assert!(!buf[0].is_event(), "the PRIVMSG is not an event"); + assert!(buf[1].is_event(), "the recorded JOIN is an event"); + assert_eq!(buf[1].raw.as_deref(), Some(":a!u@h JOIN #c")); + assert!(!buf[1].msgid.is_empty(), "event carries a msgid for replay"); + } } diff --git a/src/modules/event_playback.rs b/src/modules/event_playback.rs new file mode 100644 index 0000000..ba2fcbf --- /dev/null +++ b/src/modules/event_playback.rs @@ -0,0 +1,61 @@ +//! draft/event-playback — replay channel *events* (JOIN/PART/QUIT/NICK/MODE/TOPIC/ +//! KICK) in CHATHISTORY, interleaved with messages, for clients that negotiate the +//! cap. Without it, history stays messages-only (the classic behaviour). +//! +//! Events are recorded into the same per-conversation ring as messages +//! ([`crate::modules::chathistory::record_event`]) and replayed verbatim; the CHATHISTORY +//! command and the `+H` join-backlog filter them out for clients lacking the cap. +//! JOIN/PART/QUIT are captured here through the module lifecycle hooks; NICK/MODE/TOPIC/ +//! KICK have no hook, so they call `record_event` directly from their command paths. +//! The whole feature is gated by `event_playback` (default on) inside `record_event`. + +use crate::modules::chathistory::record_event; +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +pub struct EventPlayback; + +impl Module for EventPlayback { + fn name(&self) -> &'static str { + "event-playback" + } + + fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) { + let Some(prefix) = srv.users.get(&uid).map(|u| u.prefix()) else { + return; + }; + let key = chan.to_ascii_lowercase(); + record_event(srv, &key, &format!(":{prefix} JOIN {chan}")); + } + + fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) { + let Some(prefix) = srv.users.get(&uid).map(|u| u.prefix()) else { + return; + }; + let key = chan.to_ascii_lowercase(); + let line = if reason.is_empty() { + format!(":{prefix} PART {chan}") + } else { + format!(":{prefix} PART {chan} :{reason}") + }; + record_event(srv, &key, &line); + } + + fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) { + // A QUIT isn't channel-scoped on the wire, so mirror it into every channel the + // user still shares — that's where a scrolling client expects to see it. + let Some((prefix, chans)) = srv.users.get(&uid).map(|u| { + ( + u.prefix(), + u.channels.iter().cloned().collect::>(), + ) + }) else { + return; + }; + let line = format!(":{prefix} QUIT :{reason}"); + for key in chans { + record_event(srv, &key, &line); + } + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 1401436..d1599d3 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -24,6 +24,7 @@ pub mod connflood; pub mod customprefix; pub mod customtitle; pub mod dccallow; +pub mod event_playback; pub mod denychans; pub mod disable; pub mod dnsbl; @@ -117,6 +118,7 @@ pub fn default_modules() -> Vec> { Box::new(opertypes::OperTypes), Box::new(permchannels::PermChannels::default()), Box::new(chathistory::ChatHistoryGc), + Box::new(event_playback::EventPlayback), Box::new(account_registration::AcctRegGc), ] } diff --git a/src/users.rs b/src/users.rs index 7f79035..b4aacc9 100644 --- a/src/users.rs +++ b/src/users.rs @@ -157,6 +157,7 @@ define_caps! { "labeled-response" => labeled_response, "batch" => batch, "draft/chathistory" => chathistory, + "draft/event-playback" => event_playback, "draft/message-redaction" => message_redaction, "draft/pre-away" => pre_away, "draft/metadata-2" => metadata, @@ -424,6 +425,7 @@ impl Server { targets.insert(uid); let chans: Vec = self.users[&uid].channels.iter().cloned().collect(); for key in &chans { + crate::modules::chathistory::record_event(self, key, &line); if let Some(ch) = self.channels.get(key) { // +D delayjoin: a still-hidden member's NICK isn't shown here if ch.members.get(&uid).map(|m| m.hidden).unwrap_or(false) {