operserv: NOTIFY gains k/m/u/o flags and quit/part reasons; protocol carries kicker, mode-setter, oper-up, user modes
All checks were successful
CI / check (push) Successful in 4m1s

This commit is contained in:
Jean Chevronnet 2026-07-18 19:18:17 +00:00
parent 617d3c7966
commit e05c62c085
No known key found for this signature in database
6 changed files with 139 additions and 46 deletions

View file

@ -36,21 +36,32 @@ pub enum NetEvent {
// A user joined a channel (an FJOIN member or an IJOIN), for auto-op. `op` is // A user joined a channel (an FJOIN member or an IJOIN), for auto-op. `op` is
// whether they hold channel-operator status at that point (an FJOIN prefix). // whether they hold channel-operator status at that point (an FJOIN prefix).
Join { uid: String, channel: String, op: bool }, Join { uid: String, channel: String, op: bool },
// A user left a channel (PART) or was removed (KICK), for membership tracking. // A user left a channel (PART), for membership tracking. `reason` is the part
Part { uid: String, channel: String }, // message (may be empty).
Part { uid: String, channel: String, reason: String },
// A user was kicked from a channel: `by` is the kicker's uid, `reason` the kick
// message. Membership is dropped the same as a part.
Kicked { channel: String, uid: String, by: String, reason: String },
// A user's own modes changed (`:uid MODE uid <modes>`), e.g. going +i. Our own
// pseudo-clients are filtered out by the protocol layer.
UserMode { uid: String, modes: String },
// A user became a network operator (`OPERTYPE`). `oper_type` is the oper class
// WHOIS shows. Our own services opering themselves are filtered out.
OperUp { uid: String, oper_type: String },
// A user's channel-operator status changed (FMODE +o/-o), for live op tracking. // A user's channel-operator status changed (FMODE +o/-o), for live op tracking.
ChannelOp { channel: String, uid: String, op: bool }, ChannelOp { channel: String, uid: String, op: bool },
// A user's voice status changed (FMODE +v/-v, or a +v join prefix). // A user's voice status changed (FMODE +v/-v, or a +v join prefix).
ChannelVoice { channel: String, uid: String, voice: bool }, ChannelVoice { channel: String, uid: String, voice: bool },
// A channel's modes changed (FMODE), for enforcing mode locks. Our own // A channel's modes changed (FMODE), for enforcing mode locks. `setter` is the
// changes are filtered out by the protocol layer. // source uid (empty for a server). Our own changes are filtered by the protocol.
ChannelModeChange { channel: String, modes: String }, ChannelModeChange { channel: String, modes: String, setter: String },
// A channel's key (+k/-k) changed, tracked so GETKEY can report it. // A channel's key (+k/-k) changed, tracked so GETKEY can report it.
ChannelKey { channel: String, key: Option<String> }, ChannelKey { channel: String, key: Option<String> },
// A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is // A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is
// the source uid; our own changes are filtered out by the protocol layer. // the source uid; our own changes are filtered out by the protocol layer.
TopicChange { channel: String, setter: String, topic: String }, TopicChange { channel: String, setter: String, topic: String },
Quit { uid: String }, // A user disconnected (QUIT). `reason` is the quit message (may be empty).
Quit { uid: String, reason: String },
// A user was forcibly removed (KILL). Handled like a quit, except a killed // A user was forcibly removed (KILL). Handled like a quit, except a killed
// services bot is reintroduced rather than forgotten. // services bot is reintroduced rather than forgotten.
UserKilled { uid: String }, UserKilled { uid: String },

View file

@ -118,7 +118,7 @@ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." }, HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." },
HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> <msgid> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." }, HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> <msgid> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." },
HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." }, HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." },
HelpEntry { cmd: "NOTIFY", summary: "watch masks and log their events", detail: "Syntax: \x02NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR\x02\nWatches a \x02nick!user@host\x02 pattern, bare nick glob, or \x02#channel\x02; matching users have their flagged events announced to the staff feed. Flags: \x02c\x02 connect, \x02d\x02 disconnect, \x02j\x02 join, \x02p\x02 part, \x02n\x02 nick change, \x02t\x02 topic, \x02s\x02 services command, \x02S\x02 services SET (\x02*\x02 for all). Expiry like \x02+30d\x02, or \x02+0\x02 for permanent. Admin only." }, HelpEntry { cmd: "NOTIFY", summary: "watch masks and log their events", detail: "Syntax: \x02NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR\x02\nWatches a \x02nick!user@host\x02 pattern, bare nick glob, or \x02#channel\x02; matching users have their flagged events announced to the staff feed. Flags: \x02c\x02 connect, \x02d\x02 disconnect, \x02o\x02 oper-up, \x02j\x02 join, \x02p\x02 part, \x02k\x02 kick, \x02m\x02 channel mode, \x02t\x02 topic, \x02n\x02 nick change, \x02u\x02 user mode, \x02s\x02 services command, \x02S\x02 services SET (\x02*\x02 for all). Expiry like \x02+30d\x02, or \x02+0\x02 for permanent. Admin only." },
HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },
HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] <mask> <reason> | SHUN DEL <mask|number> | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." }, HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] <mask> <reason> | SHUN DEL <mask|number> | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." },

View file

@ -2,7 +2,7 @@ use echo_api::{human_time, parse_duration, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
// The event letters a watch can carry, in help order. // The event letters a watch can carry, in help order.
const ALL_FLAGS: &str = "cdjpntsS"; const ALL_FLAGS: &str = "cdjkmnoptusS";
// NOTIFY ADD +expiry <flags|*> <mask> <reason> | DEL <mask|number> | // NOTIFY ADD +expiry <flags|*> <mask> <reason> | DEL <mask|number> |
// LIST [pattern] | VIEW [pattern] | CLEAR // LIST [pattern] | VIEW [pattern] | CLEAR
@ -158,5 +158,5 @@ fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) 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 FLAG_LEGEND: &str = "c=connect d=disconnect o=oper-up j=join p=part k=kick m=chan-mode t=topic n=nick u=user-mode 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"; const SYNTAX: &str = "Syntax: NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR";

View file

@ -191,7 +191,7 @@ impl Protocol for InspIrcd {
// :<uid> PART <chan> [:reason] — a user leaving a channel. // :<uid> PART <chan> [:reason] — a user leaving a channel.
"PART" => match (source.as_deref(), tokens.next()) { "PART" => match (source.as_deref(), tokens.next()) {
(Some(uid), Some(chan)) if chan.starts_with('#') => { (Some(uid), Some(chan)) if chan.starts_with('#') => {
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string(), reason: trailing(rest) }]
} }
_ => vec![], _ => vec![],
}, },
@ -200,7 +200,7 @@ impl Protocol for InspIrcd {
let chan = tokens.next().unwrap_or(""); let chan = tokens.next().unwrap_or("");
match tokens.next() { match tokens.next() {
Some(uid) if chan.starts_with('#') => { Some(uid) if chan.starts_with('#') => {
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] vec![NetEvent::Kicked { channel: chan.to_string(), uid: uid.to_string(), by: source.unwrap_or_default(), reason: trailing(rest) }]
} }
_ => vec![], _ => vec![],
} }
@ -213,7 +213,7 @@ impl Protocol for InspIrcd {
// Our own uids (server SID + pseudoclients) share our SID prefix. // Our own uids (server SID + pseudoclients) share our SID prefix.
(Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { (Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
let params = a.get(3..).unwrap_or(&[]); let params = a.get(3..).unwrap_or(&[]);
let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]; let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string(), setter: src.to_string() }];
if let Some(key) = scan_key(modes, params) { if let Some(key) = scan_key(modes, params) {
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
} }
@ -228,6 +228,29 @@ impl Protocol for InspIrcd {
_ => vec![], _ => vec![],
} }
} }
// :<uid> MODE <target> <modes> — a user's own mode change (channel modes
// arrive as FMODE). Skip our own pseudo-clients and any channel target.
"MODE" => {
let a: Vec<&str> = tokens.collect();
match (source.as_deref(), a.first()) {
(Some(src), Some(target)) if !src.starts_with(self.sid.as_str()) && !target.starts_with('#') => {
let modes = a.get(1..).unwrap_or(&[]).join(" ");
if modes.is_empty() {
vec![]
} else {
vec![NetEvent::UserMode { uid: (*target).to_string(), modes }]
}
}
_ => vec![],
}
}
// :<uid> OPERTYPE :<type> — a user opered up. Skip our own services.
"OPERTYPE" => match source.as_deref() {
Some(src) if !src.starts_with(self.sid.as_str()) => {
vec![NetEvent::OperUp { uid: src.to_string(), oper_type: trailing(rest) }]
}
_ => vec![],
},
// :<src> FTOPIC <chan> <chants> <topicts> [setby] :<topic> — a topic // :<src> FTOPIC <chan> <chants> <topicts> [setby] :<topic> — a topic
// change. Skip changes we made ourselves so enforcement can't loop. // change. Skip changes we made ourselves so enforcement can't loop.
"FTOPIC" => { "FTOPIC" => {
@ -264,7 +287,7 @@ impl Protocol for InspIrcd {
_ => vec![], _ => vec![],
} }
} }
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default(), reason: trailing(rest) }],
// :<src> KILL <uid> :<reason> — a user forcibly removed. We forget // :<src> KILL <uid> :<reason> — a user forcibly removed. We forget
// them (or reintroduce, if it was one of our bots). // them (or reintroduce, if it was one of our bots).
"KILL" => match tokens.next() { "KILL" => match tokens.next() {
@ -835,7 +858,7 @@ mod tests {
fn parses_fmode_and_filters_own() { fn parses_fmode_and_filters_own() {
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m"); let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m");
assert!( assert!(
matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"), matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes, .. }] if channel == "#chan" && modes == "+m"),
"{ev:?}" "{ev:?}"
); );
let ev = proto().parse(":42S FMODE #chan 1783845132 -m"); let ev = proto().parse(":42S FMODE #chan 1783845132 -m");

View file

@ -414,6 +414,20 @@ impl Engine {
self.feed("NOTIFY", format!("{who} {what}{detail}")) self.feed("NOTIFY", format!("{who} {what}{detail}"))
} }
// Membership cleanup shared by PART and KICK: drop the user from the channel,
// forget their chatter, and re-add an assigned bot that was evicted (an op can't
// kick the channel's own bot). Any reconcile actions are returned.
fn member_left(&mut self, channel: &str, uid: &str) -> Vec<NetAction> {
self.network.channel_part(channel, uid);
self.forget_chatter(channel, uid);
if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == uid).then(|| lc.clone())) {
if self.bot_channels.remove(&(bot_lc, channel.to_string())) {
return self.reconcile_bots();
}
}
Vec::new()
}
// Inactivity-expiry thresholds (seconds); None leaves that kind never // Inactivity-expiry thresholds (seconds); None leaves that kind never
// expiring. `warn` is the lead time before expiry to email a warning (None = // expiring. `warn` is the lead time before expiry to email a warning (None =
// no warning emails). // no warning emails).
@ -1266,12 +1280,13 @@ impl Engine {
} }
} }
// Enforce the mode lock: revert any change that broke it. // Enforce the mode lock: revert any change that broke it.
NetEvent::ChannelModeChange { channel, modes } => { NetEvent::ChannelModeChange { channel, modes, setter } => {
let mut out: Vec<NetAction> = self.notify_line('m', &setter, Some(&channel), &format!("set mode {modes} on {channel}")).into_iter().collect();
let from = self.channel_mode_source(&channel); let from = self.channel_mode_source(&channel);
match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { if let Some(revert) = self.db.channel(&channel).and_then(|info| info.enforce(&modes)) {
Some(revert) => vec![NetAction::ChannelMode { from, channel, modes: revert }], out.push(NetAction::ChannelMode { from, channel, modes: revert });
None => Vec::new(),
} }
out
} }
// On join: record membership, enforce the auto-kick list, give access // On join: record membership, enforce the auto-kick list, give access
// members their status mode, and send the entry message. // members their status mode, and send the entry message.
@ -1365,19 +1380,27 @@ impl Engine {
} }
out out
} }
NetEvent::Part { uid, channel } => { NetEvent::Part { uid, channel, reason } => {
// Match before dropping membership so the identity is still resolvable. // Match before dropping membership so the identity is still resolvable.
let mut out: Vec<NetAction> = self.notify_line('p', &uid, Some(&channel), &format!("left {channel}")).into_iter().collect(); let what = if reason.is_empty() { format!("left {channel}") } else { format!("left {channel} ({reason})") };
self.network.channel_part(&channel, &uid); let mut out: Vec<NetAction> = self.notify_line('p', &uid, Some(&channel), &what).into_iter().collect();
self.forget_chatter(&channel, &uid); out.extend(self.member_left(&channel, &uid));
// A services bot kicked/parted from a channel it's still assigned out
// to rejoins — an op can't evict it. Drop the stale membership so }
// reconcile re-adds it. NetEvent::Kicked { channel, uid, by, reason } => {
if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == &uid).then(|| lc.clone())) { // Two watches can fire: the victim and the kicker. Resolve nicks and
if self.bot_channels.remove(&(bot_lc, channel.clone())) { // match before the membership drop.
out.extend(self.reconcile_bots()); let bynick = self.network.nick_of(&by).unwrap_or(&by).to_string();
} let victim = self.network.nick_of(&uid).unwrap_or(&uid).to_string();
let tail = if reason.is_empty() { String::new() } else { format!(" ({reason})") };
let mut out = Vec::new();
if let Some(l) = self.notify_line('k', &uid, Some(&channel), &format!("was kicked from {channel} by {bynick}{tail}")) {
out.push(l);
} }
if let Some(l) = self.notify_line('k', &by, Some(&channel), &format!("kicked {victim} from {channel}{tail}")) {
out.push(l);
}
out.extend(self.member_left(&channel, &uid));
out out
} }
NetEvent::ChannelOp { channel, uid, op } => { NetEvent::ChannelOp { channel, uid, op } => {
@ -1422,12 +1445,20 @@ impl Engine {
out.extend(watch); out.extend(watch);
out out
} }
NetEvent::Quit { uid } => { NetEvent::Quit { uid, reason } => {
// Match before we forget them, so their identity is still resolvable. // Match before we forget them, so their identity is still resolvable.
let out: Vec<NetAction> = self.notify_line('d', &uid, None, "disconnected").into_iter().collect(); let what = if reason.is_empty() { "disconnected".to_string() } else { format!("disconnected ({reason})") };
let out: Vec<NetAction> = self.notify_line('d', &uid, None, &what).into_iter().collect();
self.forget_user(&uid); self.forget_user(&uid);
out out
} }
NetEvent::UserMode { uid, modes } => {
self.notify_line('u', &uid, None, &format!("set user mode {modes}")).into_iter().collect()
}
NetEvent::OperUp { uid, oper_type } => {
let what = if oper_type.is_empty() { "opered up".to_string() } else { format!("opered up as \x02{oper_type}\x02") };
self.notify_line('o', &uid, None, &what).into_iter().collect()
}
NetEvent::UserKilled { uid } => { NetEvent::UserKilled { uid } => {
// A killed service agent (NickServ, ChanServ, …) has a fixed uid; // A killed service agent (NickServ, ChanServ, …) has a fixed uid;
// bring it straight back so an oper can't KILL it off the network. // bring it straight back so an oper can't KILL it off the network.

View file

@ -238,7 +238,7 @@
let mut e = engine_with("quit", "foo", "sesame"); let mut e = engine_with("quit", "foo", "sesame");
start(&mut e, "ZZZ"); start(&mut e, "ZZZ");
assert!(e.sasl_sessions.contains_key("ZZZ")); assert!(e.sasl_sessions.contains_key("ZZZ"));
e.handle(NetEvent::Quit { uid: "ZZZ".into() }); e.handle(NetEvent::Quit { uid: "ZZZ".into(), reason: String::new() });
assert!(!e.sasl_sessions.contains_key("ZZZ"), "quit should drop the exchange"); assert!(!e.sasl_sessions.contains_key("ZZZ"), "quit should drop the exchange");
} }
@ -1175,7 +1175,7 @@
// During the uplink burst (not yet synced) nobody is enforced. // During the uplink burst (not yet synced) nobody is enforced.
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAZ".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAZ".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "no enforcement during burst: {out:?}"); assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "no enforcement during burst: {out:?}");
e.handle(NetEvent::Quit { uid: "000AAAAAZ".into() }); e.handle(NetEvent::Quit { uid: "000AAAAAZ".into(), reason: String::new() });
e.handle(NetEvent::EndBurst); e.handle(NetEvent::EndBurst);
// Live: an unidentified user lands on the registered nick and is prompted. // Live: an unidentified user lands on the registered nick and is prompted.
@ -1209,7 +1209,7 @@
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL OFF".into() }); let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL OFF".into() });
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("off"))), "KILL OFF confirmed: {out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("off"))), "KILL OFF confirmed: {out:?}");
assert!(!e.db.account_wants_protect("alice"), "protection preference stored off"); assert!(!e.db.account_wants_protect("alice"), "protection preference stored off");
e.handle(NetEvent::Quit { uid: "000AAAAAA".into() }); e.handle(NetEvent::Quit { uid: "000AAAAAA".into(), reason: String::new() });
// An unidentified intruder takes the nick: no prompt, and no rename at grace. // An unidentified intruder takes the nick: no prompt, and no rename at grace.
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
@ -1221,12 +1221,12 @@
assert!(rx.try_recv().is_err(), "no rename when KILL is off"); assert!(rx.try_recv().is_err(), "no rename when KILL is off");
// Turning it back on restores enforcement for the next arrival. // Turning it back on restores enforcement for the next arrival.
e.handle(NetEvent::Quit { uid: "000AAAAAB".into() }); e.handle(NetEvent::Quit { uid: "000AAAAAB".into(), reason: String::new() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL ON".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL ON".into() });
assert!(e.db.account_wants_protect("alice"), "protection preference stored on"); assert!(e.db.account_wants_protect("alice"), "protection preference stored on");
e.handle(NetEvent::Quit { uid: "000AAAAAA".into() }); e.handle(NetEvent::Quit { uid: "000AAAAAA".into(), reason: String::new() });
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "prompt returns when KILL back on: {out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "prompt returns when KILL back on: {out:?}");
} }
@ -2095,7 +2095,7 @@
// Default: the founder is auto-opped on join. // Default: the founder is auto-opped on join.
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default"); assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default");
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into() }); e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
// Turn AUTOOP off; now a join is not auto-opped. // Turn AUTOOP off; now a join is not auto-opped.
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c AUTOOP OFF".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c AUTOOP OFF".into() });
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op when AUTOOP is off"); assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op when AUTOOP is off");
@ -2128,7 +2128,7 @@
// Default: founder is auto-opped (channel AUTOOP on, account AUTOOP on). // Default: founder is auto-opped (channel AUTOOP on, account AUTOOP on).
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default"); assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default");
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into() }); e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
// Account opts out via NickServ; now no auto-op despite channel allowing it. // Account opts out via NickServ; now no auto-op despite channel allowing it.
assert!(notice(&ns(&mut e, "SET AUTOOP OFF"), "no longer be auto-opped"), "pref set off"); assert!(notice(&ns(&mut e, "SET AUTOOP OFF"), "no longer be auto-opped"), "pref set off");
@ -2136,7 +2136,7 @@
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op with account AUTOOP off"); assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op with account AUTOOP off");
// Turn it back on: auto-op resumes. // Turn it back on: auto-op resumes.
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into() }); e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
assert!(notice(&ns(&mut e, "SET AUTOOP ON"), "auto-opped where you have"), "pref set on"); assert!(notice(&ns(&mut e, "SET AUTOOP ON"), "auto-opped where you have"), "pref set on");
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op resumes"); assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op resumes");
} }
@ -2334,7 +2334,7 @@
}).expect("bot joined on assign"); }).expect("bot joined on assign");
// The bot is kicked out (the ircd relays this as a PART for its uid). // The bot is kicked out (the ircd relays this as a PART for its uid).
let out = e.handle(NetEvent::Part { uid: bot_uid.clone(), channel: "#c".into() }); let out = e.handle(NetEvent::Part { uid: bot_uid.clone(), channel: "#c".into(), reason: String::new() });
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel, .. } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel, .. } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}");
} }
@ -3345,7 +3345,7 @@
!e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true }).iter().any(|a| matches!(a, NetAction::Privmsg { .. })), !e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true }).iter().any(|a| matches!(a, NetAction::Privmsg { .. })),
"no greet before it is enabled" "no greet before it is enabled"
); );
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into() }); e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
bs(&mut e, "000AAAAAB", "SET #c GREET ON"); bs(&mut e, "000AAAAAB", "SET #c GREET ON");
// Now the founder's greet is shown by the bot. // Now the founder's greet is shown by the bot.
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true }); let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
@ -4320,7 +4320,7 @@
assert!(os(&mut e, "000AAAAAS", "EXCEPTION LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("5.5.5.5") && text.contains("unlimited"))), "listed"); assert!(os(&mut e, "000AAAAAS", "EXCEPTION LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("5.5.5.5") && text.contains("unlimited"))), "listed");
// A quit frees a slot. // A quit frees a slot.
e.handle(NetEvent::Quit { uid: "000AAAAA4".into() }); e.handle(NetEvent::Quit { uid: "000AAAAA4".into(), reason: String::new() });
assert!(os(&mut e, "000AAAAAS", "SESSION VIEW 6.6.6.6").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("0"))), "slot freed on quit"); assert!(os(&mut e, "000AAAAAS", "SESSION VIEW 6.6.6.6").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("0"))), "slot freed on quit");
} }
@ -5059,7 +5059,7 @@
// SEEN: an online user vs one who quit. // SEEN: an online user vs one who quit.
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN alice"), "currently online")); assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN alice"), "currently online"));
e.handle(NetEvent::Quit { uid: "000AAAAAC".into() }); e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: String::new() });
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen")); assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen"));
// CLONE: copy #c's settings (incl. the AOP) into a second owned channel. // CLONE: copy #c's settings (incl. the AOP) into a second owned channel.
@ -5102,11 +5102,11 @@
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "+rnt-s")), "{out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "+rnt-s")), "{out:?}");
// Setting a locked-off mode is reverted. // Setting a locked-off mode is reverted.
let out = e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+s".into() }); let out = e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+s".into(), setter: String::new() });
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "-s")), "{out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "-s")), "{out:?}");
// An unrelated mode change is left alone. // An unrelated mode change is left alone.
assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into() }).is_empty()); assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into(), setter: String::new() }).is_empty());
} }
// The founder is opped when they join their channel; others are not. // The founder is opped when they join their channel; others are not.
@ -5423,13 +5423,41 @@ fn notify_hook_emits_feed_for_watched_user() {
let quiet = e.handle(NetEvent::UserAttrs { uid: "000AAAAAD".into(), ident: "y".into(), realhost: "h".into(), gecos: "g".into() }); let quiet = e.handle(NetEvent::UserAttrs { uid: "000AAAAAD".into(), ident: "y".into(), realhost: "h".into(), gecos: "g".into() });
assert!(!quiet.iter().any(|a| matches!(a, NetAction::Privmsg { to, .. } if to == "#services")), "no line for a clean user: {quiet:?}"); assert!(!quiet.iter().any(|a| matches!(a, NetAction::Privmsg { to, .. } if to == "#services")), "no line for a clean user: {quiet:?}");
// Their disconnect is matched before we forget them. // Their disconnect is matched before we forget them.
let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into() }); let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: String::new() });
assert!( assert!(
dc.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text.contains("[NOTIFY]") && text.contains("disconnected"))), dc.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text.contains("[NOTIFY]") && text.contains("disconnected"))),
"expected a NOTIFY disconnect line, got {dc:?}" "expected a NOTIFY disconnect line, got {dc:?}"
); );
} }
#[test]
fn notify_hook_covers_kick_reason_oper_and_usermode() {
let mut e = engine_with("nfyext", "foo", "sesame");
e.set_sid("42S".into());
e.set_log_channel(Some("#services".into()));
e.set_debugserv_uid("42SAAAAAO");
e.db.notify_add("baddie*!*@*", "kdou", "watch", "admin", None).unwrap();
// The watched victim and a (non-watched) kicker.
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "baddie1".into(), host: "h".into(), ip: "1.2.3.4".into() });
e.handle(NetEvent::UserAttrs { uid: "000AAAAAC".into(), ident: "x".into(), realhost: "h".into(), gecos: "g".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "moddy".into(), host: "h".into(), ip: "9.9.9.9".into() });
let feed = |acts: &[NetAction], needle: &str| acts.iter().any(|a| matches!(a, NetAction::Privmsg { to, text, .. } if to == "#services" && text.contains("[NOTIFY]") && text.contains(needle)));
// Kick: the victim's line names the kicker and carries the reason.
let k = e.handle(NetEvent::Kicked { channel: "#devs".into(), uid: "000AAAAAC".into(), by: "000AAAAAD".into(), reason: "spam".into() });
assert!(feed(&k, "was kicked from #devs by moddy") && feed(&k, "(spam)"), "kick line: {k:?}");
// User mode change.
let u = e.handle(NetEvent::UserMode { uid: "000AAAAAC".into(), modes: "+i".into() });
assert!(feed(&u, "set user mode +i"), "usermode line: {u:?}");
// Oper-up carries the oper class.
let o = e.handle(NetEvent::OperUp { uid: "000AAAAAC".into(), oper_type: "NetAdmin".into() });
assert!(feed(&o, "opered up") && feed(&o, "NetAdmin"), "operup line: {o:?}");
// Disconnect now carries the quit reason.
let d = e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: "Ping timeout: 240 seconds".into() });
assert!(feed(&d, "disconnected (Ping timeout: 240 seconds)"), "quit reason line: {d:?}");
}
#[test] #[test]
fn cmd_limiter_bursts_then_throttles_per_host() { fn cmd_limiter_bursts_then_throttles_per_host() {
let mut lim = CmdLimiter::default(); let mut lim = CmdLimiter::default();