Add i18n framework: per-account language, catalogs, t! macro, SET LANGUAGE
All checks were successful
CI / check (push) Successful in 5m16s

This commit is contained in:
Jean Chevronnet 2026-07-19 22:57:45 +00:00
parent 83851c794d
commit b462d37bd5
No known key found for this signature in database
16 changed files with 272 additions and 12 deletions

View file

@ -24,6 +24,11 @@ pub struct Config {
// start. Bind to localhost; unauthenticated by design (read-only gauges).
#[serde(default)]
pub health: Option<Health>,
// Localization. Absent = English only. `default` is the reply language for
// users with no preference; `dir` holds `<code>.json` catalogs; `available`
// lists the codes a user may pick with NickServ SET LANGUAGE.
#[serde(default)]
pub language: Option<Language>,
// Which service modules to start. Absent = the full standard suite (all the
// pseudo-clients); listing it trims that set. Every service is first-class.
#[serde(default)]
@ -63,6 +68,27 @@ pub struct Config {
pub extban: Option<Extban>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Language {
// Reply language for users who haven't picked one (a code like "en" or "fr").
#[serde(default = "default_language")]
pub default: String,
// Directory holding the `<code>.json` translation catalogs (english id -> text).
#[serde(default = "default_language_dir")]
pub dir: String,
// Codes a user may select with NickServ SET LANGUAGE. Empty = only the default.
#[serde(default)]
pub available: Vec<String>,
}
fn default_language() -> String {
"en".to_string()
}
fn default_language_dir() -> String {
"lang".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct Extban {
// Enabled extban names ("account", "realname", "country", …). Empty = all.

View file

@ -22,7 +22,7 @@ impl Db {
ajoin: Vec::new(),
suspension: None,
memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: None,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None,
vhost: None,
vhost_request: None,
last_seen: ts,
@ -78,7 +78,7 @@ impl Db {
ajoin: Vec::new(),
suspension: None,
memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: None,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None,
vhost: None,
vhost_request: None,
last_seen: ts,
@ -524,6 +524,22 @@ impl Db {
Ok(())
}
/// Set (or clear, with None) `account`'s preferred reply language (SET LANGUAGE).
pub fn set_language(&mut self, account: &str, language: Option<String>) -> Result<(), RegError> {
let k = key(account);
if !self.accounts.contains_key(&k) {
return Err(RegError::Internal);
}
self.log.append(Event::AccountLanguageSet { account: account.to_string(), language: language.clone() }).map_err(|_| RegError::Internal)?;
self.accounts.get_mut(&k).unwrap().language = language;
Ok(())
}
/// `account`'s preferred language, if it set one.
pub fn language_of(&self, account: &str) -> Option<String> {
self.accounts.get(&key(account)).and_then(|a| a.language.clone())
}
/// Set whether `account` wants to be auto-opped on join (NickServ SET AUTOOP).
pub fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> {
let k = key(account);

View file

@ -15,6 +15,7 @@ pub enum Event {
CertRemoved { account: String, fp: String },
AccountEmailSet { account: String, email: Option<String> },
AccountGreetSet { account: String, greet: String },
AccountLanguageSet { account: String, language: Option<String> },
AccountAutoOpSet { account: String, on: bool },
AccountKillSet { account: String, on: bool },
AccountHideStatusSet { account: String, on: bool },
@ -186,6 +187,7 @@ impl Event {
| Event::CertRemoved { .. }
| Event::AccountEmailSet { .. }
| Event::AccountGreetSet { .. }
| Event::AccountLanguageSet { .. }
| Event::AccountAutoOpSet { .. }
| Event::AccountKillSet { .. }
| Event::AccountHideStatusSet { .. }
@ -358,6 +360,11 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
a.greet = greet;
}
}
Event::AccountLanguageSet { account, language } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
a.language = language;
}
}
Event::AccountAutoOpSet { account, on } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
a.no_autoop = !on;

View file

@ -112,6 +112,9 @@ pub struct Account {
// pseudoclient. Default (normal notice) is the zero value.
#[serde(default)]
pub snotice: bool,
// NickServ SET LANGUAGE: preferred reply language (None = the network default).
#[serde(default)]
pub language: Option<String>,
// Assigned vhost (HostServ), applied to the displayed host on identify.
#[serde(default)]
pub vhost: Option<Vhost>,
@ -1166,6 +1169,8 @@ pub struct Db {
// against accounts pushed in (via the gRPC Accounts API). Node-local config.
external_accounts: bool,
confusable_check: bool, // reject look-alike / mixed-script REGISTER names
default_language: String, // network-wide reply language for users with no preference
available_languages: Vec<String>, // language codes a user may pick with SET LANGUAGE
}
// A juped server: the held name, the fake server id we allocated for it, and why.
@ -1234,7 +1239,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
}
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true }
Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, default_language: "en".to_string(), available_languages: vec!["en".to_string()] }
}
/// Fold an entry authored by another node into the store — the services-side

View file

@ -355,6 +355,29 @@ impl Db {
self.confusable_check = on;
}
/// The network-wide reply language for users who haven't set a preference.
pub fn set_default_language(&mut self, lang: &str) {
if !lang.is_empty() {
self.default_language = lang.to_string();
}
}
pub fn default_language(&self) -> String {
self.default_language.clone()
}
/// The language codes a user may select (always includes the default).
pub fn set_available_languages(&mut self, langs: Vec<String>) {
self.available_languages = langs;
if !self.available_languages.contains(&self.default_language) {
self.available_languages.push(self.default_language.clone());
}
}
pub fn available_languages(&self) -> &[String] {
&self.available_languages
}
pub fn confusable_check_enabled(&self) -> bool {
self.confusable_check
}

View file

@ -105,6 +105,18 @@ impl Store for Db {
fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError> {
Db::set_greet(self, account, greet)
}
fn set_language(&mut self, account: &str, language: Option<String>) -> Result<(), RegError> {
Db::set_language(self, account, language)
}
fn language_of(&self, account: &str) -> Option<String> {
Db::language_of(self, account)
}
fn available_languages(&self) -> Vec<String> {
Db::available_languages(self).to_vec()
}
fn default_language(&self) -> String {
Db::default_language(self)
}
fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> {
Db::set_account_autoop(self, account, on)
}

View file

@ -32,7 +32,7 @@
// which of the two competing registrations we're looking at.
let alice = |tag: &str, ts: u64, home: &str| Account {
name: "alice".into(), email: Some(tag.into()),
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None,
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None,
};
let converge = |first: &Account, second: &Account| {
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default());
@ -81,7 +81,7 @@
scram256: Some(verifier.into()), scram512: None, certfps: vec![], verified: true,
ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false,
hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false,
hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, noexpire: false,
expiry_warned: false, oper_note: None,
};
let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 0, 1, Event::AccountRegistered(Box::new(a)));
@ -439,7 +439,7 @@
db.register("alice", "pw", None).unwrap();
let bob = Account {
name: "bob".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
};
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, epoch: 0, sig: None, event: Event::AccountRegistered(Box::new(bob)) };
db.ingest(entry).unwrap();
@ -550,7 +550,7 @@
scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![],
suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false,
hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 1,
hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 1,
noexpire: false, expiry_warned: false, oper_note: None,
}))).unwrap();
}

View file

@ -7,7 +7,10 @@ impl Engine {
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
let account = self.network.account_of(from).map(str::to_string);
let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default();
let mut ctx = ServiceCtx::default();
// Render this command's replies in the sender's language: their account
// preference if set, otherwise the network default.
let lang = account.as_deref().and_then(|a| self.db.language_of(a)).unwrap_or_else(|| self.db.default_language());
let mut ctx = ServiceCtx { lang, ..Default::default() };
// Mark the log so we can tell exactly which events this command appends.
let audit_mark = self.db.log_len();
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };

View file

@ -1935,7 +1935,7 @@ fn audit_summary(event: &db::Event) -> Option<String> {
format!("{verb} channel \x02{channel}\x02 against expiry")
}
// Private, self-service, or cosmetic — not surfaced.
AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | AccountAutoOpSet { .. } | AccountKillSet { .. } | AccountHideStatusSet { .. } | AccountSnoticeSet { .. } | VhostRequested { .. }
AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | AccountLanguageSet { .. } | AccountAutoOpSet { .. } | AccountKillSet { .. } | AccountHideStatusSet { .. } | AccountSnoticeSet { .. } | VhostRequested { .. }
| VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. }
| MemoIgnoreAdd { .. } | MemoIgnoreDel { .. } | MemoPrefsSet { .. }
| ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelUrlSet { .. } | ChannelEmailSet { .. } | ChannelSettingsSet { .. }

View file

@ -418,6 +418,42 @@
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() })
}
// SET LANGUAGE stores the preference and switches replies to that language:
// the confirmation renders in the newly-chosen language, and a later command
// resolves the language from the account via the dispatch path.
#[test]
fn set_language_switches_reply_language() {
use echo_nickserv::NickServ;
// Minimal French catalog for this test process. English tests are unaffected:
// they resolve to "en" and fall back to the English source string.
let mut fr = std::collections::HashMap::new();
fr.insert("Your language is now \x02{lang}\x02.".to_string(), "Votre langue est maintenant \x02{lang}\x02.".to_string());
fr.insert(
"Your language is \x02{lang}\x02. Available: {list}. Change it with \x02SET LANGUAGE <code>\x02.".to_string(),
"Votre langue est \x02{lang}\x02. Disponibles : {list}.".to_string(),
);
echo_api::load_catalog(std::collections::HashMap::from([("fr".to_string(), fr)]));
let path = std::env::temp_dir().join("echo-setlang.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.set_available_languages(vec!["fr".to_string()]);
db.register("bob", "password1", None).unwrap();
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
let out = ns(&mut e, "SET LANGUAGE fr");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Votre langue est maintenant"))), "confirmation is French: {out:?}");
assert_eq!(e.db.language_of("bob").as_deref(), Some("fr"), "the preference is stored");
// A later command now resolves to French via the dispatch path (bob's pref).
let out2 = ns(&mut e, "SET LANGUAGE");
assert!(out2.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Disponibles"))), "later reply is French via ctx.lang: {out2:?}");
}
// A guest rename must skip a nick that's already online, or it would collide
// with (and get) an innocent user instead of freeing the name.
#[test]
@ -902,7 +938,7 @@
// An earlier claim from another node wins and takes the name over.
let winner = db::Account {
name: "alice".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
};
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)));
e.gossip_ingest(entry).unwrap();

View file

@ -128,6 +128,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::CertRemoved { .. }
| Event::AccountPasswordSet { .. }
| Event::AccountGreetSet { .. }
| Event::AccountLanguageSet { .. }
| Event::AccountAutoOpSet { .. }
| Event::AccountKillSet { .. }
| Event::AccountHideStatusSet { .. }
@ -493,7 +494,7 @@ mod tests {
memo_ignore: vec![],
memo_notify: true,
memo_limit: None,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false,
greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None,
vhost: None,
vhost_request: None,
last_seen: 111,

View file

@ -209,6 +209,29 @@ async fn main() -> Result<()> {
db.set_email_enabled(cfg.email.is_some());
db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
db.set_confusable_check(cfg.register.confusable_check);
if let Some(lang) = &cfg.language {
db.set_default_language(&lang.default);
db.set_available_languages(lang.available.clone());
// Load a JSON catalog (english msgid -> translation) per non-English code.
let mut catalog = std::collections::HashMap::new();
for code in db.available_languages().to_vec() {
if code == "en" {
continue; // English needs no catalog: the msgid is the English text
}
let path = format!("{}/{}.json", lang.dir, code);
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<std::collections::HashMap<String, String>>(&data) {
Ok(map) => {
tracing::info!(code = %code, entries = map.len(), "loaded translation catalog");
catalog.insert(code, map);
}
Err(e) => tracing::error!(%e, %path, "invalid translation catalog JSON"),
},
Err(e) => tracing::warn!(%e, %path, "translation catalog not found"),
}
}
echo_api::load_catalog(catalog);
}
match cfg.extban.as_ref().map(|e| e.enabled.as_slice()) {
Some(list) if !list.is_empty() => {
db.set_extban_enabled(list.to_vec());

View file

@ -204,7 +204,7 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
greet: String::new(),
no_autoop: !flag(nc, "AUTOOP"),
no_protect: !flag(nc, "PROTECT"),
hide_status: flag(nc, "HIDE_MASK"), snotice: false,
hide_status: flag(nc, "HIDE_MASK"), snotice: false, language: None,
vhost,
vhost_request: None,
last_seen: last_seen_of.get(name).copied().unwrap_or(ts),