modules: draft/event-playback — record JOIN/PART/QUIT/NICK/MODE/TOPIC/KICK into CHATHISTORY, replayed only for cap clients (event_playback config, default on)

This commit is contained in:
Jean Chevronnet 2026-08-25 22:17:26 +00:00
parent 9dcc269f45
commit 6bb8ccd8ee
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
8 changed files with 179 additions and 19 deletions

View file

@ -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)
}

View file

@ -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<String> = match self.ext.get::<History>().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()
}

View file

@ -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
}

View file

@ -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();

View file

@ -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<String>,
}
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>(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::<usize>().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<String> = Vec::new();
@ -303,7 +356,15 @@ impl Command for ChatHistory {
}
};
for m in picked {
lines.push(format!(
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,
@ -311,7 +372,8 @@ impl Command for ChatHistory {
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>(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::<History>().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");
}
}

View file

@ -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::<Vec<String>>(),
)
}) else {
return;
};
let line = format!(":{prefix} QUIT :{reason}");
for key in chans {
record_event(srv, &key, &line);
}
}
}

View file

@ -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<dyn Module>> {
Box::new(opertypes::OperTypes),
Box::new(permchannels::PermChannels::default()),
Box::new(chathistory::ChatHistoryGc),
Box::new(event_playback::EventPlayback),
Box::new(account_registration::AcctRegGc),
]
}

View file

@ -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<String> = 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) {