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:
parent
ad61addb84
commit
04f927d3db
12 changed files with 168 additions and 28 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
8
statserv/Cargo.toml
Normal 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" }
|
||||||
|
|
@ -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
21
statserv/src/global.rs
Normal 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
56
statserv/src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue