From b40c523b876172215f770bed57f7aed3eb69d58b Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 11 Aug 2026 19:46:07 +0000 Subject: [PATCH] =?UTF-8?q?customprefix:=20data-driven=20prefix=20engine?= =?UTF-8?q?=20=E2=80=94=20define=20arbitrary=20new=20prefix=20modes=20(let?= =?UTF-8?q?ter/prefix/rank/ranktoset/ranktounset/depriv),=20ranks=20re-spa?= =?UTF-8?q?ced=20x10;=20built-in=20tiers=20+=20defaults=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- echoircd.conf.example | 12 ++- src/channels.rs | 128 ++++++++++++++++++-------- src/ircd.rs | 3 +- src/mode.rs | 40 ++++++--- src/modules/customprefix.rs | 175 +++++++++++++++++++++++++----------- 5 files changed, 256 insertions(+), 102 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index 237067d..e1a1527 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -52,12 +52,16 @@ use_resolved_host = on # allow a unique command prefix to resolve to its full command (e.g. WHOI -> WHOIS) # abbreviation = yes -# reconfigure channel-prefix tiers (oper founder admin op halfop voice): -# a bare token = the displayed sigil; ranktoset / ranktounset = min rank to grant / -# revoke it (a number 1-6 or a tier name); depriv=no forbids removing it from -# yourself. (Symbols/policy only — adding brand-new tiers isn't supported.) +# customprefix — reconfigure built-in prefix tiers, or define brand-new ones. +# Built-in tiers (oper founder admin op halfop voice): a bare token = the sigil; +# ranktoset / ranktounset = min rank to grant / revoke it (a number or a tier name); +# depriv=no forbids removing it from yourself. # customprefix = op * ranktoset=admin ranktounset=admin depriv=no # customprefix = voice - +# New prefix (name is anything that isn't a built-in tier): letter + prefix required; +# rank (default 1), ranktoset/ranktounset (default rank), depriv (default yes). +# Ranks: voice=10 halfop=20 op=30 admin=40 founder=50 oper=60 (room to slot between). +# customprefix = helper letter=V prefix=? rank=25 ranktoset=op ranktounset=op # DNS blocklist (DNSBL) checks on connect. Repeat `dnsbl` # for multiple zones. On a listing, `dnsbl_action` decides what happens: diff --git a/src/channels.rs b/src/channels.rs index 5efe748..82c2d4f 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -18,22 +18,25 @@ pub struct Member { pub op: bool, // +o (@) pub halfop: bool, // +h (%) pub voice: bool, // +v (+) + pub custom_prefixes: Vec, // config-defined prefix mode letters held (customprefix) pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) pub recent_msgs: Vec, // +K repeat: this member's last few lines here pub hidden: bool, // +D delayjoin: JOIN withheld until they reveal themselves } -/// Prefix ranks, high→low — gate who may grant a prefix / kick whom. -pub const RANK_OPER: u8 = 6; // operprefix/ojoin — above channel owner (network staff) -pub const RANK_OWNER: u8 = 5; -pub const RANK_ADMIN: u8 = 4; -pub const RANK_OP: u8 = 3; -pub const RANK_HALFOP: u8 = 2; -pub const RANK_VOICE: u8 = 1; +/// Prefix ranks, high→low — gate who may grant a prefix / kick whom. Spaced ×10 so +/// config-defined custom prefixes (modules::customprefix) can slot in between. +pub const RANK_OPER: u8 = 60; // operprefix/ojoin — above channel owner (network staff) +pub const RANK_OWNER: u8 = 50; +pub const RANK_ADMIN: u8 = 40; +pub const RANK_OP: u8 = 30; +pub const RANK_HALFOP: u8 = 20; +pub const RANK_VOICE: u8 = 10; impl Member { /// This member's numeric rank (0 = plain member). - pub fn rank(&self) -> u8 { + /// Built-in tier rank from the fixed booleans (0 = none), ignoring custom prefixes. + fn builtin_rank(&self) -> u8 { if self.oprefix { RANK_OPER } else if self.owner { @@ -51,28 +54,70 @@ impl Member { } } - /// Highest prefix char for NAMES (`""` for a plain member). The sigil per tier is + pub fn rank(&self) -> u8 { + let mut r = self.builtin_rank(); + for &c in &self.custom_prefixes { + if let Some(d) = crate::modules::customprefix::def_for_letter(c) { + r = r.max(d.rank); + } + } + r + } + + /// Every (rank, sigil) prefix this member holds, high→low. Only allocates when + /// custom prefixes are actually present. + fn held(&self) -> Vec<(u8, &'static str)> { + use crate::modules::customprefix::{def_for_letter, sigil}; + let mut v: Vec<(u8, &'static str)> = Vec::new(); + for (on, r, i) in [ + (self.oprefix, RANK_OPER, 0), + (self.owner, RANK_OWNER, 1), + (self.admin, RANK_ADMIN, 2), + (self.op, RANK_OP, 3), + (self.halfop, RANK_HALFOP, 4), + (self.voice, RANK_VOICE, 5), + ] { + if on { + v.push((r, sigil(i))); + } + } + for &c in &self.custom_prefixes { + if let Some(d) = def_for_letter(c) { + v.push((d.rank, d.sigil.as_str())); + } + } + v.sort_by(|a, b| b.0.cmp(&a.0)); + v + } + + /// Highest prefix char for NAMES (`""` for a plain member). Sigils are /// config-overridable via [`crate::modules::customprefix`]. pub fn prefix_char(&self) -> &'static str { use crate::modules::customprefix::sigil; - if self.oprefix { - sigil(0) - } else if self.owner { - sigil(1) - } else if self.admin { - sigil(2) - } else if self.op { - sigil(3) - } else if self.halfop { - sigil(4) - } else if self.voice { - sigil(5) + if self.custom_prefixes.is_empty() { + // fast path: built-in tiers only + if self.oprefix { + sigil(0) + } else if self.owner { + sigil(1) + } else if self.admin { + sigil(2) + } else if self.op { + sigil(3) + } else if self.halfop { + sigil(4) + } else if self.voice { + sigil(5) + } else { + "" + } } else { - "" + self.held().first().map(|(_, s)| *s).unwrap_or("") } } - /// Set/clear a prefix mode by its letter (used by the S2S mode applier). + /// Set/clear a prefix mode by its letter — built-in booleans or, for a + /// config-defined letter, the custom-prefix set (used by the S2S mode applier). pub fn set_prefix(&mut self, letter: char, on: bool) { match letter { 'y' => self.oprefix = on, @@ -81,27 +126,38 @@ impl Member { 'o' => self.op = on, 'h' => self.halfop = on, 'v' => self.voice = on, - _ => {} + _ => { + if crate::modules::customprefix::def_for_letter(letter).is_some() { + self.custom_prefixes.retain(|&c| c != letter); + if on { + self.custom_prefixes.push(letter); + } + } + } } } /// Every prefix char this member holds, high→low (for the `multi-prefix` cap). pub fn all_prefixes(&self) -> String { use crate::modules::customprefix::sigil; - let mut s = String::new(); - for (on, i) in [ - (self.oprefix, 0), - (self.owner, 1), - (self.admin, 2), - (self.op, 3), - (self.halfop, 4), - (self.voice, 5), - ] { - if on { - s.push_str(sigil(i)); + if self.custom_prefixes.is_empty() { + let mut s = String::new(); + for (on, i) in [ + (self.oprefix, 0), + (self.owner, 1), + (self.admin, 2), + (self.op, 3), + (self.halfop, 4), + (self.voice, 5), + ] { + if on { + s.push_str(sigil(i)); + } } + s + } else { + self.held().iter().map(|(_, s)| *s).collect() } - s } } diff --git a/src/ircd.rs b/src/ircd.rs index 05c2a2b..1195f5e 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -103,7 +103,8 @@ impl Ircd { crate::modules::metadata::load(&mut server); // restore channel metadata crate::modules::reputation::load(&mut server); // restore per-IP reputation crate::modules::geoip::init(&mut server); // load the GeoIP database - crate::modules::customprefix::init(&server); // load prefix-sigil overrides + crate::modules::customprefix::init(&server); // load prefix config + crate::mode::init_custom_prefixes(); // register any config-defined prefix modes Ircd { server, commands: command_table(), diff --git a/src/mode.rs b/src/mode.rs index 99dbf61..a298931 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -6,9 +6,11 @@ //! edit to the parser. The handler set is an ordinary slice of zero-sized //! `&'static` values: no fixed cap, no per-mode allocation, no mutable registry. +use std::sync::OnceLock; + use crate::channels::{ normalize_ban_mask, Ban, ChanModes, Channel, MsgFlood, Rate, RANK_ADMIN, RANK_HALFOP, RANK_OP, - RANK_OWNER, + RANK_OWNER, RANK_VOICE, }; use crate::numeric::*; use crate::server::{now, Server}; @@ -44,9 +46,31 @@ pub trait ChanMode: Sync { ) -> Applied; } -/// Look up the handler for a channel-mode letter. +/// Dynamic handlers for config-defined custom prefix modes (see modules::customprefix). +static CUSTOM_PREFIX_HANDLERS: OnceLock> = OnceLock::new(); + +/// Build the custom-prefix handlers once, at boot, after `customprefix::init`. +pub fn init_custom_prefixes() { + let v: Vec = crate::modules::customprefix::custom_defs() + .iter() + .map(|d| Prefix { + ch: d.letter, + rank: d.rank, + }) + .collect(); + let _ = CUSTOM_PREFIX_HANDLERS.set(v); +} + +/// Look up the handler for a channel-mode letter (built-in, then custom prefixes). pub fn chan_mode(c: char) -> Option<&'static (dyn ChanMode + Sync)> { - CHAN_MODES.iter().copied().find(|m| m.letter() == c) + if let Some(m) = CHAN_MODES.iter().copied().find(|m| m.letter() == c) { + return Some(m); + } + CUSTOM_PREFIX_HANDLERS + .get()? + .iter() + .find(|p| p.ch == c) + .map(|p| p as &(dyn ChanMode + Sync)) } /// The registered channel modes. Add a mode by adding its handler here. @@ -121,7 +145,7 @@ static HALFOP: Prefix = Prefix { }; static VOICE: Prefix = Prefix { ch: 'v', - rank: 1, // RANK_VOICE + rank: RANK_VOICE, }; impl ChanMode for Prefix { @@ -189,13 +213,7 @@ impl ChanMode for Prefix { .get_mut(key) .and_then(|c| c.members.get_mut(&tuid)) { - match self.rank { - RANK_OWNER => m.owner = adding, - RANK_ADMIN => m.admin = adding, - RANK_OP => m.op = adding, - RANK_HALFOP => m.halfop = adding, - _ => m.voice = adding, - } + m.set_prefix(self.ch, adding); // routes built-in booleans + custom prefixes } // +D delayjoin: gaining a prefix reveals a hidden member if adding { diff --git a/src/modules/customprefix.rs b/src/modules/customprefix.rs index ba95918..8d19d49 100644 --- a/src/modules/customprefix.rs +++ b/src/modules/customprefix.rs @@ -1,45 +1,61 @@ -//! customprefix — reconfigure the channel prefix tiers from config, like InspIRCd's -//! m_customprefix does for existing prefixes (`change="yes"`). One line per tier: +//! customprefix — reconfigure the channel prefix tiers *and* define brand-new ones, +//! like InspIRCd's m_customprefix. Two forms, one line each: //! //! ```text +//! # reconfigure a built-in tier (oper founder admin op halfop voice): //! customprefix = op * ranktoset=admin ranktounset=admin depriv=no -//! customprefix = voice - +//! +//! # define a NEW prefix mode (name is anything that isn't a built-in tier): +//! customprefix = helper letter=V prefix=? rank=25 ranktoset=op ranktounset=op depriv=yes //! ``` //! -//! Tiers: `oper founder admin op halfop voice`. Knobs: -//! * a bare token = the displayed sigil (e.g. `*`) -//! * `ranktoset=` min rank to grant this prefix (default: the prefix's rank) -//! * `ranktounset=` min rank to revoke it (default: ranktoset) -//! * `depriv=no` members may not remove this prefix from themselves -//! -//! A `` is a number (1–6) or a tier name (`op`, `admin`, …). Only the display -//! and set/unset policy change — the mode letters (`yqaohv`) and ranks stay fixed, so -//! NAMES/WHO, ISUPPORT PREFIX and the S2S FJOIN burst stay consistent (a linked -//! network must share this config). Adding brand-new tiers with new letters/ranks -//! would need a data-driven prefix engine and isn't supported. Loaded once at boot. +//! For a built-in tier: a bare token is the sigil; `ranktoset`/`ranktounset` set the +//! min rank to grant/revoke it; `depriv=no` forbids self-removal. For a new prefix: +//! `letter` (mode char) and `prefix` (sigil) are required; `rank` is its rank +//! (default 1); `ranktoset`/`ranktounset` default to `rank`; `depriv` defaults yes. +//! A `` is a number or a built-in tier name. New prefixes flow through the +//! normal mode machinery (a dynamic handler is registered in [`crate::mode`]) and +//! are held on `Member.custom_prefixes`. A linked network must share this config. use std::sync::OnceLock; +use crate::config::yesish; use crate::server::Server; const TIER_NAMES: [&str; 6] = ["oper", "founder", "admin", "op", "halfop", "voice"]; -const LETTERS: [char; 6] = ['y', 'q', 'a', 'o', 'h', 'v']; +pub const LETTERS: [char; 6] = ['y', 'q', 'a', 'o', 'h', 'v']; const DEFAULT_SIGILS: [&str; 6] = ["!", "~", "&", "@", "%", "+"]; -const RANKS: [u8; 6] = [6, 5, 4, 3, 2, 1]; // oper..voice +/// Built-in tier ranks (×10 spacing leaves room to slot custom tiers between them). +pub const RANKS: [u8; 6] = [60, 50, 40, 30, 20, 10]; + +/// A config-defined channel prefix mode. +pub struct PrefixDef { + pub letter: char, + pub sigil: String, + pub rank: u8, + pub ranktoset: u8, + pub ranktounset: u8, + pub depriv: bool, +} struct PrefixCfg { sigils: [String; 6], ranktoset: [Option; 6], ranktounset: [Option; 6], depriv: [bool; 6], + custom: Vec, } static CFG: OnceLock = OnceLock::new(); -/// Parse a rank: a number (1–6) or a tier name. +fn is_builtin_letter(c: char) -> bool { + LETTERS.contains(&c) +} + +/// Parse a rank: a number or a tier name. fn parse_rank(v: &str) -> Option { if let Ok(n) = v.parse::() { - return Some(n.min(6)); + return Some(n); } TIER_NAMES .iter() @@ -47,82 +63,141 @@ fn parse_rank(v: &str) -> Option { .map(|i| RANKS[i]) } -/// Load the `customprefix` overrides once, at boot. +/// Load the `customprefix` config once, at boot. pub fn init(s: &Server) { let mut cfg = PrefixCfg { sigils: DEFAULT_SIGILS.map(String::from), ranktoset: [None; 6], ranktounset: [None; 6], depriv: [true; 6], + custom: Vec::new(), }; for line in s.conf_all("customprefix") { let mut it = line.split_whitespace(); let Some(name) = it.next() else { continue }; - let Some(i) = TIER_NAMES.iter().position(|t| t.eq_ignore_ascii_case(name)) else { - continue; - }; - for tok in it { - match tok.split_once('=') { - Some(("ranktoset", v)) => cfg.ranktoset[i] = parse_rank(v), - Some(("ranktounset", v)) => cfg.ranktounset[i] = parse_rank(v), - Some(("depriv", v)) => cfg.depriv[i] = crate::config::yesish(v), - Some(_) => {} - None => { - // a bare token is the sigil - if let Some(c) = tok.chars().next() { - cfg.sigils[i] = c.to_string(); + if let Some(i) = TIER_NAMES.iter().position(|t| t.eq_ignore_ascii_case(name)) { + // reconfigure a built-in tier + for tok in it { + match tok.split_once('=') { + Some(("ranktoset", v)) => cfg.ranktoset[i] = parse_rank(v), + Some(("ranktounset", v)) => cfg.ranktounset[i] = parse_rank(v), + Some(("depriv", v)) => cfg.depriv[i] = yesish(v), + Some(_) => {} + None => { + if let Some(c) = tok.chars().next() { + cfg.sigils[i] = c.to_string(); + } } } } + } else { + // define a new prefix mode + let (mut letter, mut sigil, mut rank, mut rts, mut rtu, mut depriv) = + (None, None, 1u8, None, None, true); + for tok in it { + match tok.split_once('=') { + Some(("letter", v)) => letter = v.chars().next(), + Some(("prefix", v)) => sigil = v.chars().next(), + Some(("rank", v)) => rank = v.parse().unwrap_or(1), + Some(("ranktoset", v)) => rts = parse_rank(v), + Some(("ranktounset", v)) => rtu = parse_rank(v), + Some(("depriv", v)) => depriv = yesish(v), + _ => {} + } + } + if let (Some(l), Some(sy)) = (letter, sigil) { + // don't shadow a built-in mode letter or a duplicate custom one + let taken = is_builtin_letter(l) + || crate::mode::chan_mode(l).is_some() + || cfg.custom.iter().any(|d| d.letter == l); + if !taken { + let ranktoset = rts.unwrap_or(rank); + cfg.custom.push(PrefixDef { + letter: l, + sigil: sy.to_string(), + rank, + ranktoset, + ranktounset: rtu.unwrap_or(ranktoset), + depriv, + }); + } + } } } let _ = CFG.set(cfg); } -/// The sigil for tier `i` (0 = oper … 5 = voice). +/// The sigil for built-in tier `i` (0 = oper … 5 = voice). pub fn sigil(i: usize) -> &'static str { CFG.get() .map(|c| c.sigils[i].as_str()) .unwrap_or(DEFAULT_SIGILS[i]) } +/// Every config-defined (non-built-in) prefix. +pub fn custom_defs() -> &'static [PrefixDef] { + CFG.get().map(|c| c.custom.as_slice()).unwrap_or(&[]) +} + +/// A custom prefix by its mode letter. +pub fn def_for_letter(c: char) -> Option<&'static PrefixDef> { + custom_defs().iter().find(|d| d.letter == c) +} + /// The prefix mode letter for a sigil char (FJOIN decode); `' '` if none. pub fn letter_for_sigil(c: char) -> char { let cs = c.to_string(); - (0..6) - .find(|&i| sigil(i) == cs) - .map(|i| LETTERS[i]) + if let Some(i) = (0..6).find(|&i| sigil(i) == cs) { + return LETTERS[i]; + } + custom_defs() + .iter() + .find(|d| d.sigil == cs) + .map(|d| d.letter) .unwrap_or(' ') } -fn index_for_letter(letter: char) -> Option { +fn builtin_index(letter: char) -> Option { LETTERS.iter().position(|&l| l == letter) } -/// Configured minimum rank to grant the prefix with mode letter `letter` (None ⇒ -/// use the prefix's own rank). +/// Min rank to grant a prefix (None ⇒ use the prefix's own rank). pub fn rank_to_set(letter: char) -> Option { - let i = index_for_letter(letter)?; - CFG.get()?.ranktoset[i] + if let Some(i) = builtin_index(letter) { + return CFG.get().and_then(|c| c.ranktoset[i]); + } + def_for_letter(letter).map(|d| d.ranktoset) } -/// Configured minimum rank to revoke the prefix (None ⇒ use the prefix's own rank). +/// Min rank to revoke a prefix (None ⇒ use the prefix's own rank). pub fn rank_to_unset(letter: char) -> Option { - let i = index_for_letter(letter)?; - CFG.get()?.ranktounset[i] + if let Some(i) = builtin_index(letter) { + return CFG.get().and_then(|c| c.ranktounset[i]); + } + def_for_letter(letter).map(|d| d.ranktounset) } /// Whether a member may remove this prefix from themselves (default yes). pub fn can_depriv(letter: char) -> bool { - index_for_letter(letter) - .and_then(|i| CFG.get().map(|c| c.depriv[i])) - .unwrap_or(true) + if let Some(i) = builtin_index(letter) { + return CFG.get().map(|c| c.depriv[i]).unwrap_or(true); + } + def_for_letter(letter).map(|d| d.depriv).unwrap_or(true) } -/// The ISUPPORT `PREFIX=(modes)symbols` token; `include_oper` adds the `y` tier. +/// The ISUPPORT `PREFIX=(modes)symbols` token — built-in tiers plus custom prefixes, +/// ordered high→low by rank. `include_oper` adds the `y` (operprefix) tier. pub fn isupport(include_oper: bool) -> String { + let mut all: Vec<(u8, char, &'static str)> = Vec::new(); let start = if include_oper { 0 } else { 1 }; - let letters: String = LETTERS[start..].iter().collect(); - let sigils: String = (start..6).map(sigil).collect(); + for i in start..6 { + all.push((RANKS[i], LETTERS[i], sigil(i))); + } + for d in custom_defs() { + all.push((d.rank, d.letter, d.sigil.as_str())); + } + all.sort_by(|a, b| b.0.cmp(&a.0)); + let letters: String = all.iter().map(|(_, l, _)| *l).collect(); + let sigils: String = all.iter().map(|(_, _, s)| *s).collect(); format!("({letters}){sigils}") }