diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index c89a998..9f2460e 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -376,6 +376,50 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu } return CmdResult::Fail; } + // +g callerid: a +g user only accepts PMs from users on their ACCEPT list. + // Others are blocked; the target is told someone tried (718), and a PRIVMSG + // sender is told the target is in +g and has been informed (716 + 717). + let sender_nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + let target_g = s + .users + .get(&tuid) + .map(|u| u.flags.callerid) + .unwrap_or(false); + if target_g && uid != tuid && !s.is_accepted(tuid, &sender_nick) { + let (tnick, sident, shost) = { + let t = s.users.get(&tuid); + let u = s.users.get(&uid); + ( + t.map(|x| x.nick.clone()).unwrap_or_default(), + u.map(|x| x.ident.clone()).unwrap_or_default(), + u.map(|x| x.host_display().to_string()).unwrap_or_default(), + ) + }; + s.numeric( + tuid, + RPL_UMODEGMSG, + &format!( + "{sender_nick} {sident}@{shost} :is messaging you, and you have umode +g." + ), + ); + if !notice { + s.numeric( + uid, + RPL_TARGUMODEG, + &format!("{tnick} :is in +g mode (server-side ignore)."), + ); + s.numeric( + uid, + RPL_TARGNOTIFY, + &format!("{tnick} :has been informed that you messaged them."), + ); + } + return CmdResult::Ok; + } // SILENCE: if the recipient silenced the sender, drop it silently — the // sender is never told (that's the point), but still gets their own echo. let silenced = s.is_silenced(tuid, &prefix); @@ -398,6 +442,20 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu s.numeric(uid, RPL_AWAY, &format!("{target} :{msg}")); } } + // callerid convenience: if the SENDER is +g, auto-accept whoever they + // message so that person can reply without being blocked. + if s.users.get(&uid).map(|u| u.flags.callerid).unwrap_or(false) { + let tnick = s + .users + .get(&tuid) + .map(|u| u.nick.to_ascii_lowercase()) + .unwrap_or_default(); + if let Some(su) = s.users.get_mut(&uid) { + if !tnick.is_empty() && !su.accept.contains(&tnick) { + su.accept.push(tnick); + } + } + } } else if let Some((uuid, via)) = s.find_remote(target) { // the target is a user on another server — route it across the link s.send_to_remote(uid, &uuid, via, cmd, text); diff --git a/src/coremods/core_watch.rs b/src/coremods/core_watch.rs index 3b338d9..9fe965d 100644 --- a/src/coremods/core_watch.rs +++ b/src/coremods/core_watch.rs @@ -7,11 +7,16 @@ use crate::channels::normalize_mask; use crate::command::{CmdResult, Command}; use crate::numeric::*; use crate::server::Server; -use crate::watch::{MONITOR_MAX, SILENCE_MAX, WATCH_MAX}; +use crate::watch::{ACCEPT_MAX, MONITOR_MAX, SILENCE_MAX, WATCH_MAX}; use crate::Uid; pub fn commands() -> Vec> { - vec![Box::new(Watch), Box::new(Monitor), Box::new(Silence)] + vec![ + Box::new(Watch), + Box::new(Monitor), + Box::new(Silence), + Box::new(Accept), + ] } // --- WATCH ------------------------------------------------------------------ @@ -304,3 +309,87 @@ impl Command for Silence { CmdResult::Ok } } + +// --- ACCEPT (callerid +g allow-list) ---------------------------------------- + +fn accept_list(s: &Server, uid: Uid) { + let list = s + .users + .get(&uid) + .map(|u| u.accept.clone()) + .unwrap_or_default(); + for n in list { + s.numeric(uid, RPL_ACCEPTLIST, &n); + } + s.numeric(uid, RPL_ENDOFACCEPT, ":End of ACCEPT list"); +} + +struct Accept; +impl Command for Accept { + fn name(&self) -> &'static str { + "ACCEPT" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let first = params.first().map(|a| a.as_str()).unwrap_or(""); + if first.is_empty() || first == "*" { + accept_list(s, uid); + return CmdResult::Ok; + } + for tok in params + .iter() + .flat_map(|p| p.split([',', ' '])) + .filter(|t| !t.is_empty()) + { + if tok == "*" { + accept_list(s, uid); + continue; + } + let (adding, name) = match tok.strip_prefix('-') { + Some(n) => (false, n), + None => (true, tok.strip_prefix('+').unwrap_or(tok)), + }; + if name.is_empty() { + continue; + } + let low = name.to_ascii_lowercase(); + if adding { + let (full, exists) = s + .users + .get(&uid) + .map(|u| (u.accept.len() >= ACCEPT_MAX, u.accept.contains(&low))) + .unwrap_or((true, false)); + if exists { + s.numeric( + uid, + ERR_ACCEPTEXIST, + &format!("{name} :is already on your accept list"), + ); + } else if full { + s.numeric( + uid, + ERR_ACCEPTFULL, + &format!("{name} :Your accept list is full"), + ); + } else if let Some(u) = s.users.get_mut(&uid) { + u.accept.push(low); + } + } else { + let existed = s + .users + .get(&uid) + .map(|u| u.accept.contains(&low)) + .unwrap_or(false); + if !existed { + s.numeric( + uid, + ERR_ACCEPTNOT, + &format!("{name} :is not on your accept list"), + ); + } else if let Some(u) = s.users.get_mut(&uid) { + u.accept.retain(|x| x != &low); + } + } + } + CmdResult::Ok + } +} diff --git a/src/mode.rs b/src/mode.rs index 6c72d11..79e84dd 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -781,6 +781,7 @@ static USER_MODES: &[&(dyn UserMode + Sync)] = &[ ®ISTERED, &SSLPM, &SNOMASK, + &CALLERID, ]; /// A simple boolean user flag (+i / +w). @@ -820,6 +821,9 @@ fn set_regdeaf(f: &mut UserFlags, v: bool) { fn set_sslpm(f: &mut UserFlags, v: bool) { f.ssl_pm = v; } +fn set_callerid(f: &mut UserFlags, v: bool) { + f.callerid = v; +} static BOT: UFlag = UFlag { ch: 'B', set: set_bot, @@ -844,6 +848,10 @@ static SSLPM: UFlag = UFlag { ch: 'z', set: set_sslpm, }; +static CALLERID: UFlag = UFlag { + ch: 'g', + set: set_callerid, +}; impl UserMode for UFlag { fn letter(&self) -> char { @@ -984,7 +992,7 @@ mod tests { #[test] fn registry_covers_all_user_modes() { - for c in "iwoxBDIHrRzs".chars() { + for c in "iwoxBDIHrRzsg".chars() { assert!(user_mode(c).is_some(), "missing umode +{c}"); } assert!(user_mode('Q').is_none()); diff --git a/src/numeric.rs b/src/numeric.rs index c38dfcc..df654c6 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -37,6 +37,16 @@ pub const RPL_SILELIST: u16 = 271; pub const RPL_ENDOFSILENCE: u16 = 272; pub const ERR_SILELISTFULL: u16 = 511; +// ACCEPT / callerid (umode +g) +pub const RPL_ACCEPTLIST: u16 = 281; +pub const RPL_ENDOFACCEPT: u16 = 282; +pub const ERR_ACCEPTFULL: u16 = 456; +pub const ERR_ACCEPTEXIST: u16 = 457; +pub const ERR_ACCEPTNOT: u16 = 458; +pub const RPL_TARGUMODEG: u16 = 716; // " :is in +g mode (server-side ignore)" +pub const RPL_TARGNOTIFY: u16 = 717; // " :has been informed that you messaged them" +pub const RPL_UMODEGMSG: u16 = 718; // to the +g user: " :is messaging you…" + // WATCH (notify list) pub const RPL_LOGON: u16 = 600; pub const RPL_LOGOFF: u16 = 601; diff --git a/src/server.rs b/src/server.rs index 29b6a67..3eb542e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -190,6 +190,7 @@ impl Server { watch: Vec::new(), monitor: Vec::new(), silence: Vec::new(), + accept: Vec::new(), quitting: None, flags: UserFlags::default(), last_active: now(), @@ -491,6 +492,7 @@ mod tests { watch: Vec::new(), monitor: Vec::new(), silence: Vec::new(), + accept: Vec::new(), quitting: None, flags: UserFlags::default(), last_active: 0, diff --git a/src/users.rs b/src/users.rs index afe00d8..3a937c8 100644 --- a/src/users.rs +++ b/src/users.rs @@ -28,6 +28,7 @@ pub struct UserFlags { pub reg_only_pm: bool, // +R (only accept PMs from logged-in users) pub ssl_pm: bool, // +z (only accept PMs from TLS users) pub snomask: bool, // +s (oper: receive server notices) + pub callerid: bool, // +g (only accept PMs from users on the ACCEPT list) pub away: Option, // AWAY message, if set } @@ -70,6 +71,9 @@ impl UserFlags { if self.snomask { s.push('s'); } + if self.callerid { + s.push('g'); + } s } } @@ -209,6 +213,7 @@ pub struct User { pub watch: Vec, // WATCH list — lowercased nicks pub monitor: Vec, // MONITOR list — lowercased nicks pub silence: Vec, // SILENCE masks — nick!user@host globs + pub accept: Vec, // ACCEPT list — lowercased nicks (callerid +g) pub quitting: Option, // set by QUIT; drained by the core pub flags: UserFlags, pub last_active: u64, // unix secs of the last line we received @@ -355,7 +360,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGu", + "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGu", self.name ), ); @@ -363,7 +368,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), ); diff --git a/src/watch.rs b/src/watch.rs index 8768dfe..21bb461 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -18,6 +18,7 @@ use crate::Uid; pub const WATCH_MAX: usize = 128; pub const MONITOR_MAX: usize = 128; pub const SILENCE_MAX: usize = 32; +pub const ACCEPT_MAX: usize = 64; impl Server { /// A nick just came online (registered, or someone renamed to it): tell its @@ -74,6 +75,15 @@ impl Server { .count() } + /// True if `sender_nick` is on `target`'s ACCEPT list (callerid +g). + pub fn is_accepted(&self, target: Uid, sender_nick: &str) -> bool { + let low = sender_nick.to_ascii_lowercase(); + self.users + .get(&target) + .map(|u| u.accept.contains(&low)) + .unwrap_or(false) + } + /// True if user `by` has silenced someone whose prefix is `sender_mask`. pub fn is_silenced(&self, by: Uid, sender_mask: &str) -> bool { self.users