Notify or memo the affected user on channel access, XOP, founder, and successor changes

This commit is contained in:
Jean Chevronnet 2026-07-20 19:34:08 +00:00
parent 6078cdf83d
commit 6e53cb751a
No known key found for this signature in database
13 changed files with 152 additions and 29 deletions

View file

@ -2445,6 +2445,28 @@ pub fn next_guest_nick(base: &str, seq: &mut u32, net: &dyn NetView, store: &dyn
candidate 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 // Reject a look-alike / mixed-script registration name (nick or channel): the
// impersonation trick that a message-content filter never sees at the registration // 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 // seam. Returns a rejection reason, or None if the name is fine. Genuine

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "Modulo durch null", "modulo by zero": "Modulo durch null",
"that expression rolls too many dice": "dieser Ausdruck würfelt zu viele Würfel", "that expression rolls too many dice": "dieser Ausdruck würfelt zu viele Würfel",
"hypot() takes two arguments": "hypot() nimmt zwei Argumente", "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."
} }

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "módulo por cero", "modulo by zero": "módulo por cero",
"that expression rolls too many dice": "esa expresión tira demasiados dados", "that expression rolls too many dice": "esa expresión tira demasiados dados",
"hypot() takes two arguments": "hypot() toma dos argumentos", "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."
} }

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "módulo por cero", "modulo by zero": "módulo por cero",
"that expression rolls too many dice": "esa expresión lanza demasiados dados", "that expression rolls too many dice": "esa expresión lanza demasiados dados",
"hypot() takes two arguments": "hypot() toma dos argumentos", "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."
} }

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "modulo par zéro", "modulo by zero": "modulo par zéro",
"that expression rolls too many dice": "cette expression lance trop de dés", "that expression rolls too many dice": "cette expression lance trop de dés",
"hypot() takes two arguments": "hypot() prend deux arguments", "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."
} }

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "módulo por zero", "modulo by zero": "módulo por zero",
"that expression rolls too many dice": "essa expressão rola dados demais", "that expression rolls too many dice": "essa expressão rola dados demais",
"hypot() takes two arguments": "hypot() recebe dois argumentos", "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."
} }

View file

@ -1353,5 +1353,12 @@
"modulo by zero": "módulo por zero", "modulo by zero": "módulo por zero",
"that expression rolls too many dice": "essa expressão lança demasiados dados", "that expression rolls too many dice": "essa expressão lança demasiados dados",
"hypot() takes two arguments": "hypot() recebe dois argumentos", "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."
} }

View file

@ -1,5 +1,4 @@
use echo_api::Store; use echo_api::{NetView, Sender, ServiceCtx, Store};
use echo_api::{Sender, ServiceCtx};
use echo_api::t; use echo_api::t;
// The named tiers ACCESS ADD accepts, high to low; the granular FLAGS command // 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"]; const TIERS: [&str; 4] = ["sop", "op", "halfop", "voice"];
// ACCESS <#channel> LIST | ADD <account> <sop|op|halfop|voice> | DEL <account> // ACCESS <#channel> LIST | ADD <account> <sop|op|halfop|voice> | DEL <account>
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 { let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>"); ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>");
return; return;
@ -37,7 +36,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
return; return;
} }
match db.access_add(chan, account, &level) { 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."), 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; return;
} }
match db.access_del(chan, account) { 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)), 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."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
} }

View file

@ -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; use echo_api::t;
// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no // 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; // 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 // 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`. // 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 { let Some(info) = db.channel(chan) else {
ctx.notice(me, from.uid, t!(ctx, "\x02{chan}\x02 isn't registered.", chan = chan)); ctx.notice(me, from.uid, t!(ctx, "\x02{chan}\x02 isn't registered.", chan = chan));
return; return;
@ -76,7 +76,11 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv
} }
let letters = updated.to_letters(); let letters = updated.to_letters();
match db.access_add(chan, target, &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."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
} }
} }

View file

@ -288,13 +288,13 @@ impl Service for ChanServ {
} }
} }
Some("MODE") => mode::handle(me, from, args, ctx, db), 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") => { Some("FLAGS") => {
let Some(&chan) = args.get(1) else { let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]"); ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]");
return; 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("OP") => op::handle(me, from, "+o", args, ctx, net, db),
Some("DEOP") => 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("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db), Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db),
Some("LIST") => list::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("ENTRYMSG") => entrymsg::handle(me, from, args, ctx, db),
Some("GETKEY") => getkey::handle(me, from, args, ctx, net, db), Some("GETKEY") => getkey::handle(me, from, args, ctx, net, db),
Some("SEEN") => seen::handle(me, from, args, ctx, net), 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. // 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("ENFORCE") | Some("SYNC") => enforce::handle(me, from, args, ctx, net, db),
Some("CLONE") => clone::handle(me, from, args, ctx, db), Some("CLONE") => clone::handle(me, from, args, ctx, db),
Some("SOP") => xop::handle(me, from, "SOP", "sop", 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, db), Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, net, db),
Some("HOP") => xop::handle(me, from, "HOP", "halfop", args, ctx, db), Some("HOP") => xop::handle(me, from, "HOP", "halfop", args, ctx, net, db),
Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, 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()), Some("HELP") => echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()),
// Direct `/msg ChanServ FOO` earns this reply; the fantasy router // Direct `/msg ChanServ FOO` earns this reply; the fantasy router
// gates on COMMANDS first so an in-channel `!foo` stays silent. // gates on COMMANDS first so an in-channel `!foo` stays silent.

View file

@ -1,8 +1,8 @@
use echo_api::{ChanSetting, Sender, ServiceCtx, Store}; use echo_api::{ChanSetting, NetView, Sender, ServiceCtx, Store};
use echo_api::t; use echo_api::t;
// SET <#channel> FOUNDER <account> | DESC <text>: founder-only channel settings. // SET <#channel> FOUNDER <account> | DESC <text>: 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 { let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text>"); ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text>");
return; return;
@ -32,7 +32,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
return; return;
} }
match db.set_founder(chan, account) { 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."), 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; return;
} }
match db.set_successor(chan, Some(acct)) { 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."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
} }
} }

View file

@ -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; use echo_api::t;
// SOP/AOP/HOP/VOP <#channel> ADD <account> | DEL <account> | LIST — tiered // SOP/AOP/HOP/VOP <#channel> ADD <account> | DEL <account> | LIST — tiered
// shortcuts over the access list. `level` is the tier they map to ("sop", "op", // 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"). // "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 { let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, t!(ctx, "Syntax: {word} <#channel> ADD <account> | DEL <account> | LIST", word = word)); ctx.notice(me, from.uid, t!(ctx, "Syntax: {word} <#channel> ADD <account> | DEL <account> | LIST", word = word));
return; return;
@ -30,7 +31,11 @@ pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], c
return; return;
} }
match db.access_add(chan, account, level) { 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."), 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; return;
} }
match db.access_del(chan, account) { 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)), 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."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
} }

View file

@ -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:?}"); 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. // ChanServ SET: description and founder transfer, founder-gated.
#[test] #[test]
fn chanserv_set() { fn chanserv_set() {