chanserv: LEVELS grants a capability (op/topic/invite/access) to an access tier and above, additive so it never locks anyone out

This commit is contained in:
Jean Chevronnet 2026-07-18 01:05:58 +00:00
parent 0c3e09ea00
commit d313ca3523
No known key found for this signature in database
10 changed files with 275 additions and 6 deletions

View file

@ -0,0 +1,88 @@
use echo_api::Store;
use echo_api::{AccessRole, LevelCap, Sender, ServiceCtx};
// LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]
// Tune which access tier holds each channel capability (OP, TOPIC, INVITE, ACCESS).
// Additive — it grants a capability to a lower tier, so it can never lock anyone
// out. Founder only. With no sub-command it shows the current matrix.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]");
return;
};
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
None | Some("LIST") | Some("VIEW") => list(me, from, chan, ctx, db),
Some("SET") => set(me, from, chan, &args[3..], ctx, db),
Some("RESET") | Some("DEL") => reset(me, from, chan, args.get(3).copied(), ctx, db),
_ => ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]"),
}
}
fn list(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(info) = db.channel(chan) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Access levels for \x02{}\x02 (capability → minimum tier):", info.name));
for cap in LevelCap::ALL {
let (tier, tag) = match info.levels.iter().find(|(c, _)| *c == cap) {
Some((_, role)) => (*role, " (custom)"),
None => (cap.default_role(), ""),
};
ctx.notice(me, from.uid, format!(" \x02{}\x02{}{}", cap.name(), tier_word(tier), tag));
}
ctx.notice(me, from.uid, "Founder: \x02LEVELS <#chan> SET <capability> <tier>\x02 grants it to that tier and above; \x02RESET\x02 restores the default.");
}
fn set(me: &str, from: &Sender, chan: &str, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_founder(me, from, chan, ctx, db) {
return;
}
let (Some(&cap_s), Some(&tier_s)) = (rest.first(), rest.get(1)) else {
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> SET <capability> <tier>");
return;
};
let Some(cap) = LevelCap::parse(cap_s) else {
ctx.notice(me, from.uid, "Unknown capability. One of: \x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02.");
return;
};
let role = match AccessRole::parse(tier_s) {
Some(AccessRole::Founder) => {
ctx.notice(me, from.uid, "Granting to \x02FOUNDER\x02 is redundant. Pick \x02VOP\x02, \x02HOP\x02, \x02AOP\x02, or \x02SOP\x02.");
return;
}
Some(r) => r,
None => {
ctx.notice(me, from.uid, "Unknown tier. One of: \x02VOP\x02, \x02HOP\x02, \x02AOP\x02, \x02SOP\x02.");
return;
}
};
match db.level_set(chan, cap.name(), tier_word(role)) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 is now held by \x02{}\x02 and above.", cap.name(), tier_word(role))),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn reset(me: &str, from: &Sender, chan: &str, cap_arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_founder(me, from, chan, ctx, db) {
return;
}
let Some(cap_s) = cap_arg else {
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> RESET <capability>");
return;
};
let Some(cap) = LevelCap::parse(cap_s) else {
ctx.notice(me, from.uid, "Unknown capability. One of: \x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02.");
return;
};
match db.level_reset(chan, cap.name()) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 is back to its default tier (\x02{}\x02).", cap.name(), tier_word(cap.default_role()))),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 has no custom level.", cap.name())),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
// The tier word for display / storage (SOP/AOP/HOP/VOP; founder falls back to its label).
fn tier_word(role: AccessRole) -> &'static str {
role.xop_word().unwrap_or("FOUNDER")
}

View file

@ -23,6 +23,7 @@ mod topic;
mod invite;
#[path = "akick.rs"]
mod akick;
mod levels;
#[path = "list.rs"]
mod list;
#[path = "status.rs"]
@ -54,6 +55,7 @@ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | URL [address] | EMAIL [address] | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." },
HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>\x02\nManages the channel access list. The target may be a GroupServ \x02!group\x02; each of its members holding the group's \x02c\x02 flag then inherits the access." },
HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." },
HelpEntry { cmd: "LEVELS", summary: "tune which tier holds each capability", detail: "Syntax: \x02LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]\x02\nGrants a channel capability (\x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02) to an access tier (\x02VOP\x02/\x02HOP\x02/\x02AOP\x02/\x02SOP\x02) and above, e.g. let halfops manage AKICK. Additive — it never removes access. Founder only." },
HelpEntry { cmd: "SOP/AOP/HOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02SOP|AOP|HOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: SOP grants op plus access-list management, AOP grants op, HOP grants halfop, VOP grants voice." },
HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." },
HelpEntry { cmd: "OP/DEOP/VOICE/DEVOICE", summary: "give or take op/voice", detail: "Syntax: \x02OP|DEOP|VOICE|DEVOICE <#channel> [nick]\x02\nGives or takes channel op or voice." },
@ -285,6 +287,7 @@ impl Service for ChanServ {
Some("TOPIC") => topic::handle(me, from, args, ctx, db),
Some("INVITE") => invite::handle(me, from, args, ctx, net, db),
Some("AKICK") => akick::handle(me, from, args, ctx, db),
Some("LEVELS") => levels::handle(me, from, args, ctx, db),
Some("STATUS") => status::handle(me, from, args, ctx, net, db),
Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),