chanserv: channel-scoped seen with last message, replied in-channel
All checks were successful
CI / check (push) Successful in 3m47s
All checks were successful
CI / check (push) Successful in 3m47s
This commit is contained in:
parent
f2774f13fe
commit
66eb644a07
5 changed files with 128 additions and 17 deletions
|
|
@ -926,6 +926,13 @@ pub struct SeenView {
|
||||||
pub what: String,
|
pub what: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A nick's last message in a channel (channel-scoped SEEN).
|
||||||
|
pub struct ChanSeenView {
|
||||||
|
pub nick: String,
|
||||||
|
pub ts: u64,
|
||||||
|
pub msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RegError {
|
pub enum RegError {
|
||||||
Exists,
|
Exists,
|
||||||
|
|
@ -1208,6 +1215,7 @@ 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>;
|
||||||
|
fn channel_seen(&self, channel: &str, nick: &str) -> Option<ChanSeenView>;
|
||||||
// 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.
|
// The shared stat counters (namespaced key -> count), for StatServ.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,31 @@
|
||||||
use echo_api::{Sender, ServiceCtx};
|
use echo_api::{Sender, ServiceCtx};
|
||||||
use echo_api::NetView;
|
use echo_api::NetView;
|
||||||
|
|
||||||
// SEEN <nick>: when a nick was last seen, and doing what.
|
// SEEN <nick> — network-wide: when a nick was last seen, and doing what.
|
||||||
|
// SEEN <#channel> <nick> — channel-scoped: when last active there, and their last
|
||||||
|
// message. The fantasy form (!seen nick) injects #channel.
|
||||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
|
||||||
let Some(&nick) = args.get(1) else {
|
match args.get(1) {
|
||||||
ctx.notice(me, from.uid, "Syntax: SEEN <nick>");
|
// Channel-scoped (also the fantasy `!seen nick` path, which injects the channel).
|
||||||
|
Some(&chan) if chan.starts_with('#') => {
|
||||||
|
let Some(&nick) = args.get(2) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: SEEN <#channel> <nick>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if net.uid_by_nick(nick).is_some() {
|
||||||
|
ctx.notice(me, from.uid, format!("{}: \x02{nick}\x02 is here right now.", from.nick));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match net.channel_seen(chan, nick) {
|
||||||
|
Some(s) => ctx.notice(me, from.uid, format!(
|
||||||
|
"{}: \x02{}\x02 was last seen on \x02{chan}\x02 {}, last saying: {}",
|
||||||
|
from.nick, s.nick, echo_api::human_time(s.ts), s.msg
|
||||||
|
)),
|
||||||
|
None => ctx.notice(me, from.uid, format!("{}: I have no record of \x02{nick}\x02 talking in \x02{chan}\x02.", from.nick)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Network-wide.
|
||||||
|
Some(&nick) => {
|
||||||
if net.uid_by_nick(nick).is_some() {
|
if net.uid_by_nick(nick).is_some() {
|
||||||
ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online."));
|
ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online."));
|
||||||
return;
|
return;
|
||||||
|
|
@ -15,4 +34,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
||||||
Some(s) => ctx.notice(me, from.uid, format!("\x02{}\x02 was last seen {} ({}).", s.nick, echo_api::human_time(s.ts), s.what)),
|
Some(s) => ctx.notice(me, from.uid, format!("\x02{}\x02 was last seen {} ({}).", s.nick, echo_api::human_time(s.ts), s.what)),
|
||||||
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
|
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
None => ctx.notice(me, from.uid, "Syntax: SEEN <nick> | SEEN <#channel> <nick>"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
// Record activity in bot channels (surfaced by StatServ).
|
// 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, text);
|
||||||
self.bump("botserv.messages");
|
self.bump("botserv.messages");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -103,9 +103,15 @@ impl Engine {
|
||||||
let mark = ctx.actions.len();
|
let mark = ctx.actions.len();
|
||||||
let args: Vec<&str> = [cmd, chan].into_iter().chain(words).collect();
|
let args: Vec<&str> = [cmd, chan].into_iter().chain(words).collect();
|
||||||
self.route_to(&csuid, sender, &args, ctx);
|
self.route_to(&csuid, sender, &args, ctx);
|
||||||
// Make the bot the source of everything ChanServ just emitted.
|
// Fantasy is public: the bot speaks ChanServ's text replies into the channel,
|
||||||
|
// and its actions (modes, kicks) are re-sourced from the bot.
|
||||||
for a in ctx.actions[mark..].iter_mut() {
|
for a in ctx.actions[mark..].iter_mut() {
|
||||||
resource_action(a, &csuid, &botuid);
|
match a {
|
||||||
|
NetAction::Notice { text, .. } => {
|
||||||
|
*a = NetAction::Privmsg { from: botuid.clone(), to: chan.to_string(), text: std::mem::take(text) };
|
||||||
|
}
|
||||||
|
_ => resource_action(a, &csuid, &botuid),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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
|
||||||
// naming it locally.
|
// naming it locally.
|
||||||
pub use echo_api::{HelpEntry, IncidentView, NetView, SeenView};
|
pub use echo_api::{ChanSeenView, HelpEntry, IncidentView, NetView, SeenView};
|
||||||
|
|
||||||
// The most recent moderation/action incidents kept for LOGSEARCH.
|
// The most recent moderation/action incidents kept for LOGSEARCH.
|
||||||
const INCIDENT_CAP: usize = 10_000;
|
const INCIDENT_CAP: usize = 10_000;
|
||||||
|
|
@ -29,6 +29,9 @@ pub struct Network {
|
||||||
// Per-channel BOTSTATS activity (lines + top talkers), name-keyed and
|
// Per-channel BOTSTATS activity (lines + top talkers), name-keyed and
|
||||||
// persisted alongside `stats` so it survives a restart.
|
// persisted alongside `stats` so it survives a restart.
|
||||||
chan_activity: HashMap<String, ChanActivity>,
|
chan_activity: HashMap<String, ChanActivity>,
|
||||||
|
// Per-channel per-nick last message + time, for the channel-scoped SEEN.
|
||||||
|
// Ephemeral (built from activity since services started), bounded per channel.
|
||||||
|
chan_seen: HashMap<String, HashMap<String, ChanSeen>>,
|
||||||
// Live session count per connecting IP, for OperServ session limiting.
|
// Live session count per connecting IP, for OperServ session limiting.
|
||||||
sessions: HashMap<String, u32>,
|
sessions: HashMap<String, u32>,
|
||||||
// Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH.
|
// Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH.
|
||||||
|
|
@ -106,6 +109,13 @@ pub struct Seen {
|
||||||
pub what: String,
|
pub what: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A nick's last message in a channel, for the channel-scoped SEEN.
|
||||||
|
pub struct ChanSeen {
|
||||||
|
pub nick: String,
|
||||||
|
pub ts: u64,
|
||||||
|
pub msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn now() -> u64 {
|
fn now() -> u64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
@ -339,13 +349,22 @@ impl Network {
|
||||||
|
|
||||||
// Record a line spoken in `channel` by `nick`, for BOTSTATS. The per-nick map
|
// 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.
|
// is capped so a busy channel can't grow it without bound.
|
||||||
pub fn record_line(&mut self, channel: &str, nick: &str) {
|
pub fn record_line(&mut self, channel: &str, nick: &str, msg: &str) {
|
||||||
const TALKER_CAP: usize = 512;
|
const TALKER_CAP: usize = 512;
|
||||||
let a = self.chan_activity.entry(lc(channel)).or_default();
|
let a = self.chan_activity.entry(lc(channel)).or_default();
|
||||||
a.lines = a.lines.saturating_add(1);
|
a.lines = a.lines.saturating_add(1);
|
||||||
if a.talkers.len() < TALKER_CAP || a.talkers.contains_key(nick) {
|
if a.talkers.len() < TALKER_CAP || a.talkers.contains_key(nick) {
|
||||||
*a.talkers.entry(nick.to_string()).or_insert(0) += 1;
|
*a.talkers.entry(nick.to_string()).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
// Channel-scoped last message (bounded, truncated) for SEEN.
|
||||||
|
let seen = self.chan_seen.entry(lc(channel)).or_default();
|
||||||
|
if seen.len() < TALKER_CAP || seen.contains_key(&lc(nick)) {
|
||||||
|
seen.insert(lc(nick), ChanSeen { nick: nick.to_string(), ts: now(), msg: msg.chars().take(300).collect() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_seen(&self, channel: &str, nick: &str) -> Option<&ChanSeen> {
|
||||||
|
self.chan_seen.get(&lc(channel))?.get(&lc(nick))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment a shared stat counter.
|
// Increment a shared stat counter.
|
||||||
|
|
@ -489,6 +508,13 @@ impl NetView for Network {
|
||||||
what: s.what.clone(),
|
what: s.what.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
fn channel_seen(&self, channel: &str, nick: &str) -> Option<ChanSeenView> {
|
||||||
|
Network::channel_seen(self, channel, nick).map(|s| ChanSeenView {
|
||||||
|
nick: s.nick.clone(),
|
||||||
|
ts: s.ts,
|
||||||
|
msg: s.msg.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,55 @@
|
||||||
assert_ne!(src, "42SAAAAAB", "not sourced from ChanServ");
|
assert_ne!(src, "42SAAAAAB", "not sourced from ChanServ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// !seen nick in a bot channel: the bot answers in the channel with the nick's
|
||||||
|
// last message there (fantasy injects the channel).
|
||||||
|
#[test]
|
||||||
|
fn fantasy_seen_reports_channel_last_message() {
|
||||||
|
use echo_botserv::BotServ;
|
||||||
|
use echo_chanserv::ChanServ;
|
||||||
|
use echo_nickserv::NickServ;
|
||||||
|
let path = std::env::temp_dir().join("echo-seenchan.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "test");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.register("alice", "sesame", None).unwrap();
|
||||||
|
db.register_channel("#room", "alice").unwrap();
|
||||||
|
let mut e = Engine::new(
|
||||||
|
vec![
|
||||||
|
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||||
|
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
|
||||||
|
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||||||
|
],
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
e.set_sid("42S".into());
|
||||||
|
let mut opers = std::collections::HashMap::new();
|
||||||
|
opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||||||
|
e.set_opers(opers);
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
let assign = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||||||
|
let _ = assign;
|
||||||
|
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() });
|
||||||
|
let botuid = out.iter().find_map(|a| match a {
|
||||||
|
NetAction::ServiceJoin { uid, channel } if channel == "#room" => Some(uid.clone()),
|
||||||
|
_ => None,
|
||||||
|
}).expect("bot joins #room");
|
||||||
|
|
||||||
|
// bob speaks in the channel, then quits.
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "#room".into(), text: "bye bye friends".into() });
|
||||||
|
e.handle(NetEvent::Quit { uid: "000AAAAAB".into() });
|
||||||
|
|
||||||
|
// alice asks !seen bob: the bot answers in the channel with bob's last message.
|
||||||
|
let seen = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "#room".into(), text: "!seen bob".into() });
|
||||||
|
assert!(
|
||||||
|
seen.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||||||
|
if from == &botuid && to == "#room" && text.contains("bye bye friends") && text.contains("bob"))),
|
||||||
|
"bot reports bob's last message in the channel, got {seen:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A server split (SQUIT) forgets exactly the users behind it (uids carrying
|
// A server split (SQUIT) forgets exactly the users behind it (uids carrying
|
||||||
// that SID), leaving users on other servers untouched.
|
// that SID), leaving users on other servers untouched.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue