operserv: add NOTIFY oper watch list emitting matched-user events to the staff feed

This commit is contained in:
Jean Chevronnet 2026-07-18 16:16:06 +00:00
parent 86b1aae686
commit c415117ec5
No known key found for this signature in database
11 changed files with 433 additions and 10 deletions

View file

@ -0,0 +1,170 @@
use echo_api::{human_time, parse_duration, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// The event letters a watch can carry, in help order.
const ALL_FLAGS: &str = "cdjpntsS";
// NOTIFY ADD +expiry <flags|*> <mask> <reason> | DEL <mask|number> |
// LIST [pattern] | VIEW [pattern] | CLEAR
// An oper watch list: users matching a mask have their flagged events announced
// to the staff feed. Admin-only, like the rest of the ban family.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — NOTIFY needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => add(me, from, &args[2..], ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
Some("LIST") => list(me, from, false, args.get(2).copied(), ctx, db),
Some("VIEW") => list(me, from, true, args.get(2).copied(), ctx, db),
Some("CLEAR") => match db.notify_clear() {
Ok(0) => ctx.notice(me, from.uid, "The notify list is already empty."),
Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 notify watch(es).")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
_ => ctx.notice(me, from.uid, SYNTAX),
}
}
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
// Grammar: +expiry flags|* mask reason…
let Some(exp_tok) = rest.first() else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
let Some(dur) = exp_tok.strip_prefix('+') else {
ctx.notice(me, from.uid, "The expiry comes first, e.g. \x02+30d\x02 (or \x02+0\x02 for permanent).");
return;
};
let expires = match dur {
"0" | "" => None,
d => match parse_duration(d) {
Some(0) => None,
Some(secs) => Some(now() + secs),
None => {
ctx.notice(me, from.uid, format!("\x02{d}\x02 isn't a valid expiry — try 30d, 12h, 45m, or 0."));
return;
}
},
};
let Some(&flags_tok) = rest.get(1) else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
let flags = if flags_tok == "*" {
ALL_FLAGS.to_string()
} else if flags_tok.chars().all(|c| ALL_FLAGS.contains(c)) && !flags_tok.is_empty() {
flags_tok.to_string()
} else {
ctx.notice(me, from.uid, format!("Unknown flag(s). Valid: \x02{ALL_FLAGS}\x02 (or \x02*\x02 for all). {FLAG_LEGEND}"));
return;
};
let Some(&mask) = rest.get(2) else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
if rest.len() < 4 {
ctx.notice(me, from.uid, "Please give a reason.");
return;
}
if !valid_mask(mask) {
ctx.notice(me, from.uid, "Mask must be a \x02#channel\x02, a \x02nick!user@host\x02 pattern, or a bare nick glob — and no spaces.");
return;
}
if too_wide(mask) {
ctx.notice(me, from.uid, "That mask is too wide — it would match almost everyone.");
return;
}
let reason = rest[3..].join(" ");
let setter = from.account.unwrap_or(from.nick);
match db.notify_add(mask, &flags, &reason, setter, expires) {
Ok(true) => ctx.notice(me, from.uid, format!("Now watching \x02{mask}\x02 [{flags}].")),
Ok(false) => ctx.notice(me, from.uid, format!("Updated the watch on \x02{mask}\x02 [{flags}].")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(arg) = arg else {
ctx.notice(me, from.uid, "Syntax: NOTIFY DEL <mask|number>");
return;
};
// A number targets the nth entry in LIST order; anything else is a mask.
let mask = match arg.parse::<usize>() {
Ok(n) if n >= 1 => match db.notifies().get(n - 1) {
Some(v) => v.mask.clone(),
None => {
ctx.notice(me, from.uid, format!("There's no notify number \x02{n}\x02."));
return;
}
},
_ => arg.to_string(),
};
match db.notify_del(&mask) {
Ok(true) => ctx.notice(me, from.uid, format!("Stopped watching \x02{mask}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("No watch matches \x02{mask}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn list(me: &str, from: &Sender, verbose: bool, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let entries = db.notifies();
if entries.is_empty() {
ctx.notice(me, from.uid, "The notify list is empty.");
return;
}
let pat = pattern.map(|p| p.to_ascii_lowercase());
let mut shown = 0;
for (i, n) in entries.iter().enumerate() {
if let Some(p) = &pat {
if !n.mask.to_ascii_lowercase().contains(p.as_str()) {
continue;
}
}
let expiry = match n.expires {
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
None => String::new(),
};
if verbose {
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] by {} ({}){}{}", i + 1, n.mask, n.flags, n.setter, human_time(n.ts), expiry, n.reason));
} else {
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] — {}{}", i + 1, n.mask, n.flags, n.reason, expiry));
}
shown += 1;
}
if shown == 0 {
ctx.notice(me, from.uid, "No matching notify entries.");
} else {
ctx.notice(me, from.uid, format!("End of notify list ({shown} shown)."));
}
}
// A mask is usable if it's a channel, a hostmask/extban, or a bare nick glob —
// and carries no whitespace (the wire splits on spaces).
fn valid_mask(mask: &str) -> bool {
!mask.is_empty() && !mask.contains(char::is_whitespace)
}
// A mask whose every meaningful character is a wildcard matches nearly everyone.
// `!`/`@` count as structure-only so `*!*@*` is caught alongside `*`.
fn too_wide(mask: &str) -> bool {
let body = mask.strip_prefix('#').or_else(|| mask.strip_prefix('&')).unwrap_or(mask);
body.is_empty() || body.chars().all(|c| matches!(c, '*' | '?' | '.' | '@' | '!'))
}
fn human_secs(secs: u64) -> String {
match secs {
0 => "moments".to_string(),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
const FLAG_LEGEND: &str = "c=connect d=disconnect j=join p=part n=nick t=topic s=service-cmd S=SET";
const SYNTAX: &str = "Syntax: NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR";