add SIGNORE: personal mutual server-side ignore across channels and PMs

This commit is contained in:
Jean Chevronnet 2026-08-20 14:48:17 +00:00
parent 2c9ce18cd7
commit 6c5f330746
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 107 additions and 1 deletions

View file

@ -599,7 +599,8 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
}
// 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);
// SIGNORE is mutual: drop if either party server-ignores the other.
let silenced = s.is_silenced(tuid, &prefix) || s.signore_blocks(uid, tuid);
let pm = format!(":{prefix} {cmd} {target} :{text}");
let ctags = s.line_ctags.clone();
let msgid = s.next_msgid();

View file

@ -14,6 +14,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
Box::new(Watch),
Box::new(Monitor),
Box::new(Silence),
Box::new(Signore),
Box::new(Accept),
]
}
@ -298,6 +299,77 @@ impl Command for Silence {
}
}
// --- SIGNORE (personal mutual server-side ignore) ---------------------------
fn signore_list(s: &Server, uid: Uid) {
let (nick, list) = s
.users
.get(&uid)
.map(|u| (u.nick.clone(), u.signore.clone()))
.unwrap_or_default();
let sn = &s.name;
if list.is_empty() {
s.send(uid, format!(":{sn} NOTICE {nick} :Your SIGNORE list is empty."));
} else {
for m in &list {
s.send(uid, format!(":{sn} NOTICE {nick} :SIGNORE {m}"));
}
}
s.send(uid, format!(":{sn} NOTICE {nick} :End of SIGNORE list."));
}
/// SIGNORE — a personal, mutual server-side ignore. `SIGNORE <mask>` (or `+mask`)
/// blocks a user both ways: neither of you sees the other's channel or private
/// messages. `SIGNORE -<mask>` lifts it; a bare `SIGNORE` lists your masks. A bare
/// nick becomes `nick!*@*`.
struct Signore;
impl Command for Signore {
fn name(&self) -> &'static str {
"SIGNORE"
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let Some(arg) = params.first() else {
signore_list(s, uid);
return CmdResult::Ok;
};
let nick = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default();
let sn = s.name.clone();
let (add, raw) = match arg.strip_prefix('-') {
Some(m) => (false, m),
None => (true, arg.strip_prefix('+').unwrap_or(arg)),
};
if raw.is_empty() {
signore_list(s, uid);
return CmdResult::Ok;
}
let mask = normalize_mask(raw);
if add {
let max = s.conf_num("maxsignore", 64usize);
let full = s
.users
.get(&uid)
.map(|u| u.signore.len() >= max && !u.signore.contains(&mask))
.unwrap_or(true);
if full {
s.send(uid, format!(":{sn} NOTICE {nick} :Your SIGNORE list is full ({max} max)."));
return CmdResult::Fail;
}
if let Some(u) = s.users.get_mut(&uid) {
if !u.signore.contains(&mask) {
u.signore.push(mask.clone());
}
}
s.send(uid, format!(":{sn} NOTICE {nick} :SIGNORE \x02{mask}\x02 added — you and they can no longer see each other's messages."));
} else {
if let Some(u) = s.users.get_mut(&uid) {
u.signore.retain(|x| x != &mask);
}
s.send(uid, format!(":{sn} NOTICE {nick} :SIGNORE \x02{mask}\x02 removed."));
}
CmdResult::Ok
}
}
// --- ACCEPT (callerid +g allow-list) ----------------------------------------
fn accept_list(s: &Server, uid: Uid) {

View file

@ -390,6 +390,7 @@ impl Server {
watch: Vec::new(),
monitor: Vec::new(),
silence: Vec::new(),
signore: Vec::new(),
accept: Vec::new(),
quitting: None,
flags: UserFlags::default(),
@ -1200,6 +1201,10 @@ impl Server {
let members: Vec<Uid> = ch.members.keys().copied().collect();
let time_tag = format!("time={}", iso_time(now()));
let account = self.users.get(&src).and_then(|su| su.account.clone());
// SIGNORE (mutual server-ignore): the sender's mask + list, hoisted once.
let src_mask = self.users.get(&src).map(|su| su.prefix()).unwrap_or_default();
let src_signore: Vec<String> =
self.users.get(&src).map(|su| su.signore.clone()).unwrap_or_default();
// one cached line per (server_time, account_tag, message_tags) combination
let mut cache: [Option<std::sync::Arc<str>>; 8] = std::array::from_fn(|_| None);
for m in members {
@ -1212,6 +1217,13 @@ impl Server {
if u.flags.deaf {
continue;
}
// SIGNORE: skip a member mutually server-ignored with the sender
if (!src_signore.is_empty() || !u.signore.is_empty())
&& (src_signore.iter().any(|p| crate::channels::glob_match(p, &u.prefix()))
|| u.signore.iter().any(|p| crate::channels::glob_match(p, &src_mask)))
{
continue;
}
if op_only && self.rank(m, key) < crate::channels::RANK_HALFOP {
continue;
}
@ -1507,6 +1519,7 @@ mod tests {
watch: Vec::new(),
monitor: Vec::new(),
silence: Vec::new(),
signore: Vec::new(),
accept: Vec::new(),
quitting: None,
flags: UserFlags::default(),

View file

@ -252,6 +252,7 @@ pub struct User {
pub watch: Vec<String>, // WATCH list — lowercased nicks
pub monitor: Vec<String>, // MONITOR list — lowercased nicks
pub silence: Vec<String>, // SILENCE masks — nick!user@host globs
pub signore: Vec<String>, // SIGNORE masks — mutual server-side ignore (both ways)
pub accept: Vec<String>, // ACCEPT list — lowercased nicks (callerid +g)
pub quitting: Option<String>, // set by QUIT; drained by the core
pub flags: UserFlags,

View file

@ -208,4 +208,23 @@ impl Server {
.map(|u| u.silence.iter().any(|m| glob_match(m, sender_mask)))
.unwrap_or(false)
}
/// True if `a` and `b` mutually SIGNORE each other (either one has the other on
/// their SIGNORE list): a bidirectional block — neither sees the other's channel
/// or PM messages, triggered by whichever one ran SIGNORE.
pub fn signore_blocks(&self, a: Uid, b: Uid) -> bool {
self.signore_one_way(a, b) || self.signore_one_way(b, a)
}
/// Does `by`'s SIGNORE list match `who`'s current mask?
fn signore_one_way(&self, by: Uid, who: Uid) -> bool {
let Some(byu) = self.users.get(&by) else {
return false;
};
if byu.signore.is_empty() {
return false;
}
let mask = self.users.get(&who).map(|u| u.prefix()).unwrap_or_default();
byu.signore.iter().any(|m| glob_match(m, &mask))
}
}