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.
This commit is contained in:
Jean Chevronnet 2026-07-13 19:20:52 +00:00
parent bddf13459d
commit 766ef6ca47
No known key found for this signature in database
5 changed files with 75 additions and 0 deletions

View file

@ -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<String> = 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]

View file

@ -32,6 +32,9 @@ pub struct Channel {
pub ops: HashSet<String>, // uids holding channel-operator status
pub voices: HashSet<String>, // uids holding +v
pub key: Option<String>,
// BOTSTATS: lines seen this session, and per-nick counts (top-talkers). Bounded.
pub lines: u64,
pub talkers: HashMap<String, u64>,
}
// 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<Item = &str> {
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)
}
}