Add InfoServ, unifying network bulletins onto the news store

InfoServ becomes the dedicated home for network information bulletins,
backed by the same event-sourced news store OperServ NEWS used — one place
to post, not two. POST/LIST/DEL manage public bulletins (shown to every
user on connect); OPOST/OLIST/ODEL the oper ones (shown to operators on
login). Anyone may LIST the public bulletins; posting/removing is admin-
only. The engine's existing connect/login display hooks render them
unchanged. The OperServ NEWS command is retired accordingly (the news
events and the display path stay). Default service.
This commit is contained in:
Jean Chevronnet 2026-07-14 03:56:09 +00:00
parent 1e8a5eab36
commit 8d495a948e
No known key found for this signature in database
9 changed files with 152 additions and 101 deletions

View file

@ -133,7 +133,7 @@ impl Default for Modules {
}
fn default_services() -> Vec<String> {
vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string(), "operserv".to_string()]
vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string(), "operserv".to_string(), "infoserv".to_string()]
}
#[derive(Debug, Deserialize, Clone)]

View file

@ -4565,12 +4565,12 @@ mod tests {
assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing");
}
// OperServ NEWS: logon news greets everyone on connect, oper news greets an
// operator on login; ADD/DEL/LIST are admin-only.
// InfoServ bulletins over the shared news store: public bulletins greet
// everyone on connect, oper bulletins greet an operator on login.
#[test]
fn operserv_news_logon_and_oper() {
use fedserv_operserv::OperServ;
let path = std::env::temp_dir().join("fedserv-osnews.jsonl");
fn infoserv_bulletins_public_and_oper() {
use fedserv_infoserv::InfoServ;
let path = std::env::temp_dir().join("fedserv-infoserv.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
@ -4580,7 +4580,7 @@ mod tests {
let mut e = Engine::new(
vec![
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
Box::new(OperServ { uid: "42SAAAAAH".into() }),
Box::new(InfoServ { uid: "42SAAAAAJ".into() }),
],
db,
);
@ -4591,42 +4591,42 @@ mod tests {
e.set_opers(opers);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
e.set_irc_out(tx);
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
let is = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAJ".into(), text: t.into() });
let has = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
os(&mut e, "000AAAAAS", "NEWS ADD LOGON Welcome to the network");
os(&mut e, "000AAAAAS", "NEWS ADD OPER Staff meeting at 5");
is(&mut e, "000AAAAAS", "POST Welcome to the network");
is(&mut e, "000AAAAAS", "OPOST Staff meeting at 5");
// A new user is greeted with the logon news on connect, not the oper news.
// A new user is greeted with the public bulletin on connect, not the oper one.
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() , ip: "0.0.0.0".into() });
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "logon news on connect: {out:?}");
assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper news");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "public bulletin on connect: {out:?}");
assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper bulletins");
// An operator logging in is shown the oper news (over the outbound path).
// An operator logging in is shown the oper bulletin (over the outbound path).
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
while rx.try_recv().is_ok() {} // drain the connect's logon news
while rx.try_recv().is_ok() {} // drain the connect's public bulletin
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
let mut oper_news = false;
let mut oper_bulletin = false;
while let Ok(a) = rx.try_recv() {
if let NetAction::Notice { to, text, .. } = a {
if to == "000AAAAAB" && text.contains("Staff meeting at 5") {
oper_news = true;
oper_bulletin = true;
}
}
}
assert!(oper_news, "oper news shown to an operator on login");
assert!(oper_bulletin, "oper bulletin shown to an operator on login");
// LIST shows both; DEL removes the logon item so a later connect is quiet.
assert!(has(&os(&mut e, "000AAAAAS", "NEWS LIST"), "Welcome to the network"), "list shows logon");
os(&mut e, "000AAAAAS", "NEWS DEL LOGON 1");
// LIST shows the public one (to anyone); DEL removes it so a later connect is quiet.
assert!(has(&is(&mut e, "000AAAAAP", "LIST"), "Welcome to the network"), "anyone can list public bulletins");
is(&mut e, "000AAAAAS", "DEL 1");
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() , ip: "0.0.0.0".into() });
assert!(!has(&out, "Welcome to the network"), "logon news gone after DEL");
assert!(!has(&out, "Welcome to the network"), "bulletin gone after DEL");
// A non-admin (the unidentified plain user) can't manage news.
assert!(has(&os(&mut e, "000AAAAAP", "NEWS ADD LOGON sneaky"), "Access denied"), "non-admin refused");
// A non-admin can't post.
assert!(has(&is(&mut e, "000AAAAAP", "POST sneaky"), "Access denied"), "non-admin refused");
}
// OperServ INFO staff notes: an admin annotates an account/channel; the note

View file

@ -22,6 +22,7 @@ use fedserv_statserv::StatServ;
use fedserv_hostserv::HostServ;
use fedserv_operserv::OperServ;
use fedserv_diceserv::DiceServ;
use fedserv_infoserv::InfoServ;
use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ;
@ -95,6 +96,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAI", cfg.server.sid),
}));
}
if enabled("infoserv") {
services.push(Box::new(InfoServ {
uid: format!("{}AAAAAJ", cfg.server.sid),
}));
}
if enabled("example") {
services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid),