NickServ: show last-seen (and online status) in INFO

The account's last-active time was tracked but never surfaced. INFO now
reports it, reading 'now (online)' when the account has a live session.
Threads NetView into the INFO handler for the session lookup and carries
last_seen on AccountView.
This commit is contained in:
Jean Chevronnet 2026-07-16 01:56:51 +00:00
parent 2c4bc9079c
commit 39d7421c06
No known key found for this signature in database
5 changed files with 22 additions and 4 deletions

View file

@ -464,6 +464,8 @@ pub struct AccountView {
// Personal greet shown by a bot when this account joins a greet-enabled // Personal greet shown by a bot when this account joins a greet-enabled
// channel (empty = none). // channel (empty = none).
pub greet: String, 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 // One channel access-list entry (account -> level). `level` is either a legacy

View file

@ -1,10 +1,10 @@
use echo_api::{human_time, Priv, Store}; use echo_api::{human_time, NetView, Priv, Store};
use echo_api::{Sender, ServiceCtx}; use echo_api::{Sender, ServiceCtx};
// INFO [account]: show an account's registration details. The email and other // 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 // private fields are shown to the account's own owner, or to an oper with the
// auspex privilege. // 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 name = args.get(1).copied().unwrap_or(from.nick);
let Some(acct) = db.account(name) else { let Some(acct) = db.account(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); 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!("Information for \x02{}\x02:", acct.name));
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); 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. // A greet is public — the bot shows it in-channel to everyone anyway.
if !acct.greet.is_empty() { if !acct.greet.is_empty() {
ctx.notice(me, from.uid, format!(" Greet : {}", acct.greet)); ctx.notice(me, from.uid, format!(" Greet : {}", acct.greet));

View file

@ -114,7 +114,7 @@ impl Service for NickServ {
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db), 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("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("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("ALIST") => alist::handle(me, from, ctx, db),
Some("SET") => set::handle(me, from, args, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db),
Some("SASET") => saset::handle(me, from, args, ctx, db), Some("SASET") => saset::handle(me, from, args, ctx, db),

View file

@ -11,6 +11,7 @@ impl Store for Db {
ts: a.ts, ts: a.ts,
verified: a.verified, verified: a.verified,
greet: a.greet.clone(), greet: a.greet.clone(),
last_seen: a.last_seen,
}) })
} }
fn resolve_account(&self, name: &str) -> Option<&str> { fn resolve_account(&self, name: &str) -> Option<&str> {
@ -19,7 +20,7 @@ impl Store for Db {
fn accounts_matching(&self, pattern: &str) -> Vec<AccountView> { fn accounts_matching(&self, pattern: &str) -> Vec<AccountView> {
self.accounts() self.accounts()
.filter(|a| super::glob_match(pattern, &a.name)) .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() .collect()
} }
fn accounts_by_email(&self, pattern: &str) -> Vec<String> { fn accounts_by_email(&self, pattern: &str) -> Vec<String> {

View file

@ -446,6 +446,7 @@
let mut db = Db::open(&path, "test"); let mut db = Db::open(&path, "test");
db.scram_iterations = 4096; db.scram_iterations = 4096;
db.register("alice", "sesame", None).unwrap(); db.register("alice", "sesame", None).unwrap();
db.register("bob", "hunter2", None).unwrap();
db.register_channel("#a", "alice").unwrap(); db.register_channel("#a", "alice").unwrap();
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
let cs = ChanServ { uid: "42SAAAAAB".into() }; let cs = ChanServ { uid: "42SAAAAAB".into() };
@ -461,6 +462,13 @@
assert!(notice(&info, "Information for \x02alice\x02")); assert!(notice(&info, "Information for \x02alice\x02"));
assert!(notice(&info, "Registered")); assert!(notice(&info, "Registered"));
assert!(notice(&info, "Email"), "owner sees their email line: {info:?}"); 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, "INFO ghost"), "isn't registered"));
assert!(notice(&to_ns(&mut e, "ALIST"), "#a")); assert!(notice(&to_ns(&mut e, "ALIST"), "#a"));
} }