move chathistory + redact + the history store into their own module file
This commit is contained in:
parent
ab7188bb70
commit
431ef2f5b3
6 changed files with 386 additions and 358 deletions
|
|
@ -5,8 +5,9 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::module::Hook;
|
use crate::module::Hook;
|
||||||
|
use crate::modules::chathistory::{HistMsg, History};
|
||||||
use crate::numeric::*;
|
use crate::numeric::*;
|
||||||
use crate::server::{iso_time, now, HistMsg, Server};
|
use crate::server::{iso_time, now, Server};
|
||||||
use crate::Uid;
|
use crate::Uid;
|
||||||
|
|
||||||
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
|
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
|
||||||
|
|
@ -551,7 +552,7 @@ impl Server {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let out: Vec<String> = match self.history.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).collect();
|
||||||
let start = recent.len().saturating_sub(lines as usize);
|
let start = recent.len().saturating_sub(lines as usize);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
||||||
use crate::command::{CmdResult, Command};
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::modules::chathistory::{dm_key, record};
|
||||||
use crate::numeric::*;
|
use crate::numeric::*;
|
||||||
use crate::server::{iso_time, parse_iso, HistMsg, Server, HISTORY_CAP};
|
use crate::server::Server;
|
||||||
use crate::Uid;
|
use crate::Uid;
|
||||||
|
|
||||||
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
||||||
|
|
@ -123,314 +124,9 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
Box::new(PrivMsg),
|
Box::new(PrivMsg),
|
||||||
Box::new(Notice),
|
Box::new(Notice),
|
||||||
Box::new(TagMsg),
|
Box::new(TagMsg),
|
||||||
Box::new(ChatHistory),
|
|
||||||
Box::new(Redact),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// REDACT — delete a previously-sent channel message (draft/message-redaction).
|
|
||||||
/// `REDACT <#chan> <msgid> [:reason]`. Allowed for the message's author, a channel
|
|
||||||
/// half-op-or-above, or an oper. Relayed to channel members who enabled the cap,
|
|
||||||
/// and the message is dropped from CHATHISTORY.
|
|
||||||
struct Redact;
|
|
||||||
impl Command for Redact {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"REDACT"
|
|
||||||
}
|
|
||||||
fn min_params(&self) -> usize {
|
|
||||||
2
|
|
||||||
}
|
|
||||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
|
||||||
let target = ¶ms[0];
|
|
||||||
let msgid = ¶ms[1];
|
|
||||||
let reason = params.get(2).cloned().unwrap_or_default();
|
|
||||||
if !target.starts_with('#') {
|
|
||||||
s.fail(
|
|
||||||
uid,
|
|
||||||
"REDACT",
|
|
||||||
"INVALID_TARGET",
|
|
||||||
"REDACT only supports channels",
|
|
||||||
);
|
|
||||||
return CmdResult::Fail;
|
|
||||||
}
|
|
||||||
let key = target.to_ascii_lowercase();
|
|
||||||
let Some(author) = s.history.get(&key).and_then(|buf| {
|
|
||||||
buf.iter().find(|m| m.msgid == *msgid).map(|m| {
|
|
||||||
m.prefix
|
|
||||||
.split('!')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
})
|
|
||||||
}) else {
|
|
||||||
s.fail(
|
|
||||||
uid,
|
|
||||||
"REDACT",
|
|
||||||
"UNKNOWN_MSGID",
|
|
||||||
&format!("No such message id {msgid}"),
|
|
||||||
);
|
|
||||||
return CmdResult::Fail;
|
|
||||||
};
|
|
||||||
let my_nick = s
|
|
||||||
.users
|
|
||||||
.get(&uid)
|
|
||||||
.map(|u| u.nick.to_ascii_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if my_nick != author && s.rank(uid, &key) < RANK_HALFOP && !s.is_oper(uid) {
|
|
||||||
s.fail(
|
|
||||||
uid,
|
|
||||||
"REDACT",
|
|
||||||
"REDACT_FORBIDDEN",
|
|
||||||
"You may only redact your own messages",
|
|
||||||
);
|
|
||||||
return CmdResult::Fail;
|
|
||||||
}
|
|
||||||
if let Some(buf) = s.history.get_mut(&key) {
|
|
||||||
buf.retain(|m| m.msgid != *msgid);
|
|
||||||
}
|
|
||||||
let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
|
||||||
let line = if reason.is_empty() {
|
|
||||||
format!(":{prefix} REDACT {target} {msgid}")
|
|
||||||
} else {
|
|
||||||
format!(":{prefix} REDACT {target} {msgid} :{reason}")
|
|
||||||
};
|
|
||||||
let members: Vec<Uid> = s
|
|
||||||
.channels
|
|
||||||
.get(&key)
|
|
||||||
.map(|c| c.members.keys().copied().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
for m in members {
|
|
||||||
if s.users
|
|
||||||
.get(&m)
|
|
||||||
.map(|u| u.caps.message_redaction)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
s.send(m, line.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CmdResult::Ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the
|
|
||||||
/// `\0` prefix keeps it from ever colliding with a `#channel` key).
|
|
||||||
fn dm_key(a: &str, b: &str) -> String {
|
|
||||||
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
|
|
||||||
if a <= b {
|
|
||||||
format!("\0{a}\0{b}")
|
|
||||||
} else {
|
|
||||||
format!("\0{b}\0{a}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CHATHISTORY — replay recent messages (draft/chathistory), leveraging BATCH.
|
|
||||||
/// `CHATHISTORY <LATEST|BEFORE|AFTER|AROUND|BETWEEN> <#chan|nick> <selector..>
|
|
||||||
/// <limit>`; a `<selector>` is `*`, `timestamp=<iso>` or `msgid=<id>`. Channel
|
|
||||||
/// history is members-only; a nick target replays that DM conversation. The reply
|
|
||||||
/// is a `chathistory` batch of the original lines with their 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();
|
|
||||||
// CHATHISTORY TARGETS <t1> <t2> <limit> — list conversations with activity
|
|
||||||
// in the window, newest-in-window timestamp each. No target param.
|
|
||||||
if sub == "TARGETS" {
|
|
||||||
let bound = |i: usize, dflt: u64| {
|
|
||||||
params
|
|
||||||
.get(i)
|
|
||||||
.and_then(|s| s.strip_prefix("timestamp="))
|
|
||||||
.and_then(parse_iso)
|
|
||||||
.unwrap_or(dflt)
|
|
||||||
};
|
|
||||||
let (a, b) = (bound(1, 0), bound(2, u64::MAX));
|
|
||||||
let (lo, hi) = (a.min(b), a.max(b));
|
|
||||||
let limit = params
|
|
||||||
.get(3)
|
|
||||||
.and_then(|l| l.parse::<usize>().ok())
|
|
||||||
.unwrap_or(50)
|
|
||||||
.clamp(1, HISTORY_CAP);
|
|
||||||
let me = s
|
|
||||||
.users
|
|
||||||
.get(&uid)
|
|
||||||
.map(|u| u.nick.to_ascii_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut targets: Vec<(String, u64)> = Vec::new();
|
|
||||||
for (key, buf) in &s.history {
|
|
||||||
let Some(ts) = buf
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|m| m.ts >= lo && m.ts <= hi)
|
|
||||||
.map(|m| m.ts)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if key.starts_with('#') {
|
|
||||||
if s.is_member(uid, key) {
|
|
||||||
let name = buf
|
|
||||||
.back()
|
|
||||||
.map(|m| m.target.clone())
|
|
||||||
.unwrap_or_else(|| key.clone());
|
|
||||||
targets.push((name, ts));
|
|
||||||
}
|
|
||||||
} else if let Some(rest) = key.strip_prefix('\0') {
|
|
||||||
let p: Vec<&str> = rest.split('\0').collect();
|
|
||||||
if p.len() == 2 && (p[0] == me || p[1] == me) {
|
|
||||||
let other = if p[0] == me { p[1] } else { p[0] };
|
|
||||||
targets.push((other.to_string(), ts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
targets.sort_by_key(|(_, ts)| *ts);
|
|
||||||
let start = targets.len().saturating_sub(limit);
|
|
||||||
let bref = s.next_msgid().replace('-', "");
|
|
||||||
s.send(
|
|
||||||
uid,
|
|
||||||
format!(":{} BATCH +{bref} draft/chathistory-targets", s.name),
|
|
||||||
);
|
|
||||||
for (t, ts) in &targets[start..] {
|
|
||||||
s.send(
|
|
||||||
uid,
|
|
||||||
format!(
|
|
||||||
"@batch={bref} :{} CHATHISTORY TARGETS {t} {}",
|
|
||||||
s.name,
|
|
||||||
iso_time(*ts)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
s.send(uid, format!(":{} BATCH -{bref}", s.name));
|
|
||||||
return CmdResult::Ok;
|
|
||||||
}
|
|
||||||
let target = params[1].clone();
|
|
||||||
// channel target → channel key (members only); a nick → the DM pair key
|
|
||||||
// (the requester is inherently part of it, so no membership check)
|
|
||||||
let is_channel = target.starts_with('#');
|
|
||||||
let key = if is_channel {
|
|
||||||
target.to_ascii_lowercase()
|
|
||||||
} else {
|
|
||||||
let me = s
|
|
||||||
.users
|
|
||||||
.get(&uid)
|
|
||||||
.map(|u| u.nick.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
dm_key(&me, &target)
|
|
||||||
};
|
|
||||||
// BETWEEN takes two selectors then the limit; the rest take one + the limit
|
|
||||||
let (sel, sel2, limit_s) = if sub == "BETWEEN" {
|
|
||||||
(
|
|
||||||
params[2].as_str(),
|
|
||||||
params.get(3).map(|s| s.as_str()).unwrap_or("*"),
|
|
||||||
params.get(4),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(params[2].as_str(), "", params.get(3))
|
|
||||||
};
|
|
||||||
let limit = limit_s
|
|
||||||
.and_then(|l| l.parse::<usize>().ok())
|
|
||||||
.unwrap_or(50)
|
|
||||||
.clamp(1, HISTORY_CAP);
|
|
||||||
|
|
||||||
let bref = s.next_msgid().replace('-', "");
|
|
||||||
let mut lines: Vec<String> = Vec::new();
|
|
||||||
if !is_channel || 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);
|
|
||||||
// resolve any selector to a buffer index (for AROUND / BETWEEN)
|
|
||||||
let idx_of = |sl: &str| -> Option<usize> {
|
|
||||||
if let Some(id) = sl.strip_prefix("msgid=") {
|
|
||||||
buf.iter().position(|m| m.msgid == id)
|
|
||||||
} else if let Some(iso) = sl.strip_prefix("timestamp=") {
|
|
||||||
parse_iso(iso).and_then(|b| buf.iter().position(|m| m.ts >= b))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
"AROUND" => {
|
|
||||||
// messages centred on the selector: half before, half after
|
|
||||||
let i = idx_of(sel).unwrap_or(buf.len() / 2);
|
|
||||||
let start = i.saturating_sub(limit / 2);
|
|
||||||
buf.iter().skip(start).take(limit).collect()
|
|
||||||
}
|
|
||||||
"BETWEEN" => {
|
|
||||||
// messages strictly between the two selector points
|
|
||||||
let a = idx_of(sel).unwrap_or(0);
|
|
||||||
let b = idx_of(sel2).unwrap_or(buf.len());
|
|
||||||
let (lo, hi) = (a.min(b), a.max(b));
|
|
||||||
buf.iter()
|
|
||||||
.skip(lo + 1)
|
|
||||||
.take(hi.saturating_sub(lo + 1))
|
|
||||||
.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} :{} {} {} :{}",
|
|
||||||
iso_time(m.ts),
|
|
||||||
m.msgid,
|
|
||||||
m.prefix,
|
|
||||||
m.verb,
|
|
||||||
m.target,
|
|
||||||
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.
|
/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies.
|
||||||
/// `pub(crate)` so the multiline module can replay an assembled batch through it.
|
/// `pub(crate)` so the multiline module can replay an assembled batch through it.
|
||||||
pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult {
|
pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult {
|
||||||
|
|
@ -618,7 +314,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
let line = format!(":{prefix} {cmd} {target} :{body}");
|
let line = format!(":{prefix} {cmd} {target} :{body}");
|
||||||
let ctags = s.line_ctags.clone();
|
let ctags = s.line_ctags.clone();
|
||||||
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
||||||
s.store_history(&key, &prefix, cmd, target, &body, &msgid); // for CHATHISTORY
|
record(s, &key, &prefix, cmd, target, &body, &msgid); // for CHATHISTORY
|
||||||
let members: Vec<Uid> = s
|
let members: Vec<Uid> = s
|
||||||
.channels
|
.channels
|
||||||
.get(&key)
|
.get(&key)
|
||||||
|
|
@ -741,14 +437,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
s.send_tagged(tuid, uid, &ctags, &msgid, &pm);
|
s.send_tagged(tuid, uid, &ctags, &msgid, &pm);
|
||||||
// store for CHATHISTORY under the canonical pair key (both parties share it)
|
// store for CHATHISTORY under the canonical pair key (both parties share it)
|
||||||
let sender_nick = prefix.split('!').next().unwrap_or_default();
|
let sender_nick = prefix.split('!').next().unwrap_or_default();
|
||||||
s.store_history(
|
record(s, &dm_key(sender_nick, target), &prefix, cmd, target, text, &msgid);
|
||||||
&dm_key(sender_nick, target),
|
|
||||||
&prefix,
|
|
||||||
cmd,
|
|
||||||
target,
|
|
||||||
text,
|
|
||||||
&msgid,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if s.users
|
if s.users
|
||||||
.get(&uid)
|
.get(&uid)
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,7 @@ impl ChanMode for ChanHistory {
|
||||||
let Some(lines) = lines_s.parse::<u32>().ok().filter(|&n| n > 0) else {
|
let Some(lines) = lines_s.parse::<u32>().ok().filter(|&n| n > 0) else {
|
||||||
return Applied::No;
|
return Applied::No;
|
||||||
};
|
};
|
||||||
let lines = lines.min(crate::server::HISTORY_CAP as u32);
|
let lines = lines.min(crate::modules::chathistory::HISTORY_CAP as u32);
|
||||||
let secs = secs_s.parse::<u64>().unwrap_or(0);
|
let secs = secs_s.parse::<u64>().unwrap_or(0);
|
||||||
if let Some(c) = s.channels.get_mut(key) {
|
if let Some(c) = s.channels.get_mut(key) {
|
||||||
c.modes.history = Some((lines, secs));
|
c.modes.history = Some((lines, secs));
|
||||||
|
|
|
||||||
374
src/modules/chathistory.rs
Normal file
374
src/modules/chathistory.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
//! chathistory — InspIRCd's `m_chathistory` family (draft/chathistory +
|
||||||
|
//! draft/message-redaction). Recent PRIVMSG/NOTICE traffic is kept in a capped
|
||||||
|
//! per-conversation ring (channels and DM pairs) so clients can replay it on
|
||||||
|
//! demand or on join (the channel `+H` backlog lives in `channels::replay_chanhistory`).
|
||||||
|
//! Self-contained: the ring lives in `Server.ext`; the message path records into it
|
||||||
|
//! via [`record`], and the CHATHISTORY and REDACT commands read/edit it here.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
|
use crate::channels::RANK_HALFOP;
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::server::{iso_time, now, parse_iso, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// Recent messages CHATHISTORY keeps per conversation.
|
||||||
|
pub const HISTORY_CAP: usize = 256;
|
||||||
|
|
||||||
|
/// One stored message, replayed by CHATHISTORY / the `+H` backlog.
|
||||||
|
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 target: String, // original target (channel, or the DM recipient)
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conversation key (`#chan` or a DM-pair key) -> capped ring. Stored in `Server.ext`.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct History(pub HashMap<String, VecDeque<HistMsg>>);
|
||||||
|
|
||||||
|
/// Record a message for CHATHISTORY replay (capped ring per conversation). Called
|
||||||
|
/// from the core message path once per delivered PRIVMSG/NOTICE.
|
||||||
|
pub fn record(
|
||||||
|
s: &mut Server,
|
||||||
|
key: &str,
|
||||||
|
prefix: &str,
|
||||||
|
verb: &'static str,
|
||||||
|
target: &str,
|
||||||
|
text: &str,
|
||||||
|
msgid: &str,
|
||||||
|
) {
|
||||||
|
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: msgid.to_string(),
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
verb,
|
||||||
|
target: target.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
while buf.len() > HISTORY_CAP {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the
|
||||||
|
/// `\0` prefix keeps it from ever colliding with a `#channel` key).
|
||||||
|
pub fn dm_key(a: &str, b: &str) -> String {
|
||||||
|
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
|
||||||
|
if a <= b {
|
||||||
|
format!("\0{a}\0{b}")
|
||||||
|
} else {
|
||||||
|
format!("\0{b}\0{a}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![Box::new(ChatHistory), Box::new(Redact)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CHATHISTORY — replay recent messages (draft/chathistory), leveraging BATCH.
|
||||||
|
/// `CHATHISTORY <LATEST|BEFORE|AFTER|AROUND|BETWEEN> <#chan|nick> <selector..>
|
||||||
|
/// <limit>`; a `<selector>` is `*`, `timestamp=<iso>` or `msgid=<id>`. Channel
|
||||||
|
/// history is members-only; a nick target replays that DM conversation. The reply
|
||||||
|
/// is a `chathistory` batch of the original lines with their 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();
|
||||||
|
// CHATHISTORY TARGETS <t1> <t2> <limit> — list conversations with activity
|
||||||
|
// in the window, newest-in-window timestamp each. No target param.
|
||||||
|
if sub == "TARGETS" {
|
||||||
|
let bound = |i: usize, dflt: u64| {
|
||||||
|
params
|
||||||
|
.get(i)
|
||||||
|
.and_then(|s| s.strip_prefix("timestamp="))
|
||||||
|
.and_then(parse_iso)
|
||||||
|
.unwrap_or(dflt)
|
||||||
|
};
|
||||||
|
let (a, b) = (bound(1, 0), bound(2, u64::MAX));
|
||||||
|
let (lo, hi) = (a.min(b), a.max(b));
|
||||||
|
let limit = params
|
||||||
|
.get(3)
|
||||||
|
.and_then(|l| l.parse::<usize>().ok())
|
||||||
|
.unwrap_or(50)
|
||||||
|
.clamp(1, HISTORY_CAP);
|
||||||
|
let me = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut targets: Vec<(String, u64)> = Vec::new();
|
||||||
|
if let Some(hist) = s.ext.get::<History>() {
|
||||||
|
for (key, buf) in &hist.0 {
|
||||||
|
let Some(ts) = buf
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.ts >= lo && m.ts <= hi)
|
||||||
|
.map(|m| m.ts)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.starts_with('#') {
|
||||||
|
if s.is_member(uid, key) {
|
||||||
|
let name = buf
|
||||||
|
.back()
|
||||||
|
.map(|m| m.target.clone())
|
||||||
|
.unwrap_or_else(|| key.clone());
|
||||||
|
targets.push((name, ts));
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = key.strip_prefix('\0') {
|
||||||
|
let p: Vec<&str> = rest.split('\0').collect();
|
||||||
|
if p.len() == 2 && (p[0] == me || p[1] == me) {
|
||||||
|
let other = if p[0] == me { p[1] } else { p[0] };
|
||||||
|
targets.push((other.to_string(), ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets.sort_by_key(|(_, ts)| *ts);
|
||||||
|
let start = targets.len().saturating_sub(limit);
|
||||||
|
let bref = s.next_msgid().replace('-', "");
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} BATCH +{bref} draft/chathistory-targets", s.name),
|
||||||
|
);
|
||||||
|
for (t, ts) in &targets[start..] {
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
"@batch={bref} :{} CHATHISTORY TARGETS {t} {}",
|
||||||
|
s.name,
|
||||||
|
iso_time(*ts)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.send(uid, format!(":{} BATCH -{bref}", s.name));
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
let target = params[1].clone();
|
||||||
|
// channel target → channel key (members only); a nick → the DM pair key
|
||||||
|
// (the requester is inherently part of it, so no membership check)
|
||||||
|
let is_channel = target.starts_with('#');
|
||||||
|
let key = if is_channel {
|
||||||
|
target.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
let me = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
dm_key(&me, &target)
|
||||||
|
};
|
||||||
|
// BETWEEN takes two selectors then the limit; the rest take one + the limit
|
||||||
|
let (sel, sel2, limit_s) = if sub == "BETWEEN" {
|
||||||
|
(
|
||||||
|
params[2].as_str(),
|
||||||
|
params.get(3).map(|s| s.as_str()).unwrap_or("*"),
|
||||||
|
params.get(4),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(params[2].as_str(), "", params.get(3))
|
||||||
|
};
|
||||||
|
let limit = limit_s
|
||||||
|
.and_then(|l| l.parse::<usize>().ok())
|
||||||
|
.unwrap_or(50)
|
||||||
|
.clamp(1, HISTORY_CAP);
|
||||||
|
|
||||||
|
let bref = s.next_msgid().replace('-', "");
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
if !is_channel || s.is_member(uid, &key) {
|
||||||
|
if let Some(buf) = s.ext.get::<History>().and_then(|h| h.0.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);
|
||||||
|
// resolve any selector to a buffer index (for AROUND / BETWEEN)
|
||||||
|
let idx_of = |sl: &str| -> Option<usize> {
|
||||||
|
if let Some(id) = sl.strip_prefix("msgid=") {
|
||||||
|
buf.iter().position(|m| m.msgid == id)
|
||||||
|
} else if let Some(iso) = sl.strip_prefix("timestamp=") {
|
||||||
|
parse_iso(iso).and_then(|b| buf.iter().position(|m| m.ts >= b))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
"AROUND" => {
|
||||||
|
// messages centred on the selector: half before, half after
|
||||||
|
let i = idx_of(sel).unwrap_or(buf.len() / 2);
|
||||||
|
let start = i.saturating_sub(limit / 2);
|
||||||
|
buf.iter().skip(start).take(limit).collect()
|
||||||
|
}
|
||||||
|
"BETWEEN" => {
|
||||||
|
// messages strictly between the two selector points
|
||||||
|
let a = idx_of(sel).unwrap_or(0);
|
||||||
|
let b = idx_of(sel2).unwrap_or(buf.len());
|
||||||
|
let (lo, hi) = (a.min(b), a.max(b));
|
||||||
|
buf.iter()
|
||||||
|
.skip(lo + 1)
|
||||||
|
.take(hi.saturating_sub(lo + 1))
|
||||||
|
.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} :{} {} {} :{}",
|
||||||
|
iso_time(m.ts),
|
||||||
|
m.msgid,
|
||||||
|
m.prefix,
|
||||||
|
m.verb,
|
||||||
|
m.target,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// REDACT — delete a previously-sent channel message (draft/message-redaction).
|
||||||
|
/// `REDACT <#chan> <msgid> [:reason]`. Allowed for the message's author, a channel
|
||||||
|
/// half-op-or-above, or an oper. Relayed to channel members who enabled the cap,
|
||||||
|
/// and the message is dropped from CHATHISTORY.
|
||||||
|
struct Redact;
|
||||||
|
impl Command for Redact {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"REDACT"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let target = ¶ms[0];
|
||||||
|
let msgid = ¶ms[1];
|
||||||
|
let reason = params.get(2).cloned().unwrap_or_default();
|
||||||
|
if !target.starts_with('#') {
|
||||||
|
s.fail(
|
||||||
|
uid,
|
||||||
|
"REDACT",
|
||||||
|
"INVALID_TARGET",
|
||||||
|
"REDACT only supports channels",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
let Some(author) = s
|
||||||
|
.ext
|
||||||
|
.get::<History>()
|
||||||
|
.and_then(|h| h.0.get(&key))
|
||||||
|
.and_then(|buf| {
|
||||||
|
buf.iter().find(|m| m.msgid == *msgid).map(|m| {
|
||||||
|
m.prefix
|
||||||
|
.split('!')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
s.fail(
|
||||||
|
uid,
|
||||||
|
"REDACT",
|
||||||
|
"UNKNOWN_MSGID",
|
||||||
|
&format!("No such message id {msgid}"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let my_nick = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if my_nick != author && s.rank(uid, &key) < RANK_HALFOP && !s.is_oper(uid) {
|
||||||
|
s.fail(
|
||||||
|
uid,
|
||||||
|
"REDACT",
|
||||||
|
"REDACT_FORBIDDEN",
|
||||||
|
"You may only redact your own messages",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if let Some(buf) = s.ext.get_mut::<History>().and_then(|h| h.0.get_mut(&key)) {
|
||||||
|
buf.retain(|m| m.msgid != *msgid);
|
||||||
|
}
|
||||||
|
let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||||
|
let line = if reason.is_empty() {
|
||||||
|
format!(":{prefix} REDACT {target} {msgid}")
|
||||||
|
} else {
|
||||||
|
format!(":{prefix} REDACT {target} {msgid} :{reason}")
|
||||||
|
};
|
||||||
|
let members: Vec<Uid> = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.members.keys().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for m in members {
|
||||||
|
if s.users
|
||||||
|
.get(&m)
|
||||||
|
.map(|u| u.caps.message_redaction)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send(m, line.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//! than the hook bus, but lives here as its own self-contained unit.
|
//! than the hook bus, but lives here as its own self-contained unit.
|
||||||
|
|
||||||
pub mod antimixedutf8;
|
pub mod antimixedutf8;
|
||||||
|
pub mod chathistory;
|
||||||
pub mod cloak;
|
pub mod cloak;
|
||||||
pub mod dnsbl;
|
pub mod dnsbl;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
|
|
@ -38,5 +39,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> {
|
||||||
.chain(metadata::commands())
|
.chain(metadata::commands())
|
||||||
.chain(markread::commands())
|
.chain(markread::commands())
|
||||||
.chain(multiline::commands())
|
.chain(multiline::commands())
|
||||||
|
.chain(chathistory::commands())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,6 @@ pub const TICK_SECS: u64 = 15;
|
||||||
pub const PING_AFTER: u64 = 90;
|
pub const PING_AFTER: u64 = 90;
|
||||||
pub const PING_TIMEOUT: u64 = 60;
|
pub const PING_TIMEOUT: u64 = 60;
|
||||||
pub const REG_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 {
|
pub fn now() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
|
|
@ -87,16 +85,6 @@ pub fn parse_iso(s: &str) -> Option<u64> {
|
||||||
Some((days * 86400 + h * 3600 + mi * 60 + se).max(0) as u64)
|
Some((days * 86400 + h * 3600 + mi * 60 + se).max(0) as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One stored 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 target: String, // original target (channel, or the DM recipient)
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A recently-departed identity, kept for WHOWAS.
|
/// A recently-departed identity, kept for WHOWAS.
|
||||||
pub struct WhowasEntry {
|
pub struct WhowasEntry {
|
||||||
pub nick: String,
|
pub nick: String,
|
||||||
|
|
@ -149,7 +137,6 @@ pub struct Server {
|
||||||
// the command's `label` (single tag, BATCH, or ACK). RefCell because the
|
// the command's `label` (single tag, BATCH, or ACK). RefCell because the
|
||||||
// output primitives are `&self`.
|
// output primitives are `&self`.
|
||||||
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
||||||
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
|
||||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||||
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
||||||
/// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem`
|
/// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem`
|
||||||
|
|
@ -197,7 +184,6 @@ impl Server {
|
||||||
sasl_server: cfg.sasl_server,
|
sasl_server: cfg.sasl_server,
|
||||||
webirc: cfg.webirc,
|
webirc: cfg.webirc,
|
||||||
label_capture: RefCell::new(None),
|
label_capture: RefCell::new(None),
|
||||||
history: HashMap::new(),
|
|
||||||
event_tx,
|
event_tx,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
ext: Extensible::default(),
|
ext: Extensible::default(),
|
||||||
|
|
@ -229,30 +215,6 @@ 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,
|
|
||||||
target: &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,
|
|
||||||
target: target.to_string(),
|
|
||||||
text: text.to_string(),
|
|
||||||
});
|
|
||||||
while buf.len() > HISTORY_CAP {
|
|
||||||
buf.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- connection lifecycle ------------------------------------------------
|
// --- connection lifecycle ------------------------------------------------
|
||||||
|
|
||||||
pub fn add_conn(
|
pub fn add_conn(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue