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

View file

@ -69,7 +69,7 @@ impl Default for Modules {
}
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)]

View file

@ -118,10 +118,6 @@ pub struct Engine {
trigger_cache: HashMap<String, CachedTriggers>,
// Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event.
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,
// target_nick_lc). Ephemeral; expire after VOTE_TTL.
votes: HashMap<(String, String), VoteState>,
@ -195,20 +191,20 @@ impl Engine {
badword_cache: HashMap::new(),
trigger_cache: HashMap::new(),
pending_unbans: Vec::new(),
stats: std::collections::BTreeMap::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) {
*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
// Stats API. Any service's counters ride in here alongside these.
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("channels.total".to_string(), self.db.channels().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()) {
self.network.record_line(to, &nick);
self.bump("botserv.messages");
@ -2637,18 +2633,51 @@ mod tests {
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]
fn botserv_botstats_reports_activity() {
let (mut e, _p) = kicker_fixture("bsbotstats");
fn statserv_reports_channel_and_global() {
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 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() });
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, "two");
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:?}");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker listed: {out:?}");
// Per-channel view.
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

View file

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH};
// 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
// uplink-sourced user map.
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 {
@ -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).
pub fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> {
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)>)> {
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_chanserv::ChanServ;
use fedserv_memoserv::MemoServ;
use fedserv_statserv::StatServ;
use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ;
@ -69,6 +70,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAE", cfg.server.sid),
}));
}
if enabled("statserv") {
services.push(Box::new(StatServ {
uid: format!("{}AAAAAF", cfg.server.sid),
}));
}
if enabled("example") {
services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid),