chanserv: all op-and-above tiers grant +o for a single @ prefix; tier distinction stays in the access list
All checks were successful
CI / check (push) Successful in 4m12s

This commit is contained in:
Jean Chevronnet 2026-07-17 15:58:11 +00:00
parent 2f6fd41465
commit b5aef650f2
No known key found for this signature in database
3 changed files with 28 additions and 31 deletions

View file

@ -632,7 +632,7 @@ pub const ACCESS_FLAGS: &str = "foOhvtiasg";
// The capabilities an access `level` confers (founder is layered on by callers).
#[derive(Debug, Clone, Copy, Default)]
pub struct Caps {
pub auto: Option<&'static str>, // auto status mode on join: +qo / +ao / +o / +h / +v
pub auto: Option<&'static str>, // auto status mode on join: +o / +h / +v
pub op: bool, // moderation: kick / ban / op / mode / akick
pub topic: bool,
pub invite: bool,
@ -647,12 +647,12 @@ pub struct Caps {
// entries and new flag entries coexist.
pub fn level_caps(level: &str) -> Caps {
match level {
"founder" => Caps { auto: Some("+qo"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 },
// The XOP tiers, ordered high to low. Each op-and-above tier carries op
// (+o, the @ prefix) plus its rank marker: founder owner (+q, ~), sop
// protect (+a, &). So AOP and above all show @, while sop outranks aop and
// founder outranks sop. SOP additionally holds access-list management.
"sop" => Caps { auto: Some("+ao"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() },
"founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 },
// The XOP tiers, ordered high to low. Every op-and-above tier takes the op
// mode (+o), so the whole network keeps a single @ prefix — the tiers are
// told apart by capability, not a channel symbol: SOP adds access-list
// management (the `access` cap) over AOP, founder adds `set`.
"sop" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() },
"op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() },
"halfop" => Caps { auto: Some("+h"), rank: 1, ..Caps::default() },
"voice" => Caps { auto: Some("+v"), rank: 1, ..Caps::default() },
@ -1689,26 +1689,23 @@ mod tests {
assert!(t.topic && !t.op && t.auto.is_none());
assert!(level_caps("a").access && level_caps("f").set);
// Each op-and-above tier carries op (+o) plus its rank marker, so all show
// @: founder +qo (~@), SOP +ao (&@), AOP +o (@), HOP +h.
// Every op-and-above tier resolves to +o (a single @ prefix). The tiers are
// distinguished by capability, not the mode: SOP has `access`, AOP doesn't.
assert!(level_caps("sop").op && level_caps("sop").access);
assert_eq!(level_caps("founder").auto, Some("+qo"));
assert_eq!(level_caps("sop").auto, Some("+ao"), "SOP is op + protect");
assert_eq!(level_caps("founder").auto, Some("+o"));
assert_eq!(level_caps("sop").auto, Some("+o"));
assert_eq!(level_caps("op").auto, Some("+o"));
assert!(level_caps("op").op && !level_caps("op").access);
assert_eq!(level_caps("halfop").auto, Some("+h"));
// Combined, the stronger auto mode wins: SOP (+ao) over a voice group (+v),
// and founder (+qo) over everything.
assert_eq!(level_caps("sop").union(level_caps("voice")).auto, Some("+ao"));
assert_eq!(level_caps("founder").union(level_caps("sop")).auto, Some("+qo"));
// Combined, the stronger auto mode wins: op (+o) over a voice group (+v).
assert_eq!(level_caps("sop").union(level_caps("voice")).auto, Some("+o"));
}
#[test]
fn status_mode_repeats_the_target_per_letter() {
assert_eq!(status_mode("+o", "X"), "+o X");
assert_eq!(status_mode("+ao", "X"), "+ao X X", "protect + op each take the nick");
// DOWN strips the whole ladder, so the target repeats once per letter.
// Multi-letter modes (e.g. the DOWN strip) repeat the target per letter.
assert_eq!(status_mode("-qaohv", "X"), "-qaohv X X X X X");
}