Promote BotServ BOTSTATS to a StatServ pseudo-service

Stats now have their own service. StatServ answers /msg StatServ <#channel>
(per-channel activity — lines + top talkers, founder-or-admin) and
/msg StatServ SERVER (the shared cross-service counter registry + gauges,
operators only). The shared counter map moved from the engine onto the
network view so both StatServ (via NetView::stat_counters) and the gRPC
Stats API read one source. BotServ's BOTSTATS command is removed; the
per-channel activity is still recorded in the dispatch loop.
This commit is contained in:
Jean Chevronnet 2026-07-13 20:04:03 +00:00
parent ad61addb84
commit 04f927d3db
No known key found for this signature in database
12 changed files with 168 additions and 28 deletions

8
Cargo.lock generated
View file

@ -291,6 +291,7 @@ dependencies = [
"fedserv-inspircd", "fedserv-inspircd",
"fedserv-memoserv", "fedserv-memoserv",
"fedserv-nickserv", "fedserv-nickserv",
"fedserv-statserv",
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"prost", "prost",
@ -356,6 +357,13 @@ dependencies = [
"fedserv-api", "fedserv-api",
] ]
[[package]]
name = "fedserv-statserv"
version = "0.0.1"
dependencies = [
"fedserv-api",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv"] members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv"]
[package] [package]
name = "fedserv" name = "fedserv"
@ -15,6 +15,7 @@ fedserv-nickserv = { path = "nickserv" }
fedserv-example = { path = "example" } fedserv-example = { path = "example" }
fedserv-botserv = { path = "botserv" } fedserv-botserv = { path = "botserv" }
fedserv-memoserv = { path = "memoserv" } fedserv-memoserv = { path = "memoserv" }
fedserv-statserv = { path = "statserv" }
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -646,6 +646,8 @@ pub trait NetView {
fn last_seen(&self, nick: &str) -> Option<SeenView>; fn last_seen(&self, nick: &str) -> Option<SeenView>;
// BOTSTATS: (lines seen this session, top talkers by count desc). // BOTSTATS: (lines seen this session, top talkers by count desc).
fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>; fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>;
// The shared stat counters (namespaced key -> count), for StatServ.
fn stat_counters(&self) -> Vec<(String, u64)>;
} }
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the // A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the

View file

@ -22,8 +22,6 @@ mod badwords;
mod copy; mod copy;
#[path = "trigger.rs"] #[path = "trigger.rs"]
mod trigger; mod trigger;
#[path = "stats.rs"]
mod stats;
// Shared gate: the sender may administer <chan>'s bot options only as its // Shared gate: the sender may administer <chan>'s bot options only as its
// founder or a services admin. Notices and returns false on failure. // founder or a services admin. Notices and returns false on failure.
@ -68,7 +66,6 @@ impl Service for BotServ {
Some("BADWORDS") => badwords::handle(me, from, args, ctx, db), Some("BADWORDS") => badwords::handle(me, from, args, ctx, db),
Some("COPY") => copy::handle(me, from, args, ctx, db), Some("COPY") => copy::handle(me, from, args, ctx, db),
Some("TRIGGER") => trigger::handle(me, from, args, ctx, db), Some("TRIGGER") => trigger::handle(me, from, args, ctx, db),
Some("BOTSTATS") | Some("STATS") => stats::handle(me, from, args, ctx, net, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers (with \x02TEST\x02/\x02WARN\x02/\x02TTB\x02), \x02BADWORDS\x02 and \x02TRIGGER\x02 <#channel> ADD|DEL|LIST manage badword regexes and auto-responses, \x02COPY\x02 <#src> <#dst> clones settings. Operators also have \x02BOT\x02 ADD|CHANGE|DEL|LIST."), Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers (with \x02TEST\x02/\x02WARN\x02/\x02TTB\x02), \x02BADWORDS\x02 and \x02TRIGGER\x02 <#channel> ADD|DEL|LIST manage badword regexes and auto-responses, \x02COPY\x02 <#src> <#dst> clones settings. Operators also have \x02BOT\x02 ADD|CHANGE|DEL|LIST."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
} }

View file

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

View file

@ -118,10 +118,6 @@ pub struct Engine {
trigger_cache: HashMap<String, CachedTriggers>, trigger_cache: HashMap<String, CachedTriggers>,
// Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event. // Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event.
pending_unbans: Vec<PendingUnban>, pending_unbans: Vec<PendingUnban>,
// Shared, namespaced stat counters any service contributes to (via
// ServiceCtx::count) plus engine-internal events; exposed over the gRPC
// Stats API. Ordered so a snapshot is stable.
stats: std::collections::BTreeMap<String, u64>,
// In-flight community !votekick/!voteban tallies, keyed by (channel_lc, // In-flight community !votekick/!voteban tallies, keyed by (channel_lc,
// target_nick_lc). Ephemeral; expire after VOTE_TTL. // target_nick_lc). Ephemeral; expire after VOTE_TTL.
votes: HashMap<(String, String), VoteState>, votes: HashMap<(String, String), VoteState>,
@ -195,20 +191,20 @@ impl Engine {
badword_cache: HashMap::new(), badword_cache: HashMap::new(),
trigger_cache: HashMap::new(), trigger_cache: HashMap::new(),
pending_unbans: Vec::new(), pending_unbans: Vec::new(),
stats: std::collections::BTreeMap::new(),
votes: HashMap::new(), votes: HashMap::new(),
} }
} }
// Increment a shared stat counter. // Increment a shared stat counter (held on the network view, so services and
// the gRPC API read from one place).
pub fn bump(&mut self, key: &str) { pub fn bump(&mut self, key: &str) {
*self.stats.entry(key.to_string()).or_insert(0) += 1; self.network.bump(key);
} }
// The shared counters plus live gauges derived from the store, for the gRPC // The shared counters plus live gauges derived from the store, for the gRPC
// Stats API. Any service's counters ride in here alongside these. // Stats API. Any service's counters ride in here alongside these.
pub fn stats_snapshot(&self) -> std::collections::BTreeMap<String, u64> { pub fn stats_snapshot(&self) -> std::collections::BTreeMap<String, u64> {
let mut m = self.stats.clone(); let mut m: std::collections::BTreeMap<String, u64> = self.network.stat_counters().clone();
m.insert("accounts.total".to_string(), self.db.accounts().count() as u64); m.insert("accounts.total".to_string(), self.db.accounts().count() as u64);
m.insert("channels.total".to_string(), self.db.channels().count() as u64); m.insert("channels.total".to_string(), self.db.channels().count() as u64);
m.insert("bots.total".to_string(), self.db.bots().count() as u64); m.insert("bots.total".to_string(), self.db.bots().count() as u64);
@ -1019,7 +1015,7 @@ impl Engine {
} }
} }
} }
// BOTSTATS: count activity in channels that have a bot. // Record activity in bot channels (surfaced by StatServ).
if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) { if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) {
self.network.record_line(to, &nick); self.network.record_line(to, &nick);
self.bump("botserv.messages"); self.bump("botserv.messages");
@ -2637,18 +2633,51 @@ mod tests {
assert!(kicked(&vote(&mut e, "000AAAAAC", "!votekick victim")), "2 distinct votes: kicked"); assert!(kicked(&vote(&mut e, "000AAAAAC", "!votekick victim")), "2 distinct votes: kicked");
} }
// BOTSTATS reports per-channel activity the bot has seen this session. // StatServ reports per-channel activity (#channel) and the global registry
// (SERVER, operators only).
#[test] #[test]
fn botserv_botstats_reports_activity() { fn statserv_reports_channel_and_global() {
let (mut e, _p) = kicker_fixture("bsbotstats"); use fedserv_botserv::BotServ;
use fedserv_nickserv::NickServ;
use fedserv_statserv::StatServ;
let path = std::env::temp_dir().join("fedserv-statserv.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.register("boss", "password1", None).unwrap();
db.register_channel("#c", "boss").unwrap();
let mut e = Engine::new(
vec![
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
Box::new(BotServ { uid: "42SAAAAAD".into() }),
Box::new(StatServ { uid: "42SAAAAAF".into() }),
],
db,
);
e.set_sid("42S".into());
let mut opers = std::collections::HashMap::new();
opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin).with(fedserv_api::Priv::Auspex));
e.set_opers(opers);
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
let ss = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAF".into(), text: t.into() });
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
bs(&mut e, "ASSIGN #c Bendy");
say(&mut e, "one"); say(&mut e, "one");
say(&mut e, "two"); say(&mut e, "two");
say(&mut e, "three"); say(&mut e, "three");
let out = bs(&mut e, "BOTSTATS #c");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("line(s) seen this session"))), "line count: {out:?}"); // Per-channel view.
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker listed: {out:?}"); let out = ss(&mut e, "#c");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("line(s) seen this session"))), "channel lines: {out:?}");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker: {out:?}");
// Global view (oper) shows the shared counters.
let out = ss(&mut e, "SERVER");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("botserv.messages"))), "global counters: {out:?}");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("channels.total"))), "gauge shown");
} }
// The shared stats registry gathers per-service command counts, BotServ // The shared stats registry gathers per-service command counts, BotServ

View file

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
// The read-only network view a module sees; re-exported so the engine keeps // The read-only network view a module sees; re-exported so the engine keeps
@ -17,6 +17,9 @@ pub struct Network {
// `users` so a reconnect can clear them wholesale without disturbing the // `users` so a reconnect can clear them wholesale without disturbing the
// uplink-sourced user map. // uplink-sourced user map.
bots: HashMap<String, String>, bots: HashMap<String, String>,
// Shared, namespaced stat counters any service contributes to (ephemeral,
// ordered for a stable snapshot). Read by StatServ and the gRPC Stats API.
stats: BTreeMap<String, u64>,
} }
pub struct User { pub struct User {
@ -196,6 +199,16 @@ impl Network {
} }
} }
// Increment a shared stat counter.
pub fn bump(&mut self, key: &str) {
*self.stats.entry(key.to_string()).or_insert(0) += 1;
}
// The raw shared counters (sorted). Gauges are merged in by the reader.
pub fn stat_counters(&self) -> &BTreeMap<String, u64> {
&self.stats
}
// BOTSTATS view: (total lines, top talkers by count, descending). // BOTSTATS view: (total lines, top talkers by count, descending).
pub fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { pub fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> {
let c = self.channels.get(&lc(channel))?; let c = self.channels.get(&lc(channel))?;
@ -260,4 +273,7 @@ impl NetView for Network {
fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> {
Network::channel_activity(self, channel) Network::channel_activity(self, channel)
} }
fn stat_counters(&self) -> Vec<(String, u64)> {
Network::stat_counters(self).iter().map(|(k, v)| (k.clone(), *v)).collect()
}
} }

View file

@ -17,6 +17,7 @@ use engine::Engine;
use fedserv_botserv::BotServ; use fedserv_botserv::BotServ;
use fedserv_chanserv::ChanServ; use fedserv_chanserv::ChanServ;
use fedserv_memoserv::MemoServ; use fedserv_memoserv::MemoServ;
use fedserv_statserv::StatServ;
use fedserv_example::ExampleServ; use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd; use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ; use fedserv_nickserv::NickServ;
@ -69,6 +70,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAE", cfg.server.sid), uid: format!("{}AAAAAE", cfg.server.sid),
})); }));
} }
if enabled("statserv") {
services.push(Box::new(StatServ {
uid: format!("{}AAAAAF", cfg.server.sid),
}));
}
if enabled("example") { if enabled("example") {
services.push(Box::new(ExampleServ { services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid), uid: format!("{}AAAAAC", cfg.server.sid),

8
statserv/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-statserv"
version = "0.0.1"
edition = "2021"
description = "StatServ: surface the shared stats registry and per-channel activity."
[dependencies]
fedserv-api = { path = "../api" }

View file

@ -1,12 +1,8 @@
use fedserv_api::{NetView, Sender, ServiceCtx, Store}; use fedserv_api::{NetView, Sender, ServiceCtx, Store};
// BOTSTATS <#channel>: session activity the bot has seen in a channel — total // <#channel>: the lines seen in a channel this session and its top talkers.
// lines and the top talkers. Founder-or-admin. // Founder-or-admin.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { pub fn handle(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: BOTSTATS <#channel>");
return;
};
if !super::require_channel_admin(me, from, chan, ctx, db) { if !super::require_channel_admin(me, from, chan, ctx, db) {
return; return;
} }

21
statserv/src/global.rs Normal file
View file

@ -0,0 +1,21 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
// SERVER: the shared, cross-service counter registry plus a couple of live
// gauges. Operators only (Priv::Auspex), since it is network-wide.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
if !from.privs.has(Priv::Auspex) {
ctx.notice(me, from.uid, "Network statistics are for services operators.");
return;
}
ctx.notice(me, from.uid, "Network statistics:");
ctx.notice(me, from.uid, format!(" channels.total: {}", db.channels().len()));
ctx.notice(me, from.uid, format!(" bots.total: {}", db.bots().len()));
let counters = net.stat_counters();
if counters.is_empty() {
ctx.notice(me, from.uid, " (no activity counters yet)");
return;
}
for (key, value) in counters {
ctx.notice(me, from.uid, format!(" {key}: {value}"));
}
}

56
statserv/src/lib.rs Normal file
View file

@ -0,0 +1,56 @@
//! StatServ surfaces statistics: the shared, cross-service counter registry
//! (SERVER, operators only) and per-channel activity the bots have seen
//! (a #channel argument, for its founder). `lib.rs` holds the dispatcher; each
//! view lives in its own file.
use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store};
#[path = "global.rs"]
mod global;
#[path = "channel.rs"]
mod channel;
pub struct StatServ {
pub uid: String,
}
impl Service for StatServ {
fn nick(&self) -> &str {
"StatServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Statistics Service"
}
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() {
Some(chan) if chan.starts_with('#') => channel::handle(me, from, chan, ctx, net, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SERVER") || cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, ctx, net, db),
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02SERVER\x02, a \x02#channel\x02, or \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "StatServ reports statistics: \x02SERVER\x02 for network-wide counters (operators), or \x02<#channel>\x02 for a channel's activity (its founder).");
}
// Shared gate: the sender may see a channel's stats only as its founder or a
// services admin.
fn require_channel_admin(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool {
let Some(founder) = db.channel(chan).map(|c| c.founder) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return false;
};
if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can see its stats."));
return false;
}
true
}