From e8b55bdb270cce74fcbde7fe2d70e1331ed2fff6 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 11:53:48 +0000 Subject: [PATCH] chanserv: entrymsg, enforce, getkey, seen, clone, aop/sop/vop Tracks channel membership (join/part/kick), the current key, and per-nick last-seen in the network view. Entry messages greet joiners; enforce re-applies the lock, access modes and auto-kicks to everyone present; getkey reports the key; seen reports last activity; clone copies a channel's settings; aop/sop/vop are tiered shortcuts over the access list. --- modules/chanserv/chanserv.rs | 37 +++++++++++- modules/chanserv/clone.rs | 32 +++++++++++ modules/chanserv/enforce.rs | 35 ++++++++++++ modules/chanserv/entrymsg.rs | 36 ++++++++++++ modules/chanserv/getkey.rs | 19 ++++++ modules/chanserv/seen.rs | 18 ++++++ modules/chanserv/xop.rs | 51 +++++++++++++++++ modules/protocol/inspircd.rs | 68 ++++++++++++++++++++-- modules/protocol/mod.rs | 4 ++ src/engine/db.rs | 29 +++++++++- src/engine/mod.rs | 108 ++++++++++++++++++++++++++++++----- src/engine/state.rs | 68 ++++++++++++++++++++-- 12 files changed, 479 insertions(+), 26 deletions(-) create mode 100644 modules/chanserv/clone.rs create mode 100644 modules/chanserv/enforce.rs create mode 100644 modules/chanserv/entrymsg.rs create mode 100644 modules/chanserv/getkey.rs create mode 100644 modules/chanserv/seen.rs create mode 100644 modules/chanserv/xop.rs diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 4a5a676..2767cdd 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -26,6 +26,18 @@ mod list; mod status; #[path = "set.rs"] mod set; +#[path = "entrymsg.rs"] +mod entrymsg; +#[path = "getkey.rs"] +mod getkey; +#[path = "seen.rs"] +mod seen; +#[path = "enforce.rs"] +mod enforce; +#[path = "clone.rs"] +mod clone; +#[path = "xop.rs"] +mod xop; pub struct ChanServ { pub uid: String, @@ -167,13 +179,36 @@ impl Service for ChanServ { Some("STATUS") => status::handle(me, from, args, ctx, net, db), Some("LIST") => list::handle(me, from, args, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), + Some("ENTRYMSG") => entrymsg::handle(me, from, args, ctx, db), + Some("GETKEY") => getkey::handle(me, from, args, ctx, net, db), + Some("SEEN") => seen::handle(me, from, args, ctx, net), + Some("ENFORCE") => enforce::handle(me, from, args, ctx, net, db), + Some("CLONE") => clone::handle(me, from, args, ctx, db), + Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db), + Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db), + Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } } } +// True if `from` is the channel's founder; otherwise notices why and returns false. +fn require_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool { + match db.channel(chan) { + None => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + false + } + Some(info) if from.account == Some(info.founder.as_str()) => true, + _ => { + ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can do that.")); + false + } + } +} + // True if `from` is the founder or an access-list op of `chan`; else notices why. fn require_op(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool { match (from.account, db.channel(chan)) { diff --git a/modules/chanserv/clone.rs b/modules/chanserv/clone.rs new file mode 100644 index 0000000..85ea5d5 --- /dev/null +++ b/modules/chanserv/clone.rs @@ -0,0 +1,32 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// CLONE : copy a channel's settings (mode lock, access, +// auto-kick, description, entry message) into another. Founder of both. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let (Some(&src), Some(&dest)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: CLONE "); + return; + }; + let (Some(sinfo), Some(dinfo)) = (db.channel(src).cloned(), db.channel(dest).cloned()) else { + ctx.notice(me, from.uid, "Both channels must be registered."); + return; + }; + if from.account != Some(sinfo.founder.as_str()) || from.account != Some(dinfo.founder.as_str()) { + ctx.notice(me, from.uid, "You must be the founder of both channels."); + return; + } + let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off); + for a in &sinfo.access { + let _ = db.access_add(dest, &a.account, &a.level); + } + for k in &sinfo.akick { + let _ = db.akick_add(dest, &k.mask, &k.reason); + } + let _ = db.set_desc(dest, &sinfo.desc); + let _ = db.set_entrymsg(dest, &sinfo.entrymsg); + if let Some(info) = db.channel(dest) { + ctx.channel_mode(me, dest, &info.lock_modes()); + } + ctx.notice(me, from.uid, format!("Copied \x02{src}\x02's settings to \x02{dest}\x02.")); +} diff --git a/modules/chanserv/enforce.rs b/modules/chanserv/enforce.rs new file mode 100644 index 0000000..958dd6d --- /dev/null +++ b/modules/chanserv/enforce.rs @@ -0,0 +1,35 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// ENFORCE <#channel>: re-apply the channel's settings to everyone present — +// the mode lock, access status modes, and the auto-kick list. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: ENFORCE <#channel>"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let Some(info) = db.channel(chan).cloned() else { + return; + }; + ctx.channel_mode(me, chan, &info.lock_modes()); + let members: Vec = net.channel_members(chan).map(str::to_string).collect(); + for uid in members { + match net.account_of(&uid).and_then(|a| info.join_mode(a)) { + Some(m) => ctx.channel_mode(me, chan, &format!("{m} {uid}")), + None => { + let nick = net.nick_of(&uid).unwrap_or("*"); + let host = net.host_of(&uid).unwrap_or("*"); + if let Some(k) = info.akick_match(&format!("{nick}!*@{host}")) { + ctx.channel_mode(me, chan, &format!("+b {}", k.mask)); + let reason = if k.reason.is_empty() { "You are banned from this channel." } else { &k.reason }; + ctx.kick(me, chan, &uid, reason); + } + } + } + } + ctx.notice(me, from.uid, format!("Re-applied \x02{chan}\x02's settings to everyone present.")); +} diff --git a/modules/chanserv/entrymsg.rs b/modules/chanserv/entrymsg.rs new file mode 100644 index 0000000..ef0606d --- /dev/null +++ b/modules/chanserv/entrymsg.rs @@ -0,0 +1,36 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// ENTRYMSG <#channel> [CLEAR | ]: message noticed to users as they join. +// With no argument, show the current message. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: ENTRYMSG <#channel> [CLEAR | ]"); + return; + }; + match args.get(2) { + None => match db.channel(chan) { + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), + Some(info) if info.entrymsg.is_empty() => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no entry message.")), + Some(info) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02: {}", info.entrymsg)), + }, + Some(&kw) if kw.eq_ignore_ascii_case("CLEAR") => { + if !super::require_op(me, from, chan, ctx, db) { + return; + } + match db.set_entrymsg(chan, "") { + Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 cleared.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some(_) => { + if !super::require_op(me, from, chan, ctx, db) { + return; + } + match db.set_entrymsg(chan, &args[2..].join(" ")) { + Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 updated.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + } +} diff --git a/modules/chanserv/getkey.rs b/modules/chanserv/getkey.rs new file mode 100644 index 0000000..67ea842 --- /dev/null +++ b/modules/chanserv/getkey.rs @@ -0,0 +1,19 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// GETKEY <#channel>: report the channel key (+k), for ops who need to let +// someone in. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: GETKEY <#channel>"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + match net.channel_key(chan) { + Some(key) => ctx.notice(me, from.uid, format!("Key for \x02{chan}\x02 is \x02{key}\x02.")), + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no key set.")), + } +} diff --git a/modules/chanserv/seen.rs b/modules/chanserv/seen.rs new file mode 100644 index 0000000..527d566 --- /dev/null +++ b/modules/chanserv/seen.rs @@ -0,0 +1,18 @@ +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// SEEN : when a nick was last seen, and doing what. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network) { + 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, super::human_time(s.ts), s.what)), + None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")), + } +} diff --git a/modules/chanserv/xop.rs b/modules/chanserv/xop.rs new file mode 100644 index 0000000..f905375 --- /dev/null +++ b/modules/chanserv/xop.rs @@ -0,0 +1,51 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// AOP/SOP/VOP <#channel> ADD | DEL | LIST — tiered +// shortcuts over the access list. `level` is the access level they map to +// ("op" for AOP/SOP, "voice" for VOP); `word` is what the user typed. +pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD | DEL | LIST")); + return; + }; + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + None | Some("LIST") => match db.channel(chan) { + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), + Some(info) => { + ctx.notice(me, from.uid, format!("{word} list for \x02{}\x02:", info.name)); + for a in info.access.iter().filter(|a| a.level == level) { + ctx.notice(me, from.uid, format!(" \x02{}\x02", a.account)); + } + } + }, + Some("ADD") => { + let Some(&account) = args.get(3) else { + ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD ")); + return; + }; + if !super::require_founder(me, from, chan, ctx, db) { + return; + } + match db.access_add(chan, account, level) { + Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02's {word} list.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("DEL") => { + let Some(&account) = args.get(3) else { + ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> DEL ")); + return; + }; + if !super::require_founder(me, from, chan, ctx, db) { + return; + } + match db.access_del(chan, account) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02's {word} list.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + _ => ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD | DEL | LIST")), + } +} diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index b687b16..ee384ef 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -96,17 +96,24 @@ impl Protocol for InspIrcd { // FJOIN [params] : — channel create/burst. // Each member is ","; surface a Join for each. "FJOIN" => { - let chan = tokens.next().unwrap_or("").to_string(); + // Mode section is everything before the " :" trailing. + let head: Vec<&str> = rest.split(" :").next().unwrap_or(rest).split_whitespace().collect(); + let chan = head.get(1).copied().unwrap_or(""); if chan.is_empty() { vec![] } else { - let mut out = vec![NetEvent::ChannelCreate { channel: chan.clone() }]; + let mut out = vec![NetEvent::ChannelCreate { channel: chan.to_string() }]; + if let Some(modes) = head.get(3) { + if let Some(key) = scan_key(modes, head.get(4..).unwrap_or(&[])) { + out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); + } + } // Members are ",[:]"; take the uid. for m in trailing(rest).split_whitespace() { if let Some((_, member)) = m.split_once(',') { let uid = member.split(':').next().unwrap_or(""); if !uid.is_empty() { - out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.clone() }); + out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() }); } } } @@ -120,6 +127,23 @@ impl Protocol for InspIrcd { } _ => vec![], }, + // : PART [:reason] — a user leaving a channel. + "PART" => match (source.as_deref(), tokens.next()) { + (Some(uid), Some(chan)) if chan.starts_with('#') => { + vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] + } + _ => vec![], + }, + // : KICK : — a user removed from a channel. + "KICK" => { + let chan = tokens.next().unwrap_or(""); + match tokens.next() { + Some(uid) if chan.starts_with('#') => { + vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }] + } + _ => vec![], + } + } // : FMODE [params] — a channel mode change. // Skip changes we made ourselves so enforcement can't loop. "FMODE" => { @@ -127,7 +151,11 @@ impl Protocol for InspIrcd { match (source.as_deref(), a.first(), a.get(2)) { // Our own uids (server SID + pseudoclients) share our SID prefix. (Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { - vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }] + let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]; + if let Some(key) = scan_key(modes, a.get(3..).unwrap_or(&[])) { + out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); + } + out } _ => vec![], } @@ -257,6 +285,38 @@ impl Protocol for InspIrcd { } } +// Whether a channel mode consumes a parameter, for standard InspIRCd chanmodes. +// List and prefix modes take one on both set and unset; the key takes one on +// both; the rest that take one do so only on set. +fn takes_param(m: char, adding: bool) -> bool { + match m { + 'b' | 'e' | 'I' | 'q' | 'a' | 'o' | 'h' | 'v' | 'k' => true, + 'l' | 'L' | 'f' | 'j' | 'J' | 'F' | 'H' => adding, + _ => false, + } +} + +// Walk a mode change and its params for a key (+k/-k). Returns Some(Some(key)) +// when a key is set, Some(None) when cleared, None when `k` isn't in the change. +fn scan_key(modes: &str, params: &[&str]) -> Option> { + let mut adding = true; + let mut pi = 0; + let mut result = None; + for m in modes.chars() { + match m { + '+' => adding = true, + '-' => adding = false, + 'k' => { + result = Some(if adding { params.get(pi).map(|s| s.to_string()) } else { None }); + pi += 1; + } + c if takes_param(c, adding) => pi += 1, + _ => {} + } + } + result +} + // The IRC "trailing" argument: everything after the first " :", else the last word. fn trailing(rest: &str) -> String { match rest.find(" :") { diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index 5c444c8..4be4cce 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -17,9 +17,13 @@ pub enum NetEvent { ChannelCreate { channel: String }, // A user joined a channel (an FJOIN member or an IJOIN), for auto-op. Join { uid: String, channel: String }, + // A user left a channel (PART) or was removed (KICK), for membership tracking. + Part { uid: String, channel: String }, // A channel's modes changed (FMODE), for enforcing mode locks. Our own // changes are filtered out by the protocol layer. ChannelModeChange { channel: String, modes: String }, + // A channel's key (+k/-k) changed, tracked so GETKEY can report it. + ChannelKey { channel: String, key: Option }, Quit { uid: String }, // An ircd relaying an IRCv3 account-registration request to us as the authority. AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String }, diff --git a/src/engine/db.rs b/src/engine/db.rs index f533c62..819de00 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -47,6 +47,7 @@ pub enum Event { ChannelAkickDel { channel: String, mask: String }, ChannelFounderSet { channel: String, founder: String }, ChannelDescSet { channel: String, desc: String }, + ChannelEntryMsgSet { channel: String, msg: String }, } // An access-list entry: an account and its level ("op" or "voice"). @@ -81,6 +82,9 @@ pub struct ChannelInfo { // Free-text description, shown in INFO. #[serde(default)] pub desc: String, + // Message noticed to users as they join. + #[serde(default)] + pub entrymsg: String, } impl ChannelInfo { @@ -403,6 +407,9 @@ impl Db { if !c.desc.is_empty() { snapshot.push(Event::ChannelDescSet { channel: c.name.clone(), desc: c.desc.clone() }); } + if !c.entrymsg.is_empty() { + snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); + } } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); @@ -543,7 +550,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new() }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() }); Ok(()) } @@ -660,6 +667,19 @@ impl Db { Ok(()) } + /// Set `channel`'s entry message (empty clears it). + pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelEntryMsgSet { channel: channel.to_string(), msg: msg.to_string() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().entrymsg = msg.to_string(); + Ok(()) + } + /// Unregister `name`. pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { let k = key(name); @@ -692,7 +712,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new() }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -735,6 +755,11 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.entrymsg = msg; + } + } } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 09bef76..6612311 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -191,32 +191,55 @@ impl Engine { None => Vec::new(), } } - // Give the founder and access-list members their status on join. + // On join: record membership, enforce the auto-kick list, give access + // members their status mode, and send the entry message. NetEvent::Join { uid, channel } => { + self.network.channel_join(&channel, &uid); let account = self.network.account_of(&uid).map(str::to_string); let from = self.chan_service.clone().unwrap_or_default(); let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); + let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty()); + let mut out = Vec::new(); match mode { - // A user with access gets their status mode. - Some(m) => vec![NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }], - // Otherwise, enforce the auto-kick list. + // A user with access gets their status mode, plus the entry message. + Some(m) => { + if let Some(msg) = entrymsg { + out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg }); + } + out.push(NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }); + } + // No access: an auto-kick match is banned and kicked, else greeted. None => { - let nick = self.network.nick_of(&uid).unwrap_or("*"); - let host = self.network.host_of(&uid).unwrap_or("*"); + let nick = self.network.nick_of(&uid).unwrap_or("*").to_string(); + let host = self.network.host_of(&uid).unwrap_or("*").to_string(); let hostmask = format!("{nick}!*@{host}"); - match self.db.channel(&channel).and_then(|c| c.akick_match(&hostmask)) { - Some(k) => { - let mask = k.mask.clone(); - let reason = if k.reason.is_empty() { "You are banned from this channel.".to_string() } else { k.reason.clone() }; - vec![ - NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("+b {mask}") }, - NetAction::Kick { from, channel, uid, reason }, - ] + let akick = self.db.channel(&channel).and_then(|c| c.akick_match(&hostmask)).map(|k| { + let reason = if k.reason.is_empty() { "You are banned from this channel.".to_string() } else { k.reason.clone() }; + (k.mask.clone(), reason) + }); + match akick { + Some((mask, reason)) => { + self.network.channel_part(&channel, &uid); + out.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("+b {mask}") }); + out.push(NetAction::Kick { from, channel, uid, reason }); + } + None => { + if let Some(msg) = entrymsg { + out.push(NetAction::Notice { from, to: uid, text: msg }); + } } - None => Vec::new(), } } } + out + } + NetEvent::Part { uid, channel } => { + self.network.channel_part(&channel, &uid); + Vec::new() + } + NetEvent::ChannelKey { channel, key } => { + self.network.set_channel_key(&channel, key); + Vec::new() } NetEvent::Quit { uid } => { self.network.user_quit(&uid); @@ -1124,6 +1147,61 @@ mod tests { assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC nope"), "founder can change")); } + // ChanServ entrymsg, enforce, getkey, seen, clone and the xop lists. + #[test] + fn chanserv_extended() { + use crate::chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-cs-ext.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register("carol", "pw", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db); + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + let notice = |out: &[NetAction], needle: &str| { + out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + // ENTRYMSG: set it, then a joining user is noticed it. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "ENTRYMSG #c welcome aboard"), "updated")); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h2".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAC" && text == "welcome aboard")), "{out:?}"); + + // XOP: AOP add shows in the AOP list and grants op access. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c ADD carol"), "Added")); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol")); + + // ENFORCE: alice present gets +o, the akick-matching guest is kicked. + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into() }); + to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out"); + let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}"); + + // GETKEY: reflects a tracked key change. + e.handle(NetEvent::ChannelKey { channel: "#c".into(), key: Some("s3cret".into()) }); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "GETKEY #c"), "s3cret")); + + // SEEN: an online user vs one who quit. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN alice"), "currently online")); + e.handle(NetEvent::Quit { uid: "000AAAAAC".into() }); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen")); + + // CLONE: copy #c's settings (incl. the AOP) into a second owned channel. + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "REGISTER #d".into() }); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "CLONE #c #d"), "Copied")); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #d LIST"), "carol")); + } + // A registered channel (re)appearing re-asserts +r; an unregistered one does not. #[test] fn channel_create_reasserts_registered_mode() { diff --git a/src/engine/state.rs b/src/engine/state.rs index 95972ed..113efee 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -1,12 +1,14 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::time::{SystemTime, UNIX_EPOCH}; // Live network view, rebuilt from the uplink's burst each connect (ephemeral — // unlike the account store, which persists). #[derive(Default)] pub struct Network { pub users: HashMap, // keyed by UID - pub channels: HashMap, + channels: HashMap, // keyed by lowercase name accounts: HashMap, // UID -> logged-in account, until logout/quit + seen: HashMap, // lowercase nick -> last activity } pub struct User { @@ -15,10 +17,26 @@ pub struct User { pub host: String, } -#[allow(dead_code)] +// A channel's live membership and current key (+k), tracked from the burst. +#[derive(Default)] pub struct Channel { - pub name: String, + pub members: HashSet, // uids + pub key: Option, +} + +// The last time a nick was seen, and doing what. +pub struct Seen { + pub nick: String, pub ts: u64, + pub what: String, +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn lc(s: &str) -> String { + s.to_ascii_lowercase() } impl Network { @@ -37,13 +55,20 @@ impl Network { pub fn user_nick_change(&mut self, uid: &str, nick: String) { if let Some(user) = self.users.get_mut(uid) { + self.seen.insert(lc(&nick), Seen { nick: nick.clone(), ts: now(), what: format!("changing nick from {}", user.nick) }); user.nick = nick; } } pub fn user_quit(&mut self, uid: &str) { + if let Some(u) = self.users.get(uid) { + self.seen.insert(lc(&u.nick), Seen { nick: u.nick.clone(), ts: now(), what: "quitting".to_string() }); + } self.users.remove(uid); self.accounts.remove(uid); + for c in self.channels.values_mut() { + c.members.remove(uid); + } } pub fn nick_of(&self, uid: &str) -> Option<&str> { @@ -63,4 +88,39 @@ impl Network { pub fn clear_account(&mut self, uid: &str) { self.accounts.remove(uid); } + + // A user joined a channel: record membership and last-seen. + pub fn channel_join(&mut self, channel: &str, uid: &str) { + self.channels.entry(lc(channel)).or_default().members.insert(uid.to_string()); + if let Some(nick) = self.nick_of(uid) { + self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("joining {channel}") }); + } + } + + // A user left a channel (part/kick): drop membership and record last-seen. + pub fn channel_part(&mut self, channel: &str, uid: &str) { + if let Some(c) = self.channels.get_mut(&lc(channel)) { + c.members.remove(uid); + } + if let Some(nick) = self.nick_of(uid) { + self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") }); + } + } + + // Uids currently in `channel`. + pub fn channel_members(&self, channel: &str) -> impl Iterator { + self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str)) + } + + pub fn set_channel_key(&mut self, channel: &str, key: Option) { + self.channels.entry(lc(channel)).or_default().key = key; + } + + pub fn channel_key(&self, channel: &str) -> Option<&str> { + self.channels.get(&lc(channel)).and_then(|c| c.key.as_deref()) + } + + pub fn last_seen(&self, nick: &str) -> Option<&Seen> { + self.seen.get(&lc(nick)) + } }