channel +H chanhistory: replay recent messages to joiners (m_chanhistory), reuses the history store

This commit is contained in:
Jean Chevronnet 2026-08-08 22:24:29 +00:00
parent d4c4c1a7d0
commit 7bf9e4c0b8
3 changed files with 130 additions and 25 deletions

View file

@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet};
use crate::module::Hook;
use crate::numeric::*;
use crate::server::{now, Server};
use crate::server::{iso_time, now, HistMsg, Server};
use crate::Uid;
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
@ -146,6 +146,7 @@ pub struct ChanModes {
pub joinflood: Option<Rate>, // +j
pub nickflood: Option<Rate>, // +F
pub redirect: Option<String>, // +L <#target> — when full, send there
pub history: Option<(u32, u64)>, // +H <lines>:<secs> — replay recent messages to joiners
}
impl ChanModes {
@ -217,6 +218,9 @@ impl ChanModes {
if self.redirect.is_some() {
s.push('L');
}
if self.history.is_some() {
s.push('H');
}
if params {
if let Some(k) = &self.key {
s.push(' ');
@ -240,6 +244,9 @@ impl ChanModes {
s.push(' ');
s.push_str(t);
}
if let Some((n, t)) = &self.history {
s.push_str(&format!(" {n}:{t}"));
}
}
s
}
@ -518,10 +525,64 @@ impl Server {
self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}"));
}
self.send_names(uid, &key);
self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner
self.propagate_join(uid, name); // tell linked servers this user joined
self.events.push_back(Hook::Join(uid, key));
}
/// +H chanhistory: replay a channel's recent messages to a user who just
/// joined — the last `<lines>` (within `<secs>`, 0 = no limit) from the store,
/// wrapped in a `chathistory` batch for batch-capable clients.
fn replay_chanhistory(&mut self, uid: Uid, key: &str) {
let Some((lines, secs)) = self.channels.get(key).and_then(|c| c.modes.history) else {
return;
};
let Some(name) = self.channels.get(key).map(|c| c.name.clone()) else {
return;
};
let batch = self.users.get(&uid).map(|u| u.caps.batch).unwrap_or(false);
let cutoff = if secs > 0 {
now().saturating_sub(secs)
} else {
0
};
let bref = if batch {
Some(self.next_msgid().replace('-', ""))
} else {
None
};
let out: Vec<String> = match self.history.get(key) {
Some(buf) => {
let mut recent: Vec<&HistMsg> = buf.iter().filter(|m| m.ts >= cutoff).collect();
let start = recent.len().saturating_sub(lines as usize);
recent.drain(..start);
recent
.iter()
.map(|m| {
let mut tags = format!("time={};msgid={}", iso_time(m.ts), m.msgid);
if let Some(b) = &bref {
tags.push_str(&format!(";batch={b}"));
}
format!("@{tags} :{} {} {name} :{}", m.prefix, m.verb, m.text)
})
.collect()
}
None => return,
};
if out.is_empty() {
return;
}
if let Some(b) = &bref {
self.send(uid, format!(":{} BATCH +{b} chathistory {name}", self.name));
}
for l in out {
self.send(uid, l);
}
if let Some(b) = &bref {
self.send(uid, format!(":{} BATCH -{b}", self.name));
}
}
pub fn send_names(&self, uid: Uid, key: &str) {
let Some(ch) = self.channels.get(key) else {
self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list"));

View file

@ -83,6 +83,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[
&JOINFLOOD,
&NICKFLOOD,
&REDIRECT,
&CHANHISTORY,
];
// --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ----------
@ -390,6 +391,49 @@ impl ChanMode for Limit {
}
}
// --- +H chanhistory: replay recent messages to joiners ----------------------
struct ChanHistory;
static CHANHISTORY: ChanHistory = ChanHistory;
impl ChanMode for ChanHistory {
fn letter(&self) -> char {
'H'
}
fn wants_param(&self, adding: bool) -> bool {
adding // +H <lines>[:<secs>]; -H takes nothing
}
fn apply(
&self,
s: &mut Server,
_chan: &str,
key: &str,
_uid: Uid,
adding: bool,
param: Option<&str>,
) -> Applied {
if adding {
let Some(p) = param else {
return Applied::No;
};
let (lines_s, secs_s) = p.split_once(':').unwrap_or((p, "0"));
let Some(lines) = lines_s.parse::<u32>().ok().filter(|&n| n > 0) else {
return Applied::No;
};
let lines = lines.min(crate::server::HISTORY_CAP as u32);
let secs = secs_s.parse::<u64>().unwrap_or(0);
if let Some(c) = s.channels.get_mut(key) {
c.modes.history = Some((lines, secs));
}
Applied::Yes(Some(format!("{lines}:{secs}")))
} else {
if let Some(c) = s.channels.get_mut(key) {
c.modes.history = None;
}
Applied::Yes(None)
}
}
}
// --- list modes (+b bans, +e ban exceptions, +I invite exceptions) ----------
#[derive(Clone, Copy)]

View file

@ -416,7 +416,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 CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLH,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
self.network
),
);