From fcca8e4e3b09ddfbd3ca7fa23edd4c615c1971f8 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 18:37:45 +0000 Subject: [PATCH] Front the whole network's help through HelpServ Each service now exposes its help catalog via Service::help_topics; the engine collects them into a network-wide index on Network, reachable through two new NetView methods. HelpServ uses it: HELP [command] (or a bare service name) serves any service's per-command help, bare HELP lists the services, and its own ticket commands still work. No cross-crate dependency: the catalog flows through the SDK's NetView, keeping every module dependent only on echo-api. --- api/src/lib.rs | 14 ++++++++++++ modules/botserv/src/lib.rs | 4 ++++ modules/chanfix/src/lib.rs | 4 ++++ modules/chanserv/src/lib.rs | 4 ++++ modules/diceserv/src/lib.rs | 4 ++++ modules/groupserv/src/lib.rs | 4 ++++ modules/helpserv/src/lib.rs | 26 +++++++++++++++++---- modules/hostserv/src/lib.rs | 4 ++++ modules/infoserv/src/lib.rs | 4 ++++ modules/memoserv/src/lib.rs | 4 ++++ modules/nickserv/src/lib.rs | 4 ++++ modules/operserv/src/lib.rs | 4 ++++ modules/reportserv/src/lib.rs | 4 ++++ modules/statserv/src/lib.rs | 4 ++++ src/engine/mod.rs | 12 +++++++++- src/engine/state.rs | 14 +++++++++++- src/engine/tests.rs | 43 +++++++++++++++++++++++++++++++++++ 17 files changed, 151 insertions(+), 6 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 8db2df6..d6ca133 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1053,6 +1053,15 @@ pub trait NetView { // `limit`). An empty pattern returns the most recent; otherwise matches the // id or a case-insensitive substring of the summary. fn search_incidents(&self, pattern: &str, limit: usize) -> Vec; + // The network-wide help index the engine builds from every service, so + // HelpServ can front help for the whole network. `help_services` lists the + // service names; `service_help` returns one service's blurb and topics. + fn help_services(&self) -> Vec { + Vec::new() + } + fn service_help(&self, _service: &str) -> Option<(&'static str, &'static [HelpEntry])> { + None + } } // One recorded action from the incident log: a short id (also stamped into the @@ -1083,6 +1092,11 @@ pub trait Service: Send { fn manages_accounts(&self) -> bool { false } + // This service's HELP catalog: its blurb and per-command topics. The engine + // collects these into a network-wide index HelpServ can front. Empty default. + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + ("", &[]) + } fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store); } diff --git a/modules/botserv/src/lib.rs b/modules/botserv/src/lib.rs index 71c6c40..ec3dd5a 100644 --- a/modules/botserv/src/lib.rs +++ b/modules/botserv/src/lib.rs @@ -68,6 +68,10 @@ impl Service for BotServ { "Bot Services" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/chanfix/src/lib.rs b/modules/chanfix/src/lib.rs index cc87f3e..0c570d0 100644 --- a/modules/chanfix/src/lib.rs +++ b/modules/chanfix/src/lib.rs @@ -43,6 +43,10 @@ impl Service for ChanFix { "Channel Fixer" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); // The whole service is operator-only. diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 823e6b1..082ee18 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -90,6 +90,10 @@ impl Service for ChanServ { true } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/diceserv/src/lib.rs b/modules/diceserv/src/lib.rs index 3c88792..eb10664 100644 --- a/modules/diceserv/src/lib.rs +++ b/modules/diceserv/src/lib.rs @@ -37,6 +37,10 @@ impl Service for DiceServ { "Dice Roller" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, _db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/groupserv/src/lib.rs b/modules/groupserv/src/lib.rs index 4ed2acb..9d25639 100644 --- a/modules/groupserv/src/lib.rs +++ b/modules/groupserv/src/lib.rs @@ -52,6 +52,10 @@ impl Service for GroupServ { "Group Service" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/helpserv/src/lib.rs b/modules/helpserv/src/lib.rs index 76b7c39..2e96657 100644 --- a/modules/helpserv/src/lib.rs +++ b/modules/helpserv/src/lib.rs @@ -24,7 +24,7 @@ mod next; #[path = "close.rs"] mod close; -const BLURB: &str = "HelpServ is the help desk. Open a ticket with \x02REQUEST\x02; operators work the queue."; +const BLURB: &str = "HelpServ is the help desk and the network's help index. Open a ticket with \x02REQUEST\x02; operators work the queue."; const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "REQUEST", summary: "open a help-desk ticket", detail: "Syntax: \x02REQUEST \x02\nOpens a ticket for the staff. Also \x02HELPME\x02." }, @@ -51,7 +51,11 @@ impl Service for HelpServ { "Help Service" } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { Some("REQUEST") | Some("HELPME") => request::handle(me, from, &args[1..], ctx, db), @@ -61,8 +65,22 @@ impl Service for HelpServ { Some("TAKE") | Some("ASSIGN") => take::handle(me, from, args.get(1).copied(), ctx, db), Some("NEXT") => next::handle(me, from, ctx, db), Some("CLOSE") | Some("RESOLVE") => close::handle(me, from, args.get(1).copied(), ctx, db), - Some("HELP") | None => echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()), - Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REQUEST\x02 or \x02HELP\x02.")), + // HELP [service [command]]: bare HELP is HelpServ's own; HELP + // [command] fronts any other service's help through the shared renderer. + Some("HELP") | None => match args.get(1).and_then(|&s| net.service_help(s)) { + Some((blurb, topics)) => echo_api::help(me, from, ctx, blurb, topics, args.get(2).copied()), + None => { + echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()); + if args.get(1).is_none() { + ctx.notice(me, from.uid, format!("For a service's own commands, type \x02HELP \x02. Services: {}.", net.help_services().join(", "))); + } + } + }, + // Not a HelpServ command — maybe the name of a service to get help for. + Some(_) => match net.service_help(args[0]) { + Some((blurb, topics)) => echo_api::help(me, from, ctx, blurb, topics, args.get(1).copied()), + None => ctx.notice(me, from.uid, format!("I don't know \x02{}\x02. Try \x02REQUEST\x02 , \x02HELP\x02, or a service name (e.g. \x02NickServ\x02).", args[0])), + }, } } } diff --git a/modules/hostserv/src/lib.rs b/modules/hostserv/src/lib.rs index 5602234..a8a64aa 100644 --- a/modules/hostserv/src/lib.rs +++ b/modules/hostserv/src/lib.rs @@ -67,6 +67,10 @@ impl Service for HostServ { "Host Services" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/infoserv/src/lib.rs b/modules/infoserv/src/lib.rs index d253a8a..50c38b7 100644 --- a/modules/infoserv/src/lib.rs +++ b/modules/infoserv/src/lib.rs @@ -48,6 +48,10 @@ impl Service for InfoServ { "Information Service" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/memoserv/src/lib.rs b/modules/memoserv/src/lib.rs index 8f189bd..9790f0d 100644 --- a/modules/memoserv/src/lib.rs +++ b/modules/memoserv/src/lib.rs @@ -39,6 +39,10 @@ impl Service for MemoServ { "Memo Services" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); let cmd = args.first().map(|s| s.to_ascii_uppercase()); diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index 35b7bcb..a4fb822 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -83,6 +83,10 @@ impl Service for NickServ { true } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); let cmd = args.first().map(|s| s.to_ascii_uppercase()); diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index d0ff1b7..4480e4a 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -51,6 +51,10 @@ impl Service for OperServ { "Operator Service" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); // Every OperServ command is operator-only: reveal nothing to others. diff --git a/modules/reportserv/src/lib.rs b/modules/reportserv/src/lib.rs index b32746c..0cb6a22 100644 --- a/modules/reportserv/src/lib.rs +++ b/modules/reportserv/src/lib.rs @@ -46,6 +46,10 @@ impl Service for ReportServ { "Report Service" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/statserv/src/lib.rs b/modules/statserv/src/lib.rs index 39c74c7..861ede8 100644 --- a/modules/statserv/src/lib.rs +++ b/modules/statserv/src/lib.rs @@ -32,6 +32,10 @@ impl Service for StatServ { "Statistics Service" } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().copied() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 86ee149..4f75825 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -186,9 +186,19 @@ impl Engine { pub fn new(services: Vec>, db: Db) -> Self { let chan_service = services.iter().find(|s| s.manages_channels()).map(|s| s.uid().to_string()); let nick_service = services.iter().find(|s| s.manages_accounts()).map(|s| s.uid().to_string()); + // Network-wide help index: every service that publishes topics, so HelpServ + // can front help for the whole network through NetView. + let mut network = Network::default(); + network.help_catalog = services + .iter() + .filter_map(|s| { + let (blurb, topics) = s.help_topics(); + (!topics.is_empty()).then(|| (s.nick().to_string(), blurb, topics)) + }) + .collect(); Self { services, - network: Network::default(), + network, db, sasl_sessions: HashMap::new(), reg_limiter: RegLimiter::new(), diff --git a/src/engine/state.rs b/src/engine/state.rs index 538c2e4..a0ca51f 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; // The read-only network view a module sees; re-exported so the engine keeps // naming it locally. -pub use echo_api::{IncidentView, NetView, SeenView}; +pub use echo_api::{HelpEntry, IncidentView, NetView, SeenView}; // The most recent moderation/action incidents kept for LOGSEARCH. const INCIDENT_CAP: usize = 10_000; @@ -31,6 +31,9 @@ pub struct Network { // ChanFix op-time scores: channel (lowercase) -> identity (account or host) -> // score. Sampled from live op state; ephemeral (node-local, rebuilt over time). chanfix: HashMap>, + // Network-wide help index (service nick, blurb, per-command topics), built by + // the engine at startup from every service so HelpServ can front it. + pub help_catalog: Vec<(String, &'static str, &'static [HelpEntry])>, } // ChanFix scoring caps and rates. @@ -417,4 +420,13 @@ impl NetView for Network { fn op_count(&self, channel: &str) -> usize { Network::op_count(self, channel) } + fn help_services(&self) -> Vec { + self.help_catalog.iter().map(|(n, _, _)| n.clone()).collect() + } + fn service_help(&self, service: &str) -> Option<(&'static str, &'static [HelpEntry])> { + self.help_catalog + .iter() + .find(|(n, _, _)| n.eq_ignore_ascii_case(service)) + .map(|(_, b, t)| (*b, *t)) + } } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index f8a3725..50a4c58 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -3460,3 +3460,46 @@ let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into(), op: false }); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#y" && modes == "+v 000AAAAAB")), "{out:?}"); } + +// HelpServ fronts the whole network's help: it serves any service's per-command +// help, lists the services, and still answers for its own commands. +#[test] +fn helpserv_fronts_the_network_help_index() { + let path = std::env::temp_dir().join("echo-helpserv-index.jsonl"); + let _ = std::fs::remove_file(&path); + let db = Db::open(&path, "test"); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(echo_helpserv::HelpServ { uid: "42SAAAAAN".into() }), + ], + db, + ); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "h".into(), ip: "0.0.0.0".into() }); + + let ask = |e: &mut Engine, text: &str| { + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAN".into(), text: text.into() }) + .into_iter() + .filter_map(|a| match a { + NetAction::Notice { text, .. } => Some(text), + _ => None, + }) + .collect::>() + }; + + // HELP fronts that service's per-command help. + let reg = ask(&mut e, "HELP NickServ REGISTER"); + assert!(reg.iter().any(|l| l.contains("Syntax:") && l.contains("REGISTER")), "{reg:?}"); + + // Bare HELP lists the services on the network. + let idx = ask(&mut e, "HELP"); + assert!(idx.iter().any(|l| l.contains("Services:") && l.contains("NickServ")), "{idx:?}"); + + // A bare service name works too. + let ns = ask(&mut e, "NickServ"); + assert!(ns.iter().any(|l| l.contains("IDENTIFY")), "{ns:?}"); + + // HelpServ's own commands still resolve. + let own = ask(&mut e, "HELP REQUEST"); + assert!(own.iter().any(|l| l.contains("Syntax:") && l.contains("REQUEST")), "{own:?}"); +}