From 6078cdf83d4da3455aaf5b60a96353b24d02766a Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 19:26:07 +0000 Subject: [PATCH 1/3] Enforce the channel akick list when a user changes nick --- src/engine/mod.rs | 71 +++++++++++++++++++++++++++++++-------------- src/engine/tests.rs | 30 +++++++++++++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ff81721..6e49b67 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1381,6 +1381,7 @@ impl Engine { self.network.user_nick_change(&uid, nick); self.pending_enforce.retain(|p| p.uid != uid); // they left the old nick let mut out = self.enforce_registered_nick(&uid, &new_nick); + out.extend(self.enforce_akick_on_nick(&uid)); if let Some(line) = self.notify_line('n', &uid, None, &format!("changed nick (was {old_nick})")) { out.push(line); } @@ -1477,28 +1478,11 @@ impl Engine { } // No access: an auto-kick match is banned and kicked, else greeted. None => { - // Match the akick list against the user's full identity — - // host and the extbans (account/realname/…) the ircd gave us. - let akick = { - let target = self.network.ban_target(&uid); - target.as_ref().and_then(|t| { - self.db.channel(&channel).and_then(|c| c.akick_match(t)).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); - acts.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("+b {mask}") }); - acts.push(NetAction::Kick { from, channel, uid, reason }); - } - None => { - if let Some(msg) = entrymsg { - acts.push(NetAction::Notice { from, to: uid, text: msg }); - } - } + let hit = self.akick_hit(&uid, &channel); + if !hit.is_empty() { + acts.extend(hit); + } else if let Some(msg) = entrymsg { + acts.push(NetAction::Notice { from, to: uid, text: msg }); } } } @@ -1825,6 +1809,49 @@ impl Engine { Some(NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("[{acc}] {greet}") }) } + // Ban+kick `uid` from `channel` if their current identity matches its akick + // list. Parts them locally and returns the +b/KICK; empty when no match. + fn akick_hit(&mut self, uid: &str, channel: &str) -> Vec { + let matched = { + let target = self.network.ban_target(uid); + target.as_ref().and_then(|t| { + self.db.channel(channel).and_then(|c| c.akick_match(t)).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 matched { + Some((mask, reason)) => { + let from = self.channel_mode_source(channel); + self.network.channel_part(channel, uid); + vec![ + NetAction::ChannelMode { from: from.clone(), channel: channel.to_string(), modes: format!("+b {mask}") }, + NetAction::Kick { from, channel: channel.to_string(), uid: uid.to_string(), reason }, + ] + } + None => Vec::new(), + } + } + + // A nick change rewrites the user's n!u@h, so re-run the akick check the join + // path does — otherwise a user joins clean, switches to a banned nick, and stays. + // Access holders are exempt, as on join. + fn enforce_akick_on_nick(&mut self, uid: &str) -> Vec { + let mut acts = Vec::new(); + let account = self.network.account_of(uid).map(str::to_string); + for channel in self.network.channels_of(uid) { + if self.db.is_channel_suspended(&channel) { + continue; + } + if account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a)).is_some() { + continue; + } + acts.extend(self.akick_hit(uid, &channel)); + } + acts + } + // Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel // that has a bot assigned. We reuse ChanServ's real command handlers — the } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 9b3b519..b8d2b20 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5179,6 +5179,36 @@ assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.contains('o'))), "{out:?}"); } + // A user who joins clean then switches to a nick matching the akick list is + // banned and kicked, just as if they'd joined under that nick. + #[test] + fn akick_enforced_on_nick_change() { + use echo_chanserv::ChanServ; + let path = std::env::temp_dir().join("echo-cs-akick-nick.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_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() }) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + to_cs(&mut e, "000AAAAAB", "AKICK #c ADD evil!*@* begone"); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "clean.host".into(), ip: "0.0.0.0".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false }); + assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { .. })), "clean nick not kicked: {out:?}"); + + let out = e.handle(NetEvent::NickChange { uid: "000AAAAAC".into(), nick: "evil".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b evil!*@*")), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason.starts_with("begone"))), "{out:?}"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() { From 6e53cb751a87d985931263eb79a5d5e6d3259114 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 19:34:08 +0000 Subject: [PATCH 2/3] Notify or memo the affected user on channel access, XOP, founder, and successor changes --- api/src/lib.rs | 22 ++++++++++++++++++++++ lang/de.json | 9 ++++++++- lang/es-ar.json | 9 ++++++++- lang/es.json | 9 ++++++++- lang/fr.json | 9 ++++++++- lang/pt-br.json | 9 ++++++++- lang/pt.json | 9 ++++++++- modules/chanserv/src/access.rs | 17 ++++++++++++----- modules/chanserv/src/flags.rs | 10 +++++++--- modules/chanserv/src/lib.rs | 14 +++++++------- modules/chanserv/src/set.rs | 16 ++++++++++++---- modules/chanserv/src/xop.rs | 17 +++++++++++++---- src/engine/tests.rs | 31 +++++++++++++++++++++++++++++++ 13 files changed, 152 insertions(+), 29 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index e93b8c2..70534c6 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2445,6 +2445,28 @@ pub fn next_guest_nick(base: &str, seq: &mut u32, net: &dyn NetView, store: &dyn candidate } +/// Tell `target` account they were affected by someone else's action: notice +/// every online session, or leave a memo (from `by`) if they're offline. The +/// message renders in the target's own language. No-op when the actor is the +/// target (self-service needs no notice). +#[allow(clippy::too_many_arguments)] +pub fn notify_or_memo(ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store, service: &str, by: &str, target: &str, msgid: &str, args: &[(&str, String)]) { + // A `!group` target is not a memo-able account, and self-service needs no notice. + if target.starts_with('!') || by.eq_ignore_ascii_case(target) { + return; + } + let lang = store.language_of(target).unwrap_or_else(|| store.default_language()); + let text = render(&lang, msgid, args); + let online = net.uids_logged_into(target); + if online.is_empty() { + let _ = store.memo_send(target, by, &text, false); + } else { + for uid in online { + ctx.notice(service, &uid, text.clone()); + } + } +} + // Reject a look-alike / mixed-script registration name (nick or channel): the // impersonation trick that a message-content filter never sees at the registration // seam. Returns a rejection reason, or None if the name is fine. Genuine diff --git a/lang/de.json b/lang/de.json index 4f0d175..81e5eec 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1353,5 +1353,12 @@ "modulo by zero": "Modulo durch null", "that expression rolls too many dice": "dieser Ausdruck würfelt zu viele Würfel", "hypot() takes two arguments": "hypot() nimmt zwei Argumente", - "pow() takes two arguments": "pow() nimmt zwei Argumente" + "pow() takes two arguments": "pow() nimmt zwei Argumente", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 hat dich zu \u0002{chan}\u0002 als \u0002{level}\u0002 hinzugefügt.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 hat deinen Zugang zu \u0002{chan}\u0002 entfernt.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 hat dich zur {word}-Liste von \u0002{chan}\u0002 hinzugefügt.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 hat dich von der {word}-Liste von \u0002{chan}\u0002 entfernt.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 hat deinen Zugang in \u0002{chan}\u0002 auf \u0002{letters}\u0002 gesetzt.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 hat dir die Gründerrolle von \u0002{chan}\u0002 übertragen.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 hat dich als Nachfolger von \u0002{chan}\u0002 festgelegt." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 184e923..6cb0ba6 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1353,5 +1353,12 @@ "modulo by zero": "módulo por cero", "that expression rolls too many dice": "esa expresión tira demasiados dados", "hypot() takes two arguments": "hypot() toma dos argumentos", - "pow() takes two arguments": "pow() toma dos argumentos" + "pow() takes two arguments": "pow() toma dos argumentos", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 te agregó a \u0002{chan}\u0002 como \u0002{level}\u0002.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 te sacó el acceso a \u0002{chan}\u0002.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te agregó a la lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te sacó de la lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 estableció tu acceso en \u0002{chan}\u0002 a \u0002{letters}\u0002.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 te transfirió el estatus de fundador de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 58b77b2..4f4dab5 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1353,5 +1353,12 @@ "modulo by zero": "módulo por cero", "that expression rolls too many dice": "esa expresión lanza demasiados dados", "hypot() takes two arguments": "hypot() toma dos argumentos", - "pow() takes two arguments": "pow() toma dos argumentos" + "pow() takes two arguments": "pow() toma dos argumentos", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 te añadió a \u0002{chan}\u0002 como \u0002{level}\u0002.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 te quitó el acceso a \u0002{chan}\u0002.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te añadió a la lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te quitó de la lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 estableció tu acceso en \u0002{chan}\u0002 a \u0002{letters}\u0002.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 te transfirió el estatus de fundador de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 39b2f79..3afa643 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1353,5 +1353,12 @@ "modulo by zero": "modulo par zéro", "that expression rolls too many dice": "cette expression lance trop de dés", "hypot() takes two arguments": "hypot() prend deux arguments", - "pow() takes two arguments": "pow() prend deux arguments" + "pow() takes two arguments": "pow() prend deux arguments", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 vous a ajouté à \u0002{chan}\u0002 en tant que \u0002{level}\u0002.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 vous a retiré l'accès à \u0002{chan}\u0002.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 vous a ajouté à la liste {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 vous a retiré de la liste {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 a défini votre accès sur \u0002{chan}\u0002 à \u0002{letters}\u0002.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 vous a transféré le statut de fondateur de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 vous a défini comme successeur de \u0002{chan}\u0002." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index 2368d0f..20e6831 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1353,5 +1353,12 @@ "modulo by zero": "módulo por zero", "that expression rolls too many dice": "essa expressão rola dados demais", "hypot() takes two arguments": "hypot() recebe dois argumentos", - "pow() takes two arguments": "pow() recebe dois argumentos" + "pow() takes two arguments": "pow() recebe dois argumentos", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 adicionou você a \u0002{chan}\u0002 como \u0002{level}\u0002.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 removeu seu acesso a \u0002{chan}\u0002.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 adicionou você à lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 removeu você da lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 definiu seu acesso em \u0002{chan}\u0002 como \u0002{letters}\u0002.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 transferiu a fundação de \u0002{chan}\u0002 para você.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu você como sucessor de \u0002{chan}\u0002." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 43a88f4..27e6ce3 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1353,5 +1353,12 @@ "modulo by zero": "módulo por zero", "that expression rolls too many dice": "essa expressão lança demasiados dados", "hypot() takes two arguments": "hypot() recebe dois argumentos", - "pow() takes two arguments": "pow() recebe dois argumentos" + "pow() takes two arguments": "pow() recebe dois argumentos", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002 as \u0002{level}\u0002.": "\u0002{by}\u0002 adicionou-o a \u0002{chan}\u0002 como \u0002{level}\u0002.", + "\u0002{by}\u0002 removed your access to \u0002{chan}\u0002.": "\u0002{by}\u0002 removeu o seu acesso a \u0002{chan}\u0002.", + "\u0002{by}\u0002 added you to \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 adicionou-o à lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 removeu-o da lista {word} de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 definiu o seu acesso em \u0002{chan}\u0002 como \u0002{letters}\u0002.", + "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 transferiu-lhe a fundação de \u0002{chan}\u0002.", + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu-o como sucessor de \u0002{chan}\u0002." } \ No newline at end of file diff --git a/modules/chanserv/src/access.rs b/modules/chanserv/src/access.rs index 3898187..eb43801 100644 --- a/modules/chanserv/src/access.rs +++ b/modules/chanserv/src/access.rs @@ -1,5 +1,4 @@ -use echo_api::Store; -use echo_api::{Sender, ServiceCtx}; +use echo_api::{NetView, Sender, ServiceCtx, Store}; use echo_api::t; // The named tiers ACCESS ADD accepts, high to low; the granular FLAGS command @@ -7,7 +6,7 @@ use echo_api::t; const TIERS: [&str; 4] = ["sop", "op", "halfop", "voice"]; // ACCESS <#channel> LIST | ADD | DEL -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD | DEL "); return; @@ -37,7 +36,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } match db.access_add(chan, account, &level) { - Ok(()) => ctx.notice(me, from.uid, t!(ctx, "Added \x02{account}\x02 to \x02{chan}\x02 as \x02{level}\x02.", account = account, chan = chan, level = level)), + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "Added \x02{account}\x02 to \x02{chan}\x02 as \x02{level}\x02.", account = account, chan = chan, level = level)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, account, "\x02{by}\x02 added you to \x02{chan}\x02 as \x02{level}\x02.", &[("by", by.to_string()), ("chan", chan.to_string()), ("level", level.clone())]); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } @@ -50,7 +53,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } match db.access_del(chan, account) { - Ok(true) => ctx.notice(me, from.uid, t!(ctx, "Removed \x02{account}\x02 from \x02{chan}\x02.", account = account, chan = chan)), + Ok(true) => { + ctx.notice(me, from.uid, t!(ctx, "Removed \x02{account}\x02 from \x02{chan}\x02.", account = account, chan = chan)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, account, "\x02{by}\x02 removed your access to \x02{chan}\x02.", &[("by", by.to_string()), ("chan", chan.to_string())]); + } Ok(false) => ctx.notice(me, from.uid, t!(ctx, "\x02{account}\x02 has no access to \x02{chan}\x02.", account = account, chan = chan)), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } diff --git a/modules/chanserv/src/flags.rs b/modules/chanserv/src/flags.rs index 322f209..08893de 100644 --- a/modules/chanserv/src/flags.rs +++ b/modules/chanserv/src/flags.rs @@ -1,4 +1,4 @@ -use echo_api::{Flags, Sender, ServiceCtx, Store, ACCESS_FLAGS}; +use echo_api::{Flags, NetView, Sender, ServiceCtx, Store, ACCESS_FLAGS}; use echo_api::t; // FLAGS <#channel> [account [+/-flags]]: the granular access model. With no @@ -7,7 +7,7 @@ use echo_api::t; // t topic i invite a access-list s settings g greet. Viewing needs op access; // changing needs the founder or the \x02a\x02 flag. Every stored level — a tier // preset ("op"/"sop"/…) or a raw flag string — resolves through `Flags`. -pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let Some(info) = db.channel(chan) else { ctx.notice(me, from.uid, t!(ctx, "\x02{chan}\x02 isn't registered.", chan = chan)); return; @@ -76,7 +76,11 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv } let letters = updated.to_letters(); match db.access_add(chan, target, &letters) { - Ok(()) => ctx.notice(me, from.uid, t!(ctx, "\x02{target}\x02 on \x02{chan}\x02 now holds \x02{letters}\x02.", target = target, chan = chan, letters = letters)), + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "\x02{target}\x02 on \x02{chan}\x02 now holds \x02{letters}\x02.", target = target, chan = chan, letters = letters)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, target, "\x02{by}\x02 set your access on \x02{chan}\x02 to \x02{letters}\x02.", &[("by", by.to_string()), ("chan", chan.to_string()), ("letters", letters.clone())]); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index cb0d66e..b3d16e3 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -288,13 +288,13 @@ impl Service for ChanServ { } } Some("MODE") => mode::handle(me, from, args, ctx, db), - Some("ACCESS") => access::handle(me, from, args, ctx, db), + Some("ACCESS") => access::handle(me, from, args, ctx, net, db), Some("FLAGS") => { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]"); return; }; - flags::handle(me, from, chan, args, ctx, db); + flags::handle(me, from, chan, args, ctx, net, db); } Some("OP") => op::handle(me, from, "+o", args, ctx, net, db), Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db), @@ -320,7 +320,7 @@ impl Service for ChanServ { Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false), Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db), Some("LIST") => list::handle(me, from, args, ctx, db), - Some("SET") => set::handle(me, from, args, ctx, db), + Some("SET") => set::handle(me, from, args, ctx, net, db), 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), @@ -328,10 +328,10 @@ impl Service for ChanServ { // the mode lock and akick list, so SYNC is the same handler. Some("ENFORCE") | Some("SYNC") => enforce::handle(me, from, args, ctx, net, db), Some("CLONE") => clone::handle(me, from, args, ctx, db), - Some("SOP") => xop::handle(me, from, "SOP", "sop", args, ctx, db), - Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db), - Some("HOP") => xop::handle(me, from, "HOP", "halfop", args, ctx, db), - Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db), + Some("SOP") => xop::handle(me, from, "SOP", "sop", args, ctx, net, db), + Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, net, db), + Some("HOP") => xop::handle(me, from, "HOP", "halfop", args, ctx, net, db), + Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, net, db), Some("HELP") => echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()), // Direct `/msg ChanServ FOO` earns this reply; the fantasy router // gates on COMMANDS first so an in-channel `!foo` stays silent. diff --git a/modules/chanserv/src/set.rs b/modules/chanserv/src/set.rs index 323b872..2e68ac2 100644 --- a/modules/chanserv/src/set.rs +++ b/modules/chanserv/src/set.rs @@ -1,8 +1,8 @@ -use echo_api::{ChanSetting, Sender, ServiceCtx, Store}; +use echo_api::{ChanSetting, NetView, Sender, ServiceCtx, Store}; use echo_api::t; // SET <#channel> FOUNDER | DESC : founder-only channel settings. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC "); return; @@ -32,7 +32,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } match db.set_founder(chan, account) { - Ok(()) => ctx.notice(me, from.uid, t!(ctx, "Founder of \x02{chan}\x02 transferred to \x02{account}\x02.", chan = chan, account = account)), + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "Founder of \x02{chan}\x02 transferred to \x02{account}\x02.", chan = chan, account = account)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, account, "\x02{by}\x02 transferred the \x02{chan}\x02 founder to you.", &[("by", by.to_string()), ("chan", chan.to_string())]); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } @@ -48,7 +52,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } match db.set_successor(chan, Some(acct)) { - Ok(()) => ctx.notice(me, from.uid, t!(ctx, "Successor of \x02{chan}\x02 set to \x02{acct}\x02. They inherit it if your account is dropped or expires.", chan = chan, acct = acct)), + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "Successor of \x02{chan}\x02 set to \x02{acct}\x02. They inherit it if your account is dropped or expires.", chan = chan, acct = acct)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, acct, "\x02{by}\x02 set you as successor of \x02{chan}\x02.", &[("by", by.to_string()), ("chan", chan.to_string())]); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } diff --git a/modules/chanserv/src/xop.rs b/modules/chanserv/src/xop.rs index b0ee386..922f2fd 100644 --- a/modules/chanserv/src/xop.rs +++ b/modules/chanserv/src/xop.rs @@ -1,10 +1,11 @@ -use echo_api::{access_role, Sender, ServiceCtx, Store}; +use echo_api::{access_role, NetView, Sender, ServiceCtx, Store}; use echo_api::t; // SOP/AOP/HOP/VOP <#channel> ADD | DEL | LIST — tiered // shortcuts over the access list. `level` is the tier they map to ("sop", "op", // "halfop", "voice"); `word` is the tier the user typed ("SOP".."VOP"). -pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +#[allow(clippy::too_many_arguments)] +pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, t!(ctx, "Syntax: {word} <#channel> ADD | DEL | LIST", word = word)); return; @@ -30,7 +31,11 @@ pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], c return; } match db.access_add(chan, account, level) { - Ok(()) => ctx.notice(me, from.uid, t!(ctx, "Added \x02{account}\x02 to \x02{chan}\x02's {word} list.", account = account, chan = chan, word = word)), + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "Added \x02{account}\x02 to \x02{chan}\x02's {word} list.", account = account, chan = chan, word = word)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, account, "\x02{by}\x02 added you to \x02{chan}\x02's {word} list.", &[("by", by.to_string()), ("chan", chan.to_string()), ("word", word.to_string())]); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } @@ -43,7 +48,11 @@ pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], c return; } match db.access_del(chan, account) { - Ok(true) => ctx.notice(me, from.uid, t!(ctx, "Removed \x02{account}\x02 from \x02{chan}\x02's {word} list.", account = account, chan = chan, word = word)), + Ok(true) => { + ctx.notice(me, from.uid, t!(ctx, "Removed \x02{account}\x02 from \x02{chan}\x02's {word} list.", account = account, chan = chan, word = word)); + let by = from.account.unwrap_or(from.nick); + echo_api::notify_or_memo(ctx, net, db, me, by, account, "\x02{by}\x02 removed you from \x02{chan}\x02's {word} list.", &[("by", by.to_string()), ("chan", chan.to_string()), ("word", word.to_string())]); + } Ok(false) => ctx.notice(me, from.uid, t!(ctx, "\x02{account}\x02 has no access to \x02{chan}\x02.", account = account, chan = chan)), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index b8d2b20..03d7dce 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5209,6 +5209,37 @@ assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason.starts_with("begone"))), "{out:?}"); } + // Being added to / removed from a channel's access list notifies the affected + // user: a memo while they're offline, a direct notice while they're online. + #[test] + fn access_change_notifies_the_affected_user() { + use echo_chanserv::ChanServ; + let path = std::env::temp_dir().join("echo-cs-access-notify.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("bob", "hunter2", 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() }) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + to_cs(&mut e, "000AAAAAB", "ACCESS #c ADD bob op"); + assert_eq!(e.db.unread_memos("bob"), 1, "offline user gets a memo"); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h2".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY hunter2".into() }); + let out = to_cs(&mut e, "000AAAAAB", "ACCESS #c DEL bob"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAC" && text.contains("removed your access"))), "online user noticed: {out:?}"); + assert_eq!(e.db.unread_memos("bob"), 1, "no extra memo when online"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() { From b500698e82ef61dc96a8b4966d0a0479053e4464 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 19:40:02 +0000 Subject: [PATCH 3/3] Add AJOIN ADDALL to auto-join every channel you are currently in --- api/src/lib.rs | 1 + lang/de.json | 5 ++++- lang/es-ar.json | 5 ++++- lang/es.json | 5 ++++- lang/fr.json | 5 ++++- lang/pt-br.json | 5 ++++- lang/pt.json | 5 ++++- modules/nickserv/src/ajoin.rs | 27 +++++++++++++++++++++++---- modules/nickserv/src/lib.rs | 2 +- src/engine/state.rs | 3 +++ 10 files changed, 52 insertions(+), 11 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 70534c6..8bb262c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2318,6 +2318,7 @@ pub trait NetView { // A user's identity for extban / AKICK matching, if known. fn ban_target(&self, uid: &str) -> Option>; fn uids_logged_into(&self, account: &str) -> Vec; + fn channels_of(&self, uid: &str) -> Vec; fn is_op(&self, channel: &str, uid: &str) -> bool; fn channel_members(&self, channel: &str) -> Vec; fn channel_key(&self, channel: &str) -> Option<&str>; diff --git a/lang/de.json b/lang/de.json index 81e5eec..503a0e6 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 hat dich von der {word}-Liste von \u0002{chan}\u0002 entfernt.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 hat deinen Zugang in \u0002{chan}\u0002 auf \u0002{letters}\u0002 gesetzt.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 hat dir die Gründerrolle von \u0002{chan}\u0002 übertragen.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 hat dich als Nachfolger von \u0002{chan}\u0002 festgelegt." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 hat dich als Nachfolger von \u0002{chan}\u0002 festgelegt.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 Kanal zu deiner Auto-Join-Liste hinzugefügt.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 Kanäle zu deiner Auto-Join-Liste hinzugefügt.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 6cb0ba6..29a0460 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te sacó de la lista {word} de \u0002{chan}\u0002.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 estableció tu acceso en \u0002{chan}\u0002 a \u0002{letters}\u0002.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 te transfirió el estatus de fundador de \u0002{chan}\u0002.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal agregado a tu lista de auto-unión.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canales agregados a tu lista de auto-unión.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 4f4dab5..0bb31cf 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 te quitó de la lista {word} de \u0002{chan}\u0002.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 estableció tu acceso en \u0002{chan}\u0002 a \u0002{letters}\u0002.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 te transfirió el estatus de fundador de \u0002{chan}\u0002.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal añadido a tu lista de auto-unión.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canales añadidos a tu lista de auto-unión.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 3afa643..954f096 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 vous a retiré de la liste {word} de \u0002{chan}\u0002.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 a défini votre accès sur \u0002{chan}\u0002 à \u0002{letters}\u0002.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 vous a transféré le statut de fondateur de \u0002{chan}\u0002.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 vous a défini comme successeur de \u0002{chan}\u0002." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 vous a défini comme successeur de \u0002{chan}\u0002.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 salon ajouté à votre liste d'auto-join.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 salons ajoutés à votre liste d'auto-join.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index 20e6831..de58b44 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 removeu você da lista {word} de \u0002{chan}\u0002.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 definiu seu acesso em \u0002{chan}\u0002 como \u0002{letters}\u0002.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 transferiu a fundação de \u0002{chan}\u0002 para você.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu você como sucessor de \u0002{chan}\u0002." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu você como sucessor de \u0002{chan}\u0002.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal adicionado à sua lista de auto-entrada.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canais adicionados à sua lista de auto-entrada.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 27e6ce3..cdc4687 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1360,5 +1360,8 @@ "\u0002{by}\u0002 removed you from \u0002{chan}\u0002's {word} list.": "\u0002{by}\u0002 removeu-o da lista {word} de \u0002{chan}\u0002.", "\u0002{by}\u0002 set your access on \u0002{chan}\u0002 to \u0002{letters}\u0002.": "\u0002{by}\u0002 definiu o seu acesso em \u0002{chan}\u0002 como \u0002{letters}\u0002.", "\u0002{by}\u0002 transferred the \u0002{chan}\u0002 founder to you.": "\u0002{by}\u0002 transferiu-lhe a fundação de \u0002{chan}\u0002.", - "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu-o como sucessor de \u0002{chan}\u0002." + "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu-o como sucessor de \u0002{chan}\u0002.", + "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal adicionado à sua lista de auto-entrada.", + "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canais adicionados à sua lista de auto-entrada.", + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." } \ No newline at end of file diff --git a/modules/nickserv/src/ajoin.rs b/modules/nickserv/src/ajoin.rs index c4a0531..ce4da44 100644 --- a/modules/nickserv/src/ajoin.rs +++ b/modules/nickserv/src/ajoin.rs @@ -1,5 +1,4 @@ -use echo_api::Store; -use echo_api::{Sender, ServiceCtx}; +use echo_api::{NetView, Sender, ServiceCtx, Store}; use echo_api::t; // A sane cap so a runaway list can't bloat an account or flood a user on identify. @@ -7,7 +6,7 @@ const MAX_AJOIN: usize = 25; // AJOIN [ADD <#channel> [key] | DEL <#channel> | LIST]: manage your auto-join // list — the channels NickServ joins you to each time you identify. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let Some(account) = from.account else { ctx.notice(me, from.uid, "You must identify to NickServ to use \x02AJOIN\x02."); return; @@ -33,6 +32,26 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } + Some("ADDALL") => { + let existing: std::collections::HashSet = + db.ajoin_list(account).into_iter().map(|e| e.channel.to_ascii_lowercase()).collect(); + let mut count = existing.len(); + let mut added = 0u64; + for channel in net.channels_of(from.uid) { + if count >= MAX_AJOIN { + break; + } + if existing.contains(&channel.to_ascii_lowercase()) { + continue; + } + let key = net.channel_key(&channel).unwrap_or(""); + if let Ok(true) = db.ajoin_add(account, &channel, key) { + added += 1; + count += 1; + } + } + ctx.notice(me, from.uid, echo_api::plural!(ctx, added, one = "Added \x02{count}\x02 channel to your auto-join list.", other = "Added \x02{count}\x02 channels to your auto-join list.", count = added)); + } Some("DEL") => { let Some(&channel) = args.get(2) else { ctx.notice(me, from.uid, "Syntax: AJOIN DEL <#channel>"); @@ -59,6 +78,6 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } } } - Some(other) => ctx.notice(me, from.uid, t!(ctx, "Unknown AJOIN command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.", other = other)), + Some(other) => ctx.notice(me, from.uid, t!(ctx, "Unknown AJOIN command \x02{other}\x02. Use \x02ADD\x02, \x02ADDALL\x02, \x02DEL\x02 or \x02LIST\x02.", other = other)), } } diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index cf6aac2..75c3ad8 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -126,7 +126,7 @@ impl Service for NickServ { Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db, true), Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db), Some("CONFIRM") => confirm::handle(me, from, args, ctx, db), - Some("AJOIN") => ajoin::handle(me, from, args, ctx, db), + Some("AJOIN") => ajoin::handle(me, from, args, ctx, net, db), Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true), Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false), Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db), diff --git a/src/engine/state.rs b/src/engine/state.rs index 694d984..0cda915 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -615,6 +615,9 @@ impl NetView for Network { fn uids_logged_into(&self, account: &str) -> Vec { Network::uids_logged_into(self, account) } + fn channels_of(&self, uid: &str) -> Vec { + Network::channels_of(self, uid) + } fn is_op(&self, channel: &str, uid: &str) -> bool { Network::is_op(self, channel, uid) }