Localize engine notices, transactional emails, and operator-access denials in all six languages
All checks were successful
CI / check (push) Successful in 5m18s

This commit is contained in:
Jean Chevronnet 2026-07-20 02:25:54 +00:00
parent ecdead8278
commit e4a14b9813
No known key found for this signature in database
13 changed files with 299 additions and 55 deletions

View file

@ -87,7 +87,8 @@ impl Engine {
};
if !already {
self.bump("botserv.warn");
return vec![NetAction::Notice { from: botuid, to: from.to_string(), text: format!("Please mind the channel rules — {reason} Next time you'll be kicked.") }];
let text = echo_api::render(&self.lang_for_uid(from), "Please mind the channel rules — {reason} Next time you'll be kicked.", &[("reason", reason.to_string())]);
return vec![NetAction::Notice { from: botuid, to: from.to_string(), text }];
}
}

View file

@ -620,7 +620,7 @@ impl Engine {
out.push(NetAction::Notice {
from: ns.clone(),
to: uid.to_string(),
text: format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."),
text: echo_api::render_plural(&self.lang_for_account(account), unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]),
});
}
}
@ -718,7 +718,8 @@ impl Engine {
// Tell any online successor they inherited a channel.
for (chan, s) in &inherited {
if let (Some(ns), Some(uid)) = (&ns, self.network.uids_logged_into(s).into_iter().next()) {
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).") });
let text = echo_api::render(&self.lang_for_account(s), "You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).", &[("chan", chan.to_string()), ("account", account.to_string())]);
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text });
}
}
// Log out and inform each local session that held the name.
@ -809,6 +810,21 @@ impl Engine {
// no per-record timer. Opers, accounts with a live session, and occupied
// channels are spared; each expiry is announced to the audit channel. Emits
// cleanup directly over the outbound path (like a takeover cleanup).
// The language to address `account`'s owner in: their stored preference, or
// the network default.
fn lang_for_account(&self, account: &str) -> String {
self.db.language_of(account).unwrap_or_else(|| self.db.default_language())
}
// The language to address the user on `uid` in: their logged-in account's
// preference, or the network default (e.g. an unidentified user).
fn lang_for_uid(&self, uid: &str) -> String {
self.network
.account_of(uid)
.and_then(|a| self.db.language_of(a))
.unwrap_or_else(|| self.db.default_language())
}
pub fn expire_sweep(&mut self) {
let now = self.now_secs();
// First, warn owners whose account/channel is approaching expiry and for
@ -816,14 +832,14 @@ impl Engine {
if let Some(lead) = self.expire_warn.filter(|_| self.db.email_enabled()) {
if let Some(ttl) = self.account_ttl {
for (account, email, left) in self.db.accounts_to_warn(now, ttl, lead) {
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Account, &account, &human_duration(left));
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Account, &account, &human_duration(left), &self.lang_for_account(&account));
self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) });
self.db.mark_account_warned(&account);
}
}
if let Some(ttl) = self.channel_ttl {
for (channel, email, left) in self.db.channels_to_warn(now, ttl, lead) {
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left));
let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &self.db.default_language());
self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) });
self.db.mark_channel_warned(&channel);
}
@ -1032,7 +1048,8 @@ impl Engine {
for uid in fire {
let guest = echo_api::next_guest_nick(&self.guest_nick, &mut self.enforce_seq, &self.network, &self.db);
if let Some(ns) = self.nick_service.clone() {
self.emit_irc(NetAction::Notice { from: ns, to: uid.clone(), text: "You didn't identify in time; you've been renamed.".to_string() });
let text = echo_api::render(&self.db.default_language(), "You didn't identify in time; you've been renamed.", &[]);
self.emit_irc(NetAction::Notice { from: ns, to: uid.clone(), text });
}
self.emit_irc(NetAction::ForceNick { uid, nick: guest });
}

View file

@ -90,7 +90,7 @@ impl Engine {
if status == AuthorityStatus::Ok && !self.db.is_verified(name) {
if let Some(addr) = addr {
let code = self.db.issue_code(name, db::CodeKind::Confirm);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code, &self.lang_for_account(name));
self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
}
}
@ -191,7 +191,8 @@ impl Engine {
self.network.clear_account(&uid);
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
if let Some(ns) = &ns {
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You have been logged out of \x02{account}\x02.") });
let text = echo_api::render(&self.lang_for_account(account), "You have been logged out of \x02{account}\x02.", &[("account", account.to_string())]);
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text });
}
}
n
@ -273,7 +274,7 @@ impl Engine {
}
Some((false, Some(addr))) => {
let code = self.db.issue_code(&account, db::CodeKind::Confirm);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code, &self.lang_for_account(&account));
let mut out = resp("verification_required", "VERIFICATION_REQUIRED", "A new confirmation code has been emailed.");
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
out
@ -338,10 +339,10 @@ impl Engine {
if needs_verify {
if let Some(addr) = addr {
let code = self.db.issue_code(account, db::CodeKind::Confirm);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code);
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code, &self.lang_for_account(account));
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
if let RegReply::NickServ { agent, uid, .. } = &reply {
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: echo_api::render(&self.lang_for_account(account), "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.", &[]) });
}
}
}
@ -366,14 +367,15 @@ impl Engine {
let actions = match then {
AuthThen::Identify { uid, agent, name, account } => {
self.db.note_auth(&name, ok);
let mut ctx = ServiceCtx::default();
let lang = self.lang_for_account(&account);
let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() };
if !ok {
ctx.count("nickserv.identify_fail");
ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again.");
} else {
ctx.login(&uid, &account);
ctx.count("nickserv.identify");
ctx.notice(&agent, &uid, format!("You're now identified as \x02{account}\x02. Welcome back!"));
ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())]));
for entry in self.db.ajoin_list(&account) {
ctx.force_join(&uid, &entry.channel, &entry.key);
}
@ -386,7 +388,7 @@ impl Engine {
}
let unread = self.db.unread_memos(&account);
if unread > 0 && self.db.memo_notify_on(&account) {
ctx.notice(&agent, &uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."));
ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]));
}
}
for key in std::mem::take(&mut ctx.stats) {