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

@ -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: "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: "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: "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." },

View file

@ -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 <flags|*> <mask> <reason> | DEL <mask|number> |
// 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 +<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.
"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![],
}
}
// :<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
// 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) }],
// :<src> KILL <uid> :<reason> — 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");