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:
parent
9dcc269f45
commit
6bb8ccd8ee
8 changed files with 179 additions and 19 deletions
|
|
@ -181,6 +181,8 @@ limits {
|
||||||
# multiline_maxlines 24; # draft/multiline: max lines
|
# multiline_maxlines 24; # draft/multiline: max lines
|
||||||
# chathistory_limit 256; # CHATHISTORY messages kept per conversation
|
# chathistory_limit 256; # CHATHISTORY messages kept per conversation
|
||||||
# chathistory_maxage 604800; # max age (secs) a client may request (7 days)
|
# 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
|
# dccallow_maxentries 20; # /DCCALLOW list entries per user
|
||||||
# http_max_concurrent 32; # in-flight outbound HTTP requests (API modules)
|
# http_max_concurrent 32; # in-flight outbound HTTP requests (API modules)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1198,9 +1198,18 @@ impl Server {
|
||||||
} else {
|
} else {
|
||||||
None
|
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)) {
|
let out: Vec<String> = match self.ext.get::<History>().and_then(|h| h.0.get(key)) {
|
||||||
Some(buf) => {
|
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);
|
let start = recent.len().saturating_sub(lines as usize);
|
||||||
recent.drain(..start);
|
recent.drain(..start);
|
||||||
recent
|
recent
|
||||||
|
|
@ -1210,7 +1219,10 @@ impl Server {
|
||||||
if let Some(b) = &bref {
|
if let Some(b) = &bref {
|
||||||
tags.push_str(&format!(";batch={b}"));
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -622,7 +622,9 @@ impl Command for Kick {
|
||||||
.get(&vuuid)
|
.get(&vuuid)
|
||||||
.map(|r| r.nick.clone())
|
.map(|r| r.nick.clone())
|
||||||
.unwrap_or_else(|| victim.to_string());
|
.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);
|
s.propagate_kick(uid, chan, victim, &reason);
|
||||||
if let Some(ch) = s.channels.get_mut(&key) {
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
ch.rmembers.remove(&vuuid);
|
ch.rmembers.remove(&vuuid);
|
||||||
|
|
@ -760,7 +762,9 @@ impl Command for TopicCmd {
|
||||||
ts: now(),
|
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
|
s.propagate_topic(uid, target, &text); // tell links
|
||||||
CmdResult::Ok
|
CmdResult::Ok
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
crate::modules::hidemode::broadcast(s, &key, target, uid, &prefix, &changes);
|
||||||
} else {
|
} else {
|
||||||
s.to_channel(
|
let mline = format!(":{prefix} MODE {target} {applied}{pstr}");
|
||||||
&key,
|
crate::modules::chathistory::record_event(s, &key, &mline);
|
||||||
&format!(":{prefix} MODE {target} {applied}{pstr}"),
|
s.to_channel(&key, &mline, None);
|
||||||
None,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// links: a timestamped FMODE sourced from the acting user's uuid
|
// links: a timestamped FMODE sourced from the acting user's uuid
|
||||||
let src_uuid = s.users[&uid].uuid.clone();
|
let src_uuid = s.users[&uid].uuid.clone();
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,19 @@ pub struct HistMsg {
|
||||||
pub ts: u64,
|
pub ts: u64,
|
||||||
pub msgid: String,
|
pub msgid: String,
|
||||||
pub prefix: String, // sender's nick!user@host at send time
|
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 target: String, // original target (channel, or the DM recipient)
|
||||||
pub text: String,
|
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`.
|
/// conversation key (`#chan` or a DM-pair key) -> capped ring. Stored in `Server.ext`.
|
||||||
|
|
@ -62,6 +72,43 @@ pub fn record(
|
||||||
verb,
|
verb,
|
||||||
target: target.to_string(),
|
target: target.to_string(),
|
||||||
text: text.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 {
|
while buf.len() > cap {
|
||||||
buf.pop_front();
|
buf.pop_front();
|
||||||
|
|
@ -230,6 +277,12 @@ impl Command for ChatHistory {
|
||||||
.and_then(|l| l.parse::<usize>().ok())
|
.and_then(|l| l.parse::<usize>().ok())
|
||||||
.unwrap_or(50)
|
.unwrap_or(50)
|
||||||
.clamp(1, limit(s));
|
.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 bref = s.next_msgid().replace('-', "");
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
|
@ -303,7 +356,15 @@ impl Command for ChatHistory {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for m in picked {
|
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} :{} {} {} :{}",
|
"@time={};msgid={};batch={bref} :{} {} {} :{}",
|
||||||
iso_time(m.ts),
|
iso_time(m.ts),
|
||||||
m.msgid,
|
m.msgid,
|
||||||
|
|
@ -311,7 +372,8 @@ impl Command for ChatHistory {
|
||||||
m.verb,
|
m.verb,
|
||||||
m.target,
|
m.target,
|
||||||
m.text
|
m.text
|
||||||
));
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -437,6 +499,7 @@ mod tests {
|
||||||
verb: "PRIVMSG",
|
verb: "PRIVMSG",
|
||||||
target: "#c".into(),
|
target: "#c".into(),
|
||||||
text: "hi".into(),
|
text: "hi".into(),
|
||||||
|
raw: None,
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let h = s.ext.get_or_insert_with::<History>(History::default);
|
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("#fresh"), "recent conversation kept");
|
||||||
assert!(!h.0.contains_key("#stale"), "stale conversation GC'd");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/modules/event_playback.rs
Normal file
61
src/modules/event_playback.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ pub mod connflood;
|
||||||
pub mod customprefix;
|
pub mod customprefix;
|
||||||
pub mod customtitle;
|
pub mod customtitle;
|
||||||
pub mod dccallow;
|
pub mod dccallow;
|
||||||
|
pub mod event_playback;
|
||||||
pub mod denychans;
|
pub mod denychans;
|
||||||
pub mod disable;
|
pub mod disable;
|
||||||
pub mod dnsbl;
|
pub mod dnsbl;
|
||||||
|
|
@ -117,6 +118,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||||
Box::new(opertypes::OperTypes),
|
Box::new(opertypes::OperTypes),
|
||||||
Box::new(permchannels::PermChannels::default()),
|
Box::new(permchannels::PermChannels::default()),
|
||||||
Box::new(chathistory::ChatHistoryGc),
|
Box::new(chathistory::ChatHistoryGc),
|
||||||
|
Box::new(event_playback::EventPlayback),
|
||||||
Box::new(account_registration::AcctRegGc),
|
Box::new(account_registration::AcctRegGc),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ define_caps! {
|
||||||
"labeled-response" => labeled_response,
|
"labeled-response" => labeled_response,
|
||||||
"batch" => batch,
|
"batch" => batch,
|
||||||
"draft/chathistory" => chathistory,
|
"draft/chathistory" => chathistory,
|
||||||
|
"draft/event-playback" => event_playback,
|
||||||
"draft/message-redaction" => message_redaction,
|
"draft/message-redaction" => message_redaction,
|
||||||
"draft/pre-away" => pre_away,
|
"draft/pre-away" => pre_away,
|
||||||
"draft/metadata-2" => metadata,
|
"draft/metadata-2" => metadata,
|
||||||
|
|
@ -424,6 +425,7 @@ impl Server {
|
||||||
targets.insert(uid);
|
targets.insert(uid);
|
||||||
let chans: Vec<String> = self.users[&uid].channels.iter().cloned().collect();
|
let chans: Vec<String> = self.users[&uid].channels.iter().cloned().collect();
|
||||||
for key in &chans {
|
for key in &chans {
|
||||||
|
crate::modules::chathistory::record_event(self, key, &line);
|
||||||
if let Some(ch) = self.channels.get(key) {
|
if let Some(ch) = self.channels.get(key) {
|
||||||
// +D delayjoin: a still-hidden member's NICK isn't shown here
|
// +D delayjoin: a still-hidden member's NICK isn't shown here
|
||||||
if ch.members.get(&uid).map(|m| m.hidden).unwrap_or(false) {
|
if ch.members.get(&uid).map(|m| m.hidden).unwrap_or(false) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue