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
|
||||
# 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,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>(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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue