diff --git a/api/src/lib.rs b/api/src/lib.rs index 5f1b5ca..cff8b31 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -464,6 +464,8 @@ pub struct AccountView { // Personal greet shown by a bot when this account joins a greet-enabled // channel (empty = none). pub greet: String, + // Unix time this account was last active (coalesced); never below `ts`. + pub last_seen: u64, } // One channel access-list entry (account -> level). `level` is either a legacy diff --git a/modules/nickserv/src/info.rs b/modules/nickserv/src/info.rs index 63af721..c5594d5 100644 --- a/modules/nickserv/src/info.rs +++ b/modules/nickserv/src/info.rs @@ -1,10 +1,10 @@ -use echo_api::{human_time, Priv, Store}; +use echo_api::{human_time, NetView, Priv, Store}; use echo_api::{Sender, ServiceCtx}; // INFO [account]: show an account's registration details. The email and other // private fields are shown to the account's own owner, or to an oper with the // auspex privilege. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) { let name = args.get(1).copied().unwrap_or(from.nick); let Some(acct) = db.account(name) else { ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); @@ -12,6 +12,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: }; ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name)); ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); + // Last-seen is public (Anope shows it by default); a live session reads as online. + let last_seen = if net.uids_logged_into(&acct.name).is_empty() { + human_time(acct.last_seen) + } else { + "now (online)".to_string() + }; + ctx.notice(me, from.uid, format!(" Last seen : {last_seen}")); // A greet is public — the bot shows it in-channel to everyone anyway. if !acct.greet.is_empty() { ctx.notice(me, from.uid, format!(" Greet : {}", acct.greet)); diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index 33b0c70..ac44780 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -114,7 +114,7 @@ impl Service for NickServ { Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db), Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx), Some("CERT") => cert::handle(me, from, args, ctx, db), - Some("INFO") => info::handle(me, from, args, ctx, db), + Some("INFO") => info::handle(me, from, args, ctx, net, db), Some("ALIST") => alist::handle(me, from, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db), Some("SASET") => saset::handle(me, from, args, ctx, db), diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 03621ea..f46da07 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -11,6 +11,7 @@ impl Store for Db { ts: a.ts, verified: a.verified, greet: a.greet.clone(), + last_seen: a.last_seen, }) } fn resolve_account(&self, name: &str) -> Option<&str> { @@ -19,7 +20,7 @@ impl Store for Db { fn accounts_matching(&self, pattern: &str) -> Vec { self.accounts() .filter(|a| super::glob_match(pattern, &a.name)) - .map(|a| AccountView { name: a.name.clone(), email: a.email.clone(), ts: a.ts, verified: a.verified, greet: a.greet.clone() }) + .map(|a| AccountView { name: a.name.clone(), email: a.email.clone(), ts: a.ts, verified: a.verified, greet: a.greet.clone(), last_seen: a.last_seen }) .collect() } fn accounts_by_email(&self, pattern: &str) -> Vec { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 49d86fb..9c418cd 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -446,6 +446,7 @@ let mut db = Db::open(&path, "test"); db.scram_iterations = 4096; db.register("alice", "sesame", None).unwrap(); + db.register("bob", "hunter2", None).unwrap(); db.register_channel("#a", "alice").unwrap(); let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; let cs = ChanServ { uid: "42SAAAAAB".into() }; @@ -461,6 +462,13 @@ assert!(notice(&info, "Information for \x02alice\x02")); assert!(notice(&info, "Registered")); assert!(notice(&info, "Email"), "owner sees their email line: {info:?}"); + // alice is identified, so her INFO reports her as online. + assert!(notice(&info, "Last seen"), "INFO has a last-seen line: {info:?}"); + assert!(notice(&info, "online"), "an identified account reads as online: {info:?}"); + // bob is registered but not connected: last-seen shows a time, not online. + let binfo = to_ns(&mut e, "INFO bob"); + assert!(notice(&binfo, "Last seen"), "offline account still shows last-seen: {binfo:?}"); + assert!(!notice(&binfo, "online"), "an offline account is not reported online: {binfo:?}"); assert!(notice(&to_ns(&mut e, "INFO ghost"), "isn't registered")); assert!(notice(&to_ns(&mut e, "ALIST"), "#a")); }