diff --git a/api/src/lib.rs b/api/src/lib.rs index 448f06b..b69b8af 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -36,21 +36,32 @@ pub enum NetEvent { // 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). Join { uid: String, channel: String, op: bool }, - // A user left a channel (PART) or was removed (KICK), for membership tracking. - Part { uid: String, channel: String }, + // A user left a channel (PART), for membership tracking. `reason` is the part + // 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 `), 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. ChannelOp { channel: String, uid: String, op: bool }, // A user's voice status changed (FMODE +v/-v, or a +v join prefix). ChannelVoice { channel: String, uid: String, voice: bool }, - // A channel's modes changed (FMODE), for enforcing mode locks. Our own - // changes are filtered out by the protocol layer. - ChannelModeChange { channel: String, modes: String }, + // A channel's modes changed (FMODE), for enforcing mode locks. `setter` is the + // source uid (empty for a server). Our own changes are filtered by the protocol. + ChannelModeChange { channel: String, modes: String, setter: String }, // A channel's key (+k/-k) changed, tracked so GETKEY can report it. ChannelKey { channel: String, key: Option }, // A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is // the source uid; our own changes are filtered out by the protocol layer. 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 // services bot is reintroduced rather than forgotten. UserKilled { uid: String }, diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 218a09b..1465925 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -118,7 +118,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD [+expiry] | SPAMFILTER DEL | 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> [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 | FORBID DEL | 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 + | NOTIFY DEL | 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 + | NOTIFY DEL | 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] | SQLINE DEL | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] | SNLINE DEL | SNLINE LIST [pattern]\x02\nBans matching real names." }, HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] | SHUN DEL | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." }, diff --git a/modules/operserv/src/notify.rs b/modules/operserv/src/notify.rs index 4626935..e1edf3c 100644 --- a/modules/operserv/src/notify.rs +++ b/modules/operserv/src/notify.rs @@ -2,7 +2,7 @@ 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"; +const ALL_FLAGS: &str = "cdjkmnoptusS"; // NOTIFY ADD +expiry | DEL | // 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) } -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 + | NOTIFY DEL | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR"; diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 333da5c..b6a4e7b 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -191,7 +191,7 @@ impl Protocol for InspIrcd { // : PART [:reason] — a user leaving a channel. "PART" => match (source.as_deref(), tokens.next()) { (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![], }, @@ -200,7 +200,7 @@ impl Protocol for InspIrcd { let chan = tokens.next().unwrap_or(""); match tokens.next() { 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![], } @@ -213,7 +213,7 @@ impl Protocol for InspIrcd { // 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('#') => { 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) { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } @@ -228,6 +228,29 @@ impl Protocol for InspIrcd { _ => vec![], } } + // : MODE — 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![], + } + } + // : OPERTYPE : — 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![], + }, // : FTOPIC [setby] : — a topic // change. Skip changes we made ourselves so enforcement can't loop. "FTOPIC" => { @@ -264,7 +287,7 @@ impl Protocol for InspIrcd { _ => vec![], } } - "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], + "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default(), reason: trailing(rest) }], // : KILL : — a user forcibly removed. We forget // them (or reintroduce, if it was one of our bots). "KILL" => match tokens.next() { @@ -835,7 +858,7 @@ mod tests { fn parses_fmode_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m"); 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:?}" ); let ev = proto().parse(":42S FMODE #chan 1783845132 -m"); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index a36275d..0af638d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -414,6 +414,20 @@ impl Engine { 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 { + 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 // expiring. `warn` is the lead time before expiry to email a warning (None = // no warning emails). @@ -1266,12 +1280,13 @@ impl Engine { } } // Enforce the mode lock: revert any change that broke it. - NetEvent::ChannelModeChange { channel, modes } => { + NetEvent::ChannelModeChange { channel, modes, setter } => { + let mut out: Vec = self.notify_line('m', &setter, Some(&channel), &format!("set mode {modes} on {channel}")).into_iter().collect(); let from = self.channel_mode_source(&channel); - match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { - Some(revert) => vec![NetAction::ChannelMode { from, channel, modes: revert }], - None => Vec::new(), + if let Some(revert) = self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { + out.push(NetAction::ChannelMode { from, channel, modes: revert }); } + out } // On join: record membership, enforce the auto-kick list, give access // members their status mode, and send the entry message. @@ -1365,19 +1380,27 @@ impl Engine { } out } - NetEvent::Part { uid, channel } => { + NetEvent::Part { uid, channel, reason } => { // Match before dropping membership so the identity is still resolvable. - let mut out: Vec = self.notify_line('p', &uid, Some(&channel), &format!("left {channel}")).into_iter().collect(); - self.network.channel_part(&channel, &uid); - self.forget_chatter(&channel, &uid); - // A services bot kicked/parted from a channel it's still assigned - // to rejoins — an op can't evict it. Drop the stale membership so - // reconcile re-adds it. - 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.clone())) { - out.extend(self.reconcile_bots()); - } + let what = if reason.is_empty() { format!("left {channel}") } else { format!("left {channel} ({reason})") }; + let mut out: Vec = self.notify_line('p', &uid, Some(&channel), &what).into_iter().collect(); + out.extend(self.member_left(&channel, &uid)); + out + } + NetEvent::Kicked { channel, uid, by, reason } => { + // Two watches can fire: the victim and the kicker. Resolve nicks and + // match before the membership drop. + 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 } NetEvent::ChannelOp { channel, uid, op } => { @@ -1422,12 +1445,20 @@ impl Engine { out.extend(watch); out } - NetEvent::Quit { uid } => { + NetEvent::Quit { uid, reason } => { // Match before we forget them, so their identity is still resolvable. - let out: Vec = 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 = self.notify_line('d', &uid, None, &what).into_iter().collect(); self.forget_user(&uid); 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 } => { // A killed service agent (NickServ, ChanServ, …) has a fixed uid; // bring it straight back so an oper can't KILL it off the network. diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 37598d0..663bfa4 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -238,7 +238,7 @@ let mut e = engine_with("quit", "foo", "sesame"); start(&mut e, "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"); } @@ -1175,7 +1175,7 @@ // 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() }); 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); // 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() }); 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"); - 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. 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"); // 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::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() }); 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() }); 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. 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. 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"); @@ -2128,7 +2128,7 @@ // 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"); - 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. 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"); // 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!(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"); // 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:?}"); } @@ -3345,7 +3345,7 @@ !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" ); - 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"); // Now the founder's greet is shown by the bot. 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"); // 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"); } @@ -5059,7 +5059,7 @@ // SEEN: an online user vs one who quit. 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")); // 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:?}"); // 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:?}"); // 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. @@ -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() }); 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. - let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into() }); + let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: String::new() }); assert!( dc.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text.contains("[NOTIFY]") && text.contains("disconnected"))), "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] fn cmd_limiter_bursts_then_throttles_per_host() { let mut lim = CmdLimiter::default();