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,
|
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 intent sink a service writes to. A service never mutates the network or
|
||||||
/// the store itself; it pushes normalized actions (via [`ServiceCtx::notice`]
|
/// the store itself; it pushes normalized actions (via [`ServiceCtx::notice`]
|
||||||
/// and friends) that the engine drains and applies.
|
/// 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
|
// 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.
|
// failed IDENTIFY against a cert-only account with no password verifier.
|
||||||
pub auth_reports: Vec<AuthReport>,
|
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.
|
// A login-attempt outcome a module hands back for the auth audit feed.
|
||||||
|
|
@ -505,6 +556,15 @@ pub struct AuthReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServiceCtx {
|
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>) {
|
pub fn notice(&mut self, from: &str, to: &str, text: impl Into<String>) {
|
||||||
self.actions.push(NetAction::Notice {
|
self.actions.push(NetAction::Notice {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
|
|
@ -1948,6 +2008,10 @@ pub trait Store {
|
||||||
fn verify_account(&mut self, account: &str) -> Result<(), RegError>;
|
fn verify_account(&mut self, account: &str) -> Result<(), RegError>;
|
||||||
fn set_email(&mut self, account: &str, email: Option<String>) -> 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_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.
|
// NickServ SET AUTOOP: whether this account is auto-opped on join.
|
||||||
fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError>;
|
fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError>;
|
||||||
fn account_wants_autoop(&self, account: &str) -> bool;
|
fn account_wants_autoop(&self, account: &str) -> bool;
|
||||||
|
|
@ -2798,6 +2862,22 @@ mod help_tests {
|
||||||
assert!(texts(&ctx)[0].contains("No help"));
|
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]
|
#[test]
|
||||||
fn valid_email_rejects_header_injection() {
|
fn valid_email_rejects_header_injection() {
|
||||||
assert!(valid_email("alice@example.com"));
|
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::{ForbidKind, Store};
|
||||||
use echo_api::{Sender, ServiceCtx};
|
use echo_api::{Sender, ServiceCtx};
|
||||||
|
use echo_api::t;
|
||||||
|
|
||||||
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
|
// 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) {
|
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."),
|
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") => {
|
Some("HIDE") => {
|
||||||
// Anope grammar is SET HIDE <field> {ON|OFF}. The only field that is
|
// 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
|
// 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).
|
// start. Bind to localhost; unauthenticated by design (read-only gauges).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub health: Option<Health>,
|
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
|
// Which service modules to start. Absent = the full standard suite (all the
|
||||||
// pseudo-clients); listing it trims that set. Every service is first-class.
|
// pseudo-clients); listing it trims that set. Every service is first-class.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -63,6 +68,27 @@ pub struct Config {
|
||||||
pub extban: Option<Extban>,
|
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)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Extban {
|
pub struct Extban {
|
||||||
// Enabled extban names ("account", "realname", "country", …). Empty = all.
|
// Enabled extban names ("account", "realname", "country", …). Empty = all.
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ impl Db {
|
||||||
ajoin: Vec::new(),
|
ajoin: Vec::new(),
|
||||||
suspension: None,
|
suspension: None,
|
||||||
memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: 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: None,
|
||||||
vhost_request: None,
|
vhost_request: None,
|
||||||
last_seen: ts,
|
last_seen: ts,
|
||||||
|
|
@ -78,7 +78,7 @@ impl Db {
|
||||||
ajoin: Vec::new(),
|
ajoin: Vec::new(),
|
||||||
suspension: None,
|
suspension: None,
|
||||||
memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: 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: None,
|
||||||
vhost_request: None,
|
vhost_request: None,
|
||||||
last_seen: ts,
|
last_seen: ts,
|
||||||
|
|
@ -524,6 +524,22 @@ impl Db {
|
||||||
Ok(())
|
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).
|
/// 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> {
|
pub fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> {
|
||||||
let k = key(account);
|
let k = key(account);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub enum Event {
|
||||||
CertRemoved { account: String, fp: String },
|
CertRemoved { account: String, fp: String },
|
||||||
AccountEmailSet { account: String, email: Option<String> },
|
AccountEmailSet { account: String, email: Option<String> },
|
||||||
AccountGreetSet { account: String, greet: String },
|
AccountGreetSet { account: String, greet: String },
|
||||||
|
AccountLanguageSet { account: String, language: Option<String> },
|
||||||
AccountAutoOpSet { account: String, on: bool },
|
AccountAutoOpSet { account: String, on: bool },
|
||||||
AccountKillSet { account: String, on: bool },
|
AccountKillSet { account: String, on: bool },
|
||||||
AccountHideStatusSet { account: String, on: bool },
|
AccountHideStatusSet { account: String, on: bool },
|
||||||
|
|
@ -186,6 +187,7 @@ impl Event {
|
||||||
| Event::CertRemoved { .. }
|
| Event::CertRemoved { .. }
|
||||||
| Event::AccountEmailSet { .. }
|
| Event::AccountEmailSet { .. }
|
||||||
| Event::AccountGreetSet { .. }
|
| Event::AccountGreetSet { .. }
|
||||||
|
| Event::AccountLanguageSet { .. }
|
||||||
| Event::AccountAutoOpSet { .. }
|
| Event::AccountAutoOpSet { .. }
|
||||||
| Event::AccountKillSet { .. }
|
| Event::AccountKillSet { .. }
|
||||||
| Event::AccountHideStatusSet { .. }
|
| Event::AccountHideStatusSet { .. }
|
||||||
|
|
@ -358,6 +360,11 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
a.greet = greet;
|
a.greet = greet;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::AccountLanguageSet { account, language } => {
|
||||||
|
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||||
|
a.language = language;
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::AccountAutoOpSet { account, on } => {
|
Event::AccountAutoOpSet { account, on } => {
|
||||||
if let Some(a) = accounts.get_mut(&key(&account)) {
|
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||||
a.no_autoop = !on;
|
a.no_autoop = !on;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,9 @@ pub struct Account {
|
||||||
// pseudoclient. Default (normal notice) is the zero value.
|
// pseudoclient. Default (normal notice) is the zero value.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub snotice: bool,
|
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.
|
// Assigned vhost (HostServ), applied to the displayed host on identify.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vhost: Option<Vhost>,
|
pub vhost: Option<Vhost>,
|
||||||
|
|
@ -1166,6 +1169,8 @@ pub struct Db {
|
||||||
// against accounts pushed in (via the gRPC Accounts API). Node-local config.
|
// against accounts pushed in (via the gRPC Accounts API). Node-local config.
|
||||||
external_accounts: bool,
|
external_accounts: bool,
|
||||||
confusable_check: bool, // reject look-alike / mixed-script REGISTER names
|
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.
|
// 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);
|
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");
|
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
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,29 @@ impl Db {
|
||||||
self.confusable_check = on;
|
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 {
|
pub fn confusable_check_enabled(&self) -> bool {
|
||||||
self.confusable_check
|
self.confusable_check
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,18 @@ impl Store for Db {
|
||||||
fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError> {
|
fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError> {
|
||||||
Db::set_greet(self, account, greet)
|
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> {
|
fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> {
|
||||||
Db::set_account_autoop(self, account, on)
|
Db::set_account_autoop(self, account, on)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
// which of the two competing registrations we're looking at.
|
// which of the two competing registrations we're looking at.
|
||||||
let alice = |tag: &str, ts: u64, home: &str| Account {
|
let alice = |tag: &str, ts: u64, home: &str| Account {
|
||||||
name: "alice".into(), email: Some(tag.into()),
|
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 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());
|
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,
|
scram256: Some(verifier.into()), scram512: None, certfps: vec![], verified: true,
|
||||||
ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: 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,
|
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,
|
expiry_warned: false, oper_note: None,
|
||||||
};
|
};
|
||||||
let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 0, 1, Event::AccountRegistered(Box::new(a)));
|
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();
|
db.register("alice", "pw", None).unwrap();
|
||||||
let bob = Account {
|
let bob = Account {
|
||||||
name: "bob".into(), email: None,
|
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)) };
|
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, epoch: 0, sig: None, event: Event::AccountRegistered(Box::new(bob)) };
|
||||||
db.ingest(entry).unwrap();
|
db.ingest(entry).unwrap();
|
||||||
|
|
@ -550,7 +550,7 @@
|
||||||
scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![],
|
scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![],
|
||||||
suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
|
suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
|
||||||
memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false,
|
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,
|
noexpire: false, expiry_warned: false, oper_note: None,
|
||||||
}))).unwrap();
|
}))).unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ impl Engine {
|
||||||
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
|
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
|
||||||
let account = self.network.account_of(from).map(str::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 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.
|
// Mark the log so we can tell exactly which events this command appends.
|
||||||
let audit_mark = self.db.log_len();
|
let audit_mark = self.db.log_len();
|
||||||
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };
|
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")
|
format!("{verb} channel \x02{channel}\x02 against expiry")
|
||||||
}
|
}
|
||||||
// Private, self-service, or cosmetic — not surfaced.
|
// 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 { .. }
|
| VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. }
|
||||||
| MemoIgnoreAdd { .. } | MemoIgnoreDel { .. } | MemoPrefsSet { .. }
|
| MemoIgnoreAdd { .. } | MemoIgnoreDel { .. } | MemoPrefsSet { .. }
|
||||||
| ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelUrlSet { .. } | ChannelEmailSet { .. } | ChannelSettingsSet { .. }
|
| ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelUrlSet { .. } | ChannelEmailSet { .. } | ChannelSettingsSet { .. }
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,42 @@
|
||||||
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() })
|
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
|
// 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.
|
// with (and get) an innocent user instead of freeing the name.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -902,7 +938,7 @@
|
||||||
// An earlier claim from another node wins and takes the name over.
|
// An earlier claim from another node wins and takes the name over.
|
||||||
let winner = db::Account {
|
let winner = db::Account {
|
||||||
name: "alice".into(), email: None,
|
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)));
|
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)));
|
||||||
e.gossip_ingest(entry).unwrap();
|
e.gossip_ingest(entry).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::CertRemoved { .. }
|
| Event::CertRemoved { .. }
|
||||||
| Event::AccountPasswordSet { .. }
|
| Event::AccountPasswordSet { .. }
|
||||||
| Event::AccountGreetSet { .. }
|
| Event::AccountGreetSet { .. }
|
||||||
|
| Event::AccountLanguageSet { .. }
|
||||||
| Event::AccountAutoOpSet { .. }
|
| Event::AccountAutoOpSet { .. }
|
||||||
| Event::AccountKillSet { .. }
|
| Event::AccountKillSet { .. }
|
||||||
| Event::AccountHideStatusSet { .. }
|
| Event::AccountHideStatusSet { .. }
|
||||||
|
|
@ -493,7 +494,7 @@ mod tests {
|
||||||
memo_ignore: vec![],
|
memo_ignore: vec![],
|
||||||
memo_notify: true,
|
memo_notify: true,
|
||||||
memo_limit: None,
|
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: None,
|
||||||
vhost_request: None,
|
vhost_request: None,
|
||||||
last_seen: 111,
|
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_email_enabled(cfg.email.is_some());
|
||||||
db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
|
db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
|
||||||
db.set_confusable_check(cfg.register.confusable_check);
|
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()) {
|
match cfg.extban.as_ref().map(|e| e.enabled.as_slice()) {
|
||||||
Some(list) if !list.is_empty() => {
|
Some(list) if !list.is_empty() => {
|
||||||
db.set_extban_enabled(list.to_vec());
|
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(),
|
greet: String::new(),
|
||||||
no_autoop: !flag(nc, "AUTOOP"),
|
no_autoop: !flag(nc, "AUTOOP"),
|
||||||
no_protect: !flag(nc, "PROTECT"),
|
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,
|
||||||
vhost_request: None,
|
vhost_request: None,
|
||||||
last_seen: last_seen_of.get(name).copied().unwrap_or(ts),
|
last_seen: last_seen_of.get(name).copied().unwrap_or(ts),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue