chathistory: in-memory per-channel history + CHATHISTORY latest/before/after in a batch (draft/chathistory)
This commit is contained in:
parent
1b7dbc4c0e
commit
6a634ef678
3 changed files with 164 additions and 4 deletions
|
|
@ -3,7 +3,7 @@
|
|||
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::server::{iso_time, parse_iso, HistMsg, Server, HISTORY_CAP};
|
||||
use crate::Uid;
|
||||
|
||||
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
||||
|
|
@ -119,7 +119,103 @@ fn apply_censor(body: &str, censor: &[(String, String)]) -> Option<String> {
|
|||
}
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)]
|
||||
vec![
|
||||
Box::new(PrivMsg),
|
||||
Box::new(Notice),
|
||||
Box::new(TagMsg),
|
||||
Box::new(ChatHistory),
|
||||
]
|
||||
}
|
||||
|
||||
/// CHATHISTORY — replay recent channel messages (draft/chathistory), leveraging
|
||||
/// BATCH. `CHATHISTORY <LATEST|BEFORE|AFTER> <#chan> <selector> <limit>`, where
|
||||
/// `<selector>` is `*`, `timestamp=<iso>` or `msgid=<id>`. Only members get a
|
||||
/// channel's history; the reply is a `chathistory` batch of the original
|
||||
/// PRIVMSG/NOTICE lines, each carrying its stored server-time + msgid.
|
||||
struct ChatHistory;
|
||||
impl Command for ChatHistory {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHATHISTORY"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
4
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let sub = params[0].to_ascii_uppercase();
|
||||
let target = params[1].clone();
|
||||
let key = target.to_ascii_lowercase();
|
||||
let sel = params[2].as_str();
|
||||
let limit = params[3]
|
||||
.parse::<usize>()
|
||||
.unwrap_or(50)
|
||||
.clamp(1, HISTORY_CAP);
|
||||
|
||||
let bref = s.next_msgid().replace('-', "");
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if s.is_member(uid, &key) {
|
||||
if let Some(buf) = s.history.get(&key) {
|
||||
// Resolve the selector to a reference position. `msgid=` matches an
|
||||
// exact buffer index (so same-second messages aren't lost);
|
||||
// `timestamp=` and `*` fall back to a ts bound.
|
||||
let ref_idx = sel
|
||||
.strip_prefix("msgid=")
|
||||
.and_then(|id| buf.iter().position(|m| m.msgid == id));
|
||||
let ref_ts = sel.strip_prefix("timestamp=").and_then(parse_iso);
|
||||
let picked: Vec<&HistMsg> = match sub.as_str() {
|
||||
"BEFORE" => {
|
||||
let end = ref_idx.unwrap_or_else(|| {
|
||||
let b = ref_ts.unwrap_or(u64::MAX);
|
||||
buf.iter().position(|m| m.ts >= b).unwrap_or(buf.len())
|
||||
});
|
||||
let start = end.saturating_sub(limit);
|
||||
buf.iter().take(end).skip(start).collect()
|
||||
}
|
||||
"AFTER" => {
|
||||
let begin = match ref_idx {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
let b = ref_ts.unwrap_or(0);
|
||||
buf.iter().position(|m| m.ts > b).unwrap_or(buf.len())
|
||||
}
|
||||
};
|
||||
buf.iter().skip(begin).take(limit).collect()
|
||||
}
|
||||
_ => {
|
||||
// LATEST: newest `limit`, optionally bounded below by the selector
|
||||
let begin = match (ref_idx, ref_ts) {
|
||||
(Some(i), _) => i + 1,
|
||||
(None, Some(b)) => {
|
||||
buf.iter().position(|m| m.ts > b).unwrap_or(buf.len())
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let n = buf.len() - begin;
|
||||
let start = begin + n.saturating_sub(limit);
|
||||
buf.iter().skip(start).collect()
|
||||
}
|
||||
};
|
||||
for m in picked {
|
||||
lines.push(format!(
|
||||
"@time={};msgid={};batch={bref} :{} {} {target} :{}",
|
||||
iso_time(m.ts),
|
||||
m.msgid,
|
||||
m.prefix,
|
||||
m.verb,
|
||||
m.text
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.send(
|
||||
uid,
|
||||
format!(":{} BATCH +{bref} chathistory {target}", s.name),
|
||||
);
|
||||
for l in lines {
|
||||
s.send(uid, l);
|
||||
}
|
||||
s.send(uid, format!(":{} BATCH -{bref}", s.name));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies.
|
||||
|
|
@ -308,6 +404,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu
|
|||
let line = format!(":{prefix} {cmd} {target} :{body}");
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
||||
s.store_history(&key, &prefix, cmd, &body, &msgid); // for CHATHISTORY
|
||||
let members: Vec<Uid> = s
|
||||
.channels
|
||||
.get(&key)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub const TICK_SECS: u64 = 15;
|
|||
pub const PING_AFTER: u64 = 90;
|
||||
pub const PING_TIMEOUT: u64 = 60;
|
||||
pub const REG_TIMEOUT: u64 = 60;
|
||||
/// Recent messages CHATHISTORY keeps per channel.
|
||||
pub const HISTORY_CAP: usize = 256;
|
||||
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
|
|
@ -59,6 +61,39 @@ pub fn iso_time(secs: u64) -> String {
|
|||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
|
||||
}
|
||||
|
||||
/// Parse an IRCv3 `server-time` value (`2026-08-08T19:52:42.000Z`) back to unix
|
||||
/// seconds — the inverse of [`iso_time`], for CHATHISTORY `timestamp=` selectors.
|
||||
pub fn parse_iso(s: &str) -> Option<u64> {
|
||||
let (date, time) = s.split_once('T')?;
|
||||
let mut d = date.split('-');
|
||||
let y: i64 = d.next()?.parse().ok()?;
|
||||
let mo: i64 = d.next()?.parse().ok()?;
|
||||
let da: i64 = d.next()?.parse().ok()?;
|
||||
let time = time.trim_end_matches('Z').split('.').next()?;
|
||||
let mut t = time.split(':');
|
||||
let h: i64 = t.next()?.parse().ok()?;
|
||||
let mi: i64 = t.next()?.parse().ok()?;
|
||||
let se: i64 = t.next().unwrap_or("0").parse().ok()?;
|
||||
// civil date -> days since 1970-01-01 (inverse Howard Hinnant)
|
||||
let yy = y - i64::from(mo <= 2);
|
||||
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
|
||||
let yoe = yy - era * 400;
|
||||
let mp = if mo > 2 { mo - 3 } else { mo + 9 };
|
||||
let doy = (153 * mp + 2) / 5 + da - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
Some((days * 86400 + h * 3600 + mi * 60 + se).max(0) as u64)
|
||||
}
|
||||
|
||||
/// One stored channel message, replayed by CHATHISTORY.
|
||||
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 text: String,
|
||||
}
|
||||
|
||||
/// A recently-departed identity, kept for WHOWAS.
|
||||
pub struct WhowasEntry {
|
||||
pub nick: String,
|
||||
|
|
@ -111,7 +146,8 @@ pub struct Server {
|
|||
// the command's `label` (single tag, BATCH, or ACK). RefCell because the
|
||||
// output primitives are `&self`.
|
||||
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||
}
|
||||
|
||||
impl Server {
|
||||
|
|
@ -153,6 +189,7 @@ impl Server {
|
|||
sasl_server: cfg.sasl_server,
|
||||
webirc: cfg.webirc,
|
||||
label_capture: RefCell::new(None),
|
||||
history: HashMap::new(),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
|
@ -182,6 +219,28 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// Record a channel message for CHATHISTORY replay (capped ring per channel).
|
||||
pub fn store_history(
|
||||
&mut self,
|
||||
key: &str,
|
||||
prefix: &str,
|
||||
verb: &'static str,
|
||||
text: &str,
|
||||
msgid: &str,
|
||||
) {
|
||||
let buf = self.history.entry(key.to_string()).or_default();
|
||||
buf.push_back(HistMsg {
|
||||
ts: now(),
|
||||
msgid: msgid.to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
verb,
|
||||
text: text.to_string(),
|
||||
});
|
||||
while buf.len() > HISTORY_CAP {
|
||||
buf.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
// --- connection lifecycle ------------------------------------------------
|
||||
|
||||
pub fn add_conn(
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
|||
"standard-replies",
|
||||
"labeled-response",
|
||||
"batch",
|
||||
"draft/chathistory",
|
||||
"cap-notify",
|
||||
];
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ pub struct Caps {
|
|||
pub standard_replies: bool, // understands FAIL/WARN/NOTE structured replies
|
||||
pub labeled_response: bool, // tag responses to a labeled command with its label
|
||||
pub batch: bool, // understands BATCH framing
|
||||
pub chathistory: bool, // draft/chathistory — can request message history
|
||||
pub cap_notify: bool,
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +171,7 @@ impl Caps {
|
|||
"standard-replies" => self.standard_replies,
|
||||
"labeled-response" => self.labeled_response,
|
||||
"batch" => self.batch,
|
||||
"draft/chathistory" => self.chathistory,
|
||||
"cap-notify" => self.cap_notify,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -194,6 +197,7 @@ impl Caps {
|
|||
"standard-replies" => &mut self.standard_replies,
|
||||
"labeled-response" => &mut self.labeled_response,
|
||||
"batch" => &mut self.batch,
|
||||
"draft/chathistory" => &mut self.chathistory,
|
||||
"cap-notify" => &mut self.cap_notify,
|
||||
_ => return false,
|
||||
};
|
||||
|
|
@ -392,7 +396,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 CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
self.network
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue