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 <service>
[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.
This commit is contained in:
Jean Chevronnet 2026-07-14 18:37:45 +00:00
parent 69a93198ff
commit fcca8e4e3b
No known key found for this signature in database
17 changed files with 151 additions and 6 deletions

View file

@ -186,9 +186,19 @@ impl Engine {
pub fn new(services: Vec<Box<dyn Service>>, 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(),

View file

@ -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<String, HashMap<String, u32>>,
// 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<String> {
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))
}
}

View file

@ -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::<Vec<_>>()
};
// HELP <service> <command> 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:?}");
}