Add i18n framework: per-account language, catalogs, t! macro, SET LANGUAGE
All checks were successful
CI / check (push) Successful in 5m16s
All checks were successful
CI / check (push) Successful in 5m16s
This commit is contained in:
parent
83851c794d
commit
b462d37bd5
16 changed files with 272 additions and 12 deletions
|
|
@ -473,6 +473,53 @@ pub struct Sender<'a> {
|
|||
pub privs: Privs,
|
||||
}
|
||||
|
||||
// ── Localization (i18n) ─────────────────────────────────────────────────────
|
||||
// Messages are authored in English at the call site (the English string is the
|
||||
// message id). A per-language catalog maps that id to a translation; `render`
|
||||
// looks it up for the target language and substitutes `{name}` placeholders. With
|
||||
// no catalog loaded, or no entry, the English id is used verbatim — so English is
|
||||
// always the zero-config fallback and untranslated messages degrade gracefully.
|
||||
static CATALOG: std::sync::OnceLock<std::collections::HashMap<String, std::collections::HashMap<String, String>>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install the translation catalog: `lang code -> (english msgid -> translation)`.
|
||||
/// Called once at startup; a no-op if already set.
|
||||
pub fn load_catalog(catalog: std::collections::HashMap<String, std::collections::HashMap<String, String>>) {
|
||||
let _ = CATALOG.set(catalog);
|
||||
}
|
||||
|
||||
/// Render `msgid` (the English template) into `lang`, substituting `{name}`
|
||||
/// placeholders from `args`. Falls back to the English `msgid` when there is no
|
||||
/// translation. Used via the [`t!`] macro; rarely called directly.
|
||||
pub fn render(lang: &str, msgid: &str, args: &[(&str, String)]) -> String {
|
||||
let template = CATALOG
|
||||
.get()
|
||||
.and_then(|c| c.get(lang))
|
||||
.and_then(|m| m.get(msgid))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(msgid);
|
||||
if args.is_empty() {
|
||||
return template.to_string();
|
||||
}
|
||||
let mut out = template.to_string();
|
||||
for (name, val) in args {
|
||||
out = out.replace(&format!("{{{name}}}"), val);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Localize a reply. `t!(ctx, "english template", name = value, …)` looks the
|
||||
/// template up for `ctx`'s language and fills its `{name}` placeholders. The
|
||||
/// English string IS the message id, so it also reads as the source text.
|
||||
#[macro_export]
|
||||
macro_rules! t {
|
||||
($ctx:expr, $msgid:literal $(,)?) => {
|
||||
$crate::render($ctx.lang(), $msgid, &[])
|
||||
};
|
||||
($ctx:expr, $msgid:literal, $($name:ident = $val:expr),+ $(,)?) => {
|
||||
$crate::render($ctx.lang(), $msgid, &[$((stringify!($name), format!("{}", $val))),+])
|
||||
};
|
||||
}
|
||||
|
||||
/// The intent sink a service writes to. A service never mutates the network or
|
||||
/// the store itself; it pushes normalized actions (via [`ServiceCtx::notice`]
|
||||
/// and friends) that the engine drains and applies.
|
||||
|
|
@ -492,6 +539,10 @@ pub struct ServiceCtx {
|
|||
// module can report a reject the engine's verify path never sees — e.g. a
|
||||
// failed IDENTIFY against a cert-only account with no password verifier.
|
||||
pub auth_reports: Vec<AuthReport>,
|
||||
// The language to render this command's replies in (the sender's account
|
||||
// preference, else the network default). Set by the engine before dispatch;
|
||||
// empty means English. Read via [`ServiceCtx::lang`] / the `t!` macro.
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
// A login-attempt outcome a module hands back for the auth audit feed.
|
||||
|
|
@ -505,6 +556,15 @@ pub struct AuthReport {
|
|||
}
|
||||
|
||||
impl ServiceCtx {
|
||||
/// The language replies should render in (defaults to English).
|
||||
pub fn lang(&self) -> &str {
|
||||
if self.lang.is_empty() {
|
||||
"en"
|
||||
} else {
|
||||
&self.lang
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notice(&mut self, from: &str, to: &str, text: impl Into<String>) {
|
||||
self.actions.push(NetAction::Notice {
|
||||
from: from.to_string(),
|
||||
|
|
@ -1948,6 +2008,10 @@ pub trait Store {
|
|||
fn verify_account(&mut self, account: &str) -> Result<(), RegError>;
|
||||
fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError>;
|
||||
fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError>;
|
||||
fn set_language(&mut self, account: &str, language: Option<String>) -> Result<(), RegError>;
|
||||
fn language_of(&self, account: &str) -> Option<String>;
|
||||
fn available_languages(&self) -> Vec<String>;
|
||||
fn default_language(&self) -> String;
|
||||
// NickServ SET AUTOOP: whether this account is auto-opped on join.
|
||||
fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError>;
|
||||
fn account_wants_autoop(&self, account: &str) -> bool;
|
||||
|
|
@ -2798,6 +2862,22 @@ mod help_tests {
|
|||
assert!(texts(&ctx)[0].contains("No help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_translates_substitutes_and_falls_back() {
|
||||
// No/other catalog: the English msgid is used, with args substituted.
|
||||
assert_eq!(render("en", "Hello \x02{name}\x02!", &[("name", "world".into())]), "Hello \x02world\x02!");
|
||||
// Load a French catalog, then translate + substitute.
|
||||
let mut fr = std::collections::HashMap::new();
|
||||
fr.insert("Your language is now \x02{lang}\x02.".to_string(), "Votre langue est désormais \x02{lang}\x02.".to_string());
|
||||
load_catalog(std::collections::HashMap::from([("fr".to_string(), fr)]));
|
||||
assert_eq!(
|
||||
render("fr", "Your language is now \x02{lang}\x02.", &[("lang", "fr".into())]),
|
||||
"Votre langue est désormais \x02fr\x02."
|
||||
);
|
||||
// An untranslated id still falls back to the English source even in fr.
|
||||
assert_eq!(render("fr", "not translated", &[]), "not translated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_email_rejects_header_injection() {
|
||||
assert!(valid_email("alice@example.com"));
|
||||
|
|
|
|||
6
lang/fr.json
Normal file
6
lang/fr.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"Your language is \u0002{lang}\u0002. Available: {list}. Change it with \u0002SET LANGUAGE <code>\u0002.": "Votre langue est \u0002{lang}\u0002. Disponibles : {list}. Changez-la avec \u0002SET LANGUAGE <code>\u0002.",
|
||||
"\u0002{code}\u0002 isn't an available language. Available: {list}.": "\u0002{code}\u0002 n'est pas une langue disponible. Disponibles : {list}.",
|
||||
"Your language is now \u0002{lang}\u0002.": "Votre langue est maintenant \u0002{lang}\u0002.",
|
||||
"Sorry, that didn't work. Please try again in a moment.": "Désolé, une erreur est survenue. Veuillez réessayer dans un instant."
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
use echo_api::{ForbidKind, Store};
|
||||
use echo_api::{Sender, ServiceCtx};
|
||||
use echo_api::t;
|
||||
|
||||
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
|
|
@ -45,6 +46,27 @@ 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("LANGUAGE") | Some("LANG") => {
|
||||
let available = db.available_languages();
|
||||
match args.get(2) {
|
||||
None => {
|
||||
let current = db.language_of(account).unwrap_or_else(|| db.default_language());
|
||||
ctx.notice(me, from.uid, t!(ctx, "Your language is \x02{lang}\x02. Available: {list}. Change it with \x02SET LANGUAGE <code>\x02.", lang = current, list = available.join(", ")));
|
||||
}
|
||||
Some(&code) => {
|
||||
let code = code.to_ascii_lowercase();
|
||||
if !available.contains(&code) {
|
||||
ctx.notice(me, from.uid, t!(ctx, "\x02{code}\x02 isn't an available language. Available: {list}.", code = code, list = available.join(", ")));
|
||||
return;
|
||||
}
|
||||
match db.set_language(account, Some(code.clone())) {
|
||||
// Confirm in the NEWLY chosen language, so it's visibly applied.
|
||||
Ok(()) => ctx.notice(me, from.uid, echo_api::render(&code, "Your language is now \x02{lang}\x02.", &[("lang", code.clone())])),
|
||||
Err(_) => ctx.notice(me, from.uid, t!(ctx, "Sorry, that didn't work. Please try again in a moment.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("HIDE") => {
|
||||
// Anope grammar is SET HIDE <field> {ON|OFF}. The only field that is
|
||||
// public here is the last-seen/online line (STATUS); USERMASK is an
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 { .. }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
src/main.rs
23
src/main.rs
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue