diff --git a/api/src/lib.rs b/api/src/lib.rs index 7522269..3c66773 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -926,6 +926,13 @@ pub struct SeenView { 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)] pub enum RegError { Exists, @@ -1208,6 +1215,7 @@ 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; + fn channel_seen(&self, channel: &str, nick: &str) -> Option; // BOTSTATS: (lines seen this session, top talkers by count desc). fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>; // The shared stat counters (namespaced key -> count), for StatServ. diff --git a/modules/chanserv/src/seen.rs b/modules/chanserv/src/seen.rs index a993af8..f17b0c0 100644 --- a/modules/chanserv/src/seen.rs +++ b/modules/chanserv/src/seen.rs @@ -1,18 +1,40 @@ use echo_api::{Sender, ServiceCtx}; use echo_api::NetView; -// SEEN : when a nick was last seen, and doing what. +// SEEN — network-wide: when a nick was last seen, and doing what. +// SEEN <#channel> — 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) { - let Some(&nick) = args.get(1) else { - ctx.notice(me, from.uid, "Syntax: SEEN "); - return; - }; - if net.uid_by_nick(nick).is_some() { - ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online.")); - return; - } - match net.last_seen(nick) { - 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.")), + match args.get(1) { + // 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> "); + 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() { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online.")); + return; + } + match net.last_seen(nick) { + 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, "Syntax: SEEN | SEEN <#channel> "), } } diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index fecc609..205a69a 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -30,7 +30,7 @@ impl Engine { } // Record activity in bot channels (surfaced by StatServ). 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"); } } else { @@ -103,9 +103,15 @@ impl Engine { let mark = ctx.actions.len(); let args: Vec<&str> = [cmd, chan].into_iter().chain(words).collect(); 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() { - 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), + } } } diff --git a/src/engine/state.rs b/src/engine/state.rs index eb3dc71..3cbdb7e 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; // The read-only network view a module sees; re-exported so the engine keeps // 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. const INCIDENT_CAP: usize = 10_000; @@ -29,6 +29,9 @@ pub struct Network { // Per-channel BOTSTATS activity (lines + top talkers), name-keyed and // persisted alongside `stats` so it survives a restart. chan_activity: HashMap, + // 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>, // Live session count per connecting IP, for OperServ session limiting. sessions: HashMap, // Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH. @@ -106,6 +109,13 @@ pub struct Seen { 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 { 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 // 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; let a = self.chan_activity.entry(lc(channel)).or_default(); a.lines = a.lines.saturating_add(1); if a.talkers.len() < TALKER_CAP || a.talkers.contains_key(nick) { *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. @@ -489,6 +508,13 @@ impl NetView for Network { what: s.what.clone(), }) } + fn channel_seen(&self, channel: &str, nick: &str) -> Option { + 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)>)> { Network::channel_activity(self, channel) } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 350d540..7d9467c 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -530,6 +530,55 @@ 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 // that SID), leaving users on other servers untouched. #[test]