From 2c9ce18cd7bc146e8a4e9b3b1a4a66f67ed22a6a Mon Sep 17 00:00:00 2001 From: reverse Date: Thu, 20 Aug 2026 14:26:17 +0000 Subject: [PATCH] whois: render the oper-type title line in bold + colour (per-type color=, default red) so it stands out --- src/coremods/core_info.rs | 13 +++------ src/modules/opertypes.rs | 61 ++++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 00b8191..0a4478c 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -308,16 +308,11 @@ impl Command for Whois { &format!("{nick} :is an IRC Operator"), ); } - // 320: the oper type's title on its own line, e.g. "is a Network - // Administrator" — from the oper's type (hidden with +H like the 313 line). + // 320: the oper type's title on its own line (bold + its colour), e.g. "is a + // Network Administrator" — from the oper's type (hidden with +H like 313). if oper && (!hideoper || asker_oper) { - if let Some(title) = crate::modules::opertypes::title_of(s, tuid) { - let article = if title.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) { - "an" - } else { - "a" - }; - s.numeric(uid, RPL_WHOISSPECIAL, &format!(":is {article} {title}")); + if let Some(line) = crate::modules::opertypes::whois_line(s, tuid) { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } } // 320: oper-set SWHOIS line. No redundant target-nick param — just the diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs index 1d2c106..77e3667 100644 --- a/src/modules/opertypes.rs +++ b/src/modules/opertypes.rs @@ -23,6 +23,7 @@ use crate::Uid; /// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title. pub struct OperType { pub title: String, + pub color: Option, // mIRC colour for the WHOIS title line (None = plain) pub all_commands: bool, pub commands: HashSet, 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 { s.users.get(&uid).and_then(|u| u.ext.get::()).map(|t| t.title.clone()) } +/// The formatted WHOIS special line for a typed oper, if any — "is a/an ", +/// 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 /// store the grant + title. A missing type (or an unknown id) leaves the oper with /// 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) { u.ext.set(OperType { title: r.title.clone(), + color: r.color, all_commands: r.all_commands, commands: r.commands.clone(), all_privs: r.all_privs, @@ -125,6 +144,7 @@ fn set_snomask(s: &mut Server, uid: Uid, letters: &str) { #[derive(Clone)] struct Resolved { title: String, + color: Option<u8>, all_commands: bool, commands: HashSet<String>, all_privs: bool, @@ -175,6 +195,7 @@ struct TypeDef { snomasks: String, vhost: Option<String>, level: Option<u32>, + color: Option<u8>, } 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 { title: title.to_string(), all_classes, @@ -195,6 +217,7 @@ fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str snomasks: sno.to_string(), all_snomasks: all_sno, level: Some(level), + color, ..Default::default() } } @@ -210,12 +233,15 @@ fn builtin() -> (HashMap<String, ClassDef>, HashMap<String, TypeDef>) { classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr")); let mut types: HashMap<String, TypeDef> = HashMap::default(); - // title classes all modes sno all* level - types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10)); - types.insert("globop".into(), tdef("GlobOp", &["announce"], false, "+iw", "acgoq", false, 20)); - types.insert("admin".into(), tdef("Administrator", &["announce", "ban", "override", "host"], false, "+iw", "", true, 50)); - types.insert("servadmin".into(), tdef("Services Administrator", &["announce", "ban", "override", "host", "services"], false, "+iw", "", true, 70)); - types.insert("netadmin".into(), tdef("Network Administrator", &[], true, "+iw", "", true, 100)); + // The WHOIS title line is bold + colour 4 (red) by default; override per type + // with `color=<name|0-15|none>`. + let red = Some(4); + // title classes all modes sno all* level color + types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10, red)); + 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) } @@ -314,6 +340,24 @@ fn apply_type_kv(td: &mut TypeDef, k: &str, v: &str) { 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 } } @@ -356,6 +400,7 @@ fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved { Resolved { title: td.title.clone(), + color: td.color, all_commands, commands, all_privs,