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:
parent
bddf13459d
commit
766ef6ca47
5 changed files with 75 additions and 0 deletions
|
|
@ -642,6 +642,8 @@ pub trait NetView {
|
||||||
fn channel_members(&self, channel: &str) -> Vec<String>;
|
fn channel_members(&self, channel: &str) -> Vec<String>;
|
||||||
fn channel_key(&self, channel: &str) -> Option<&str>;
|
fn channel_key(&self, channel: &str) -> Option<&str>;
|
||||||
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).
|
||||||
|
fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the
|
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ 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.
|
||||||
|
|
@ -66,6 +68,7 @@ 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.")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
botserv/src/stats.rs
Normal file
25
botserv/src/stats.rs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -995,6 +995,11 @@ impl Engine {
|
||||||
ctx.actions.push(resp);
|
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 {
|
} else {
|
||||||
let mut matched: Option<String> = None;
|
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");
|
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
|
// The shared stats registry gathers per-service command counts, BotServ
|
||||||
// events, and live gauges — the same pipe every module reports through.
|
// events, and live gauges — the same pipe every module reports through.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ pub struct Channel {
|
||||||
pub ops: HashSet<String>, // uids holding channel-operator status
|
pub ops: HashSet<String>, // uids holding channel-operator status
|
||||||
pub voices: HashSet<String>, // uids holding +v
|
pub voices: HashSet<String>, // uids holding +v
|
||||||
pub key: Option<String>,
|
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.
|
// 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))
|
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`.
|
// Uids currently in `channel`.
|
||||||
pub fn channel_members(&self, channel: &str) -> impl Iterator<Item = &str> {
|
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))
|
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(),
|
what: s.what.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> {
|
||||||
|
Network::channel_activity(self, channel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue