whois: render the oper-type title line in bold + colour (per-type color=<name|0-15|none>, default red) so it stands out

This commit is contained in:
Jean Chevronnet 2026-08-20 14:26:17 +00:00
parent bc520d59a5
commit 2c9ce18cd7
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
2 changed files with 57 additions and 17 deletions

View file

@ -308,16 +308,11 @@ impl Command for Whois {
&format!("{nick} :is an IRC Operator"), &format!("{nick} :is an IRC Operator"),
); );
} }
// 320: the oper type's title on its own line, e.g. "is a Network // 320: the oper type's title on its own line (bold + its colour), e.g. "is a
// Administrator" — from the oper's type (hidden with +H like the 313 line). // Network Administrator" — from the oper's type (hidden with +H like 313).
if oper && (!hideoper || asker_oper) { if oper && (!hideoper || asker_oper) {
if let Some(title) = crate::modules::opertypes::title_of(s, tuid) { if let Some(line) = crate::modules::opertypes::whois_line(s, tuid) {
let article = if title.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
"an"
} else {
"a"
};
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":is {article} {title}"));
} }
} }
// 320: oper-set SWHOIS line. No redundant target-nick param — just the // 320: oper-set SWHOIS line. No redundant target-nick param — just the

View file

@ -23,6 +23,7 @@ use crate::Uid;
/// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title. /// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title.
pub struct OperType { pub struct OperType {
pub title: String, pub title: String,
pub color: Option<u8>, // mIRC colour for the WHOIS title line (None = plain)
pub all_commands: bool, pub all_commands: bool,
pub commands: HashSet<String>, pub commands: HashSet<String>,
pub all_privs: bool, pub all_privs: bool,
@ -71,11 +72,28 @@ impl Module for OperTypes {
} }
} }
/// The WHOIS title of a typed oper, if any (read by core_info's 313). /// The WHOIS title of a typed oper, if any (used in the denial message).
pub fn title_of(s: &Server, uid: Uid) -> Option<String> { pub fn title_of(s: &Server, uid: Uid) -> Option<String> {
s.users.get(&uid).and_then(|u| u.ext.get::<OperType>()).map(|t| t.title.clone()) s.users.get(&uid).and_then(|u| u.ext.get::<OperType>()).map(|t| t.title.clone())
} }
/// The formatted WHOIS special line for a typed oper, if any — "is a/an <title>",
/// bold + the type's colour (mIRC code, e.g. 4 = red) so it stands out. core_info
/// emits it on its own 320 line.
pub fn whois_line(s: &Server, uid: Uid) -> Option<String> {
let t = s.users.get(&uid).and_then(|u| u.ext.get::<OperType>())?;
let article = if t.title.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) {
"an"
} else {
"a"
};
let body = format!("is {article} {}", t.title);
Some(match t.color {
Some(c) => format!("\x02\x03{c:02}{body}\x0f"), // bold + colour, reset after
None => body,
})
}
/// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then /// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then
/// store the grant + title. A missing type (or an unknown id) leaves the oper with /// store the grant + title. A missing type (or an unknown id) leaves the oper with
/// full access, so `oper` blocks without `type=` keep working. /// full access, so `oper` blocks without `type=` keep working.
@ -104,6 +122,7 @@ pub fn apply(s: &mut Server, uid: Uid, type_id: Option<&str>) {
if let Some(u) = s.users.get_mut(&uid) { if let Some(u) = s.users.get_mut(&uid) {
u.ext.set(OperType { u.ext.set(OperType {
title: r.title.clone(), title: r.title.clone(),
color: r.color,
all_commands: r.all_commands, all_commands: r.all_commands,
commands: r.commands.clone(), commands: r.commands.clone(),
all_privs: r.all_privs, all_privs: r.all_privs,
@ -125,6 +144,7 @@ fn set_snomask(s: &mut Server, uid: Uid, letters: &str) {
#[derive(Clone)] #[derive(Clone)]
struct Resolved { struct Resolved {
title: String, title: String,
color: Option<u8>,
all_commands: bool, all_commands: bool,
commands: HashSet<String>, commands: HashSet<String>,
all_privs: bool, all_privs: bool,
@ -175,6 +195,7 @@ struct TypeDef {
snomasks: String, snomasks: String,
vhost: Option<String>, vhost: Option<String>,
level: Option<u32>, level: Option<u32>,
color: Option<u8>,
} }
fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef { fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef {
@ -186,7 +207,8 @@ fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef {
} }
} }
fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str, all_sno: bool, level: u32) -> TypeDef { #[allow(clippy::too_many_arguments)]
fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str, all_sno: bool, level: u32, color: Option<u8>) -> TypeDef {
TypeDef { TypeDef {
title: title.to_string(), title: title.to_string(),
all_classes, all_classes,
@ -195,6 +217,7 @@ fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str
snomasks: sno.to_string(), snomasks: sno.to_string(),
all_snomasks: all_sno, all_snomasks: all_sno,
level: Some(level), level: Some(level),
color,
..Default::default() ..Default::default()
} }
} }
@ -210,12 +233,15 @@ fn builtin() -> (HashMap<String, ClassDef>, HashMap<String, TypeDef>) {
classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr")); classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr"));
let mut types: HashMap<String, TypeDef> = HashMap::default(); let mut types: HashMap<String, TypeDef> = HashMap::default();
// title classes all modes sno all* level // The WHOIS title line is bold + colour 4 (red) by default; override per type
types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10)); // with `color=<name|0-15|none>`.
types.insert("globop".into(), tdef("GlobOp", &["announce"], false, "+iw", "acgoq", false, 20)); let red = Some(4);
types.insert("admin".into(), tdef("Administrator", &["announce", "ban", "override", "host"], false, "+iw", "", true, 50)); // title classes all modes sno all* level color
types.insert("servadmin".into(), tdef("Services Administrator", &["announce", "ban", "override", "host", "services"], false, "+iw", "", true, 70)); types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10, red));
types.insert("netadmin".into(), tdef("Network Administrator", &[], true, "+iw", "", true, 100)); types.insert("globop".into(), tdef("GlobOp", &["announce"], false, "+iw", "acgoq", false, 20, red));
types.insert("admin".into(), tdef("Administrator", &["announce", "ban", "override", "host"], false, "+iw", "", true, 50, red));
types.insert("servadmin".into(), tdef("Services Administrator", &["announce", "ban", "override", "host", "services"], false, "+iw", "", true, 70, red));
types.insert("netadmin".into(), tdef("Network Administrator", &[], true, "+iw", "", true, 100, red));
(classes, types) (classes, types)
} }
@ -314,6 +340,24 @@ fn apply_type_kv(td: &mut TypeDef, k: &str, v: &str) {
td.level = Some(l); td.level = Some(l);
} }
} }
"color" | "colour" => {
td.color = match v.to_ascii_lowercase().as_str() {
"none" | "off" | "no" | "plain" => None,
"white" => Some(0),
"black" => Some(1),
"blue" => Some(2),
"green" => Some(3),
"red" => Some(4),
"brown" => Some(5),
"magenta" | "purple" => Some(6),
"orange" => Some(7),
"yellow" => Some(8),
"cyan" | "teal" => Some(10),
"pink" => Some(13),
"grey" | "gray" => Some(14),
n => n.parse::<u8>().ok().filter(|c| *c <= 15).or(td.color),
};
}
_ => {} // maxchans etc.: accepted, not yet enforced _ => {} // maxchans etc.: accepted, not yet enforced
} }
} }
@ -356,6 +400,7 @@ fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved {
Resolved { Resolved {
title: td.title.clone(), title: td.title.clone(),
color: td.color,
all_commands, all_commands,
commands, commands,
all_privs, all_privs,