From 766ef6ca47b9f4249d52a9af71d96eeb0847051c Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 19:20:52 +0000 Subject: [PATCH] BotServ: BOTSTATS per-channel activity BOTSTATS <#channel> reports the lines the bot has seen this session and the top talkers. Tracked live in the network view (bounded per-nick map), recorded for bot-assigned channels only, and also fed to the shared stats registry as botserv.messages. --- api/src/lib.rs | 2 ++ botserv/src/lib.rs | 3 +++ botserv/src/stats.rs | 25 +++++++++++++++++++++++++ src/engine/mod.rs | 19 +++++++++++++++++++ src/engine/state.rs | 26 ++++++++++++++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 botserv/src/stats.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index c871623..40093da 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -642,6 +642,8 @@ pub trait NetView { fn channel_members(&self, channel: &str) -> Vec; fn channel_key(&self, channel: &str) -> Option<&str>; fn last_seen(&self, nick: &str) -> Option; + // BOTSTATS: (lines seen this session, top talkers by count desc). + fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>; } // A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index 022bb0f..4f3b262 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -22,6 +22,8 @@ mod badwords; mod copy; #[path = "trigger.rs"] mod trigger; +#[path = "stats.rs"] +mod stats; // Shared gate: the sender may administer 's bot options only as its // founder or a services admin. Notices and returns false on failure. @@ -66,6 +68,7 @@ impl Service for BotServ { Some("BADWORDS") => badwords::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("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> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> 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.")), } diff --git a/botserv/src/stats.rs b/botserv/src/stats.rs new file mode 100644 index 0000000..5906f96 --- /dev/null +++ b/botserv/src/stats.rs @@ -0,0 +1,25 @@ +use fedserv_api::{NetView, Sender, ServiceCtx, Store}; + +// BOTSTATS <#channel>: session activity the bot has seen in a channel — total +// lines and the top talkers. Founder-or-admin. +pub fn handle(me: &str, from: &Sender, args: &[&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) { + return; + } + let Some((lines, top)) = net.channel_activity(chan) else { + ctx.notice(me, from.uid, format!("No activity recorded for \x02{chan}\x02 yet.")); + return; + }; + ctx.notice(me, from.uid, format!("\x02{chan}\x02 — \x02{lines}\x02 line(s) seen this session.")); + if top.is_empty() { + return; + } + ctx.notice(me, from.uid, "Top talkers:"); + for (i, (nick, count)) in top.iter().enumerate() { + ctx.notice(me, from.uid, format!(" {}. \x02{nick}\x02 — {count}", i + 1)); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 414d69d..3f38db4 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -995,6 +995,11 @@ impl Engine { ctx.actions.push(resp); } } + // BOTSTATS: count activity in channels that have a bot. + if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) { + self.network.record_line(to, &nick); + self.bump("botserv.messages"); + } } else { let mut matched: Option = None; { @@ -2513,6 +2518,20 @@ mod tests { assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss"); } + // BOTSTATS reports per-channel activity the bot has seen this session. + #[test] + fn botserv_botstats_reports_activity() { + let (mut e, _p) = kicker_fixture("bsbotstats"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + 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:?}"); + } + // The shared stats registry gathers per-service command counts, BotServ // events, and live gauges — the same pipe every module reports through. #[test] diff --git a/src/engine/state.rs b/src/engine/state.rs index 87c09d0..0489e50 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -32,6 +32,9 @@ pub struct Channel { pub ops: HashSet, // uids holding channel-operator status pub voices: HashSet, // uids holding +v pub key: Option, + // BOTSTATS: lines seen this session, and per-nick counts (top-talkers). Bounded. + pub lines: u64, + pub talkers: HashMap, } // The last time a nick was seen, and doing what. @@ -182,6 +185,26 @@ impl Network { self.channels.get(&lc(channel)).is_some_and(|c| c.voices.contains(uid)) } + // Record a line spoken in `channel` by `nick`, for BOTSTATS. The per-nick map + // is capped so a busy channel can't grow it without bound. + pub fn record_line(&mut self, channel: &str, nick: &str) { + const TALKER_CAP: usize = 512; + let c = self.channels.entry(lc(channel)).or_default(); + c.lines = c.lines.saturating_add(1); + if c.talkers.len() < TALKER_CAP || c.talkers.contains_key(nick) { + *c.talkers.entry(nick.to_string()).or_insert(0) += 1; + } + } + + // 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))?; + let mut top: Vec<(String, u64)> = c.talkers.iter().map(|(n, v)| (n.clone(), *v)).collect(); + top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + top.truncate(10); + Some((c.lines, top)) + } + // Uids currently in `channel`. pub fn channel_members(&self, channel: &str) -> impl Iterator { self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str)) @@ -234,4 +257,7 @@ impl NetView for Network { what: s.what.clone(), }) } + fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { + Network::channel_activity(self, channel) + } }