customprefix: data-driven prefix engine — define arbitrary new prefix modes (letter/prefix/rank/ranktoset/ranktounset/depriv), ranks re-spaced x10; built-in tiers + defaults unchanged

This commit is contained in:
Jean Chevronnet 2026-08-11 19:46:07 +00:00
parent d35a2071a6
commit b40c523b87
5 changed files with 256 additions and 102 deletions

View file

@ -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=<rank>` min rank to grant this prefix (default: the prefix's rank)
//! * `ranktounset=<rank>` min rank to revoke it (default: ranktoset)
//! * `depriv=no` members may not remove this prefix from themselves
//!
//! A `<rank>` is a number (16) 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 `<rank>` 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<u8>; 6],
ranktounset: [Option<u8>; 6],
depriv: [bool; 6],
custom: Vec<PrefixDef>,
}
static CFG: OnceLock<PrefixCfg> = OnceLock::new();
/// Parse a rank: a number (16) 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<u8> {
if let Ok(n) = v.parse::<u8>() {
return Some(n.min(6));
return Some(n);
}
TIER_NAMES
.iter()
@ -47,82 +63,141 @@ fn parse_rank(v: &str) -> Option<u8> {
.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<usize> {
fn builtin_index(letter: char) -> Option<usize> {
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<u8> {
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<u8> {
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}")
}