i18n: per-network locale (en/es/fr) for karma replies and the web board
All checks were successful
ci / check (push) Successful in 45s

This commit is contained in:
Jean Chevronnet 2026-07-30 00:31:17 +00:00
parent f290554325
commit 739fc8ad94
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
11 changed files with 406 additions and 115 deletions

View file

@ -90,7 +90,7 @@ over TLS.
```sh ```sh
cargo build --release --bin karma-web cargo build --release --bin karma-web
./target/release/karma-web <data-dir> <out.html> # omit out.html to print to stdout ./target/release/karma-web <data-dir> <out.html> [locale] # locale: en|es|fr (default en)
``` ```
On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`, On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`,
@ -123,8 +123,24 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default `channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
on), `password`, `sasl_user`, `sasl_pass`, and `name` (a network's log label). on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. `locale` (`en`/`es`/`fr`, default `en`). Whole-line `#`/`;` comments only —
inline comments would clash with channel `#`s.
### Locales
`locale` sets a network's language, so a community sees the bot in its own
tongue — e.g. `#argentina` on libera runs in Spanish. It currently drives the
`karma` module's replies and milestone announcements (English, Spanish, French);
other modules fall back to English. The web board takes its own locale as
`karma-web`'s third argument (or `RUSTBOT_LOCALE`), so the page can match the
audience.
```ini
[libera]
channels = #argentina
locale = es # karma replies + milestones in Spanish
```
Web modules write per-network state under `RUSTBOT_DATA_DIR` (default `.`). Web modules write per-network state under `RUSTBOT_DATA_DIR` (default `.`).

View file

@ -2,6 +2,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, render, Board}; use rustbot::karma_web::{parse_board, parse_events, render, Board};
fn main() { fn main() {
@ -11,6 +12,11 @@ fn main() {
.or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok()) .or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok())
.unwrap_or_else(|| ".".to_string()); .unwrap_or_else(|| ".".to_string());
let out = args.next(); let out = args.next();
let locale = args
.next()
.or_else(|| std::env::var("RUSTBOT_LOCALE").ok())
.map(|s| Locale::from_code(&s))
.unwrap_or_default();
let mut boards: Vec<Board> = Vec::new(); let mut boards: Vec<Board> = Vec::new();
if let Ok(rd) = fs::read_dir(&dir) { if let Ok(rd) = fs::read_dir(&dir) {
@ -38,7 +44,7 @@ fn main() {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let html = render(&boards, epoch); let html = render(&boards, epoch, locale);
match out { match out {
Some(path) => { Some(path) => {

View file

@ -28,7 +28,7 @@ impl Bot {
pub fn new(config: Config, conn: Connection) -> Bot { pub fn new(config: Config, conn: Connection) -> Bot {
let current_nick = config.nick.clone(); let current_nick = config.nick.clone();
let mods = modules::all(&config.name); let mods = modules::all(&config.name, crate::i18n::Locale::from_code(&config.locale));
let mut routes: HashMap<&'static str, usize> = HashMap::new(); let mut routes: HashMap<&'static str, usize> = HashMap::new();
for (i, m) in mods.iter().enumerate() { for (i, m) in mods.iter().enumerate() {
for spec in m.commands() { for spec in m.commands() {

View file

@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module}; use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
use crate::i18n::Locale;
use crate::json::{self, Json}; use crate::json::{self, Json};
const COMMANDS: &[CommandSpec] = &[ const COMMANDS: &[CommandSpec] = &[
@ -29,21 +30,30 @@ const ZWSP: char = '\u{200B}';
pub struct Karma { pub struct Karma {
store: Store, store: Store,
locale: Locale,
} }
impl Karma { impl Karma {
pub fn new(network: &str) -> Karma { pub fn new(network: &str, locale: Locale) -> Karma {
let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string());
let safe: String = network let safe: String = network
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect(); .collect();
Karma::with_path(PathBuf::from(dir).join(format!("karma.{safe}.json"))) Karma::with_path_locale(
PathBuf::from(dir).join(format!("karma.{safe}.json")),
locale,
)
} }
pub fn with_path(path: PathBuf) -> Karma { pub fn with_path(path: PathBuf) -> Karma {
Karma::with_path_locale(path, Locale::En)
}
pub fn with_path_locale(path: PathBuf, locale: Locale) -> Karma {
Karma { Karma {
store: Store::load(path), store: Store::load(path),
locale,
} }
} }
@ -52,19 +62,30 @@ impl Karma {
} }
fn report(&self, thing: &str) -> String { fn report(&self, thing: &str) -> String {
let dh = dehighlight(thing);
let Some(e) = self.store.entry(thing) else { let Some(e) = self.store.entry(thing) else {
return format!("no karma for '{}' yet", dehighlight(thing)); return match self.locale {
Locale::En => format!("no karma for '{dh}' yet"),
Locale::Es => format!("todavía no hay karma para '{dh}'"),
Locale::Fr => format!("pas encore de karma pour '{dh}'"),
};
}; };
let (rank, total) = self.store.rank(thing); let (rank, total) = self.store.rank(thing);
let mut out = format!( let n = e.count;
"{} has {} karma (#{rank} of {total})", let mut out = match self.locale {
dehighlight(thing), Locale::En => format!("{dh} has {n} karma (#{rank} of {total})"),
e.count Locale::Es => format!("{dh} tiene {n} de karma (#{rank} de {total})"),
); Locale::Fr => format!("{dh} a {n} karma (#{rank} sur {total})"),
};
if let Some(reason) = &e.reason { if let Some(reason) = &e.reason {
let last = match self.locale {
Locale::En => "last",
Locale::Es => "último",
Locale::Fr => "dernier",
};
match &e.by { match &e.by {
Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))), Some(by) => out.push_str(&format!("{last}: \"{reason}\" ({})", dehighlight(by))),
None => out.push_str(&format!(" — last: \"{reason}\"")), None => out.push_str(&format!("{last}: \"{reason}\"")),
} }
} }
out out
@ -73,17 +94,25 @@ impl Karma {
fn leaderboard(&self, bottom: bool) -> String { fn leaderboard(&self, bottom: bool) -> String {
let entries = self.store.ranked(TOP_N, bottom); let entries = self.store.ranked(TOP_N, bottom);
if entries.is_empty() { if entries.is_empty() {
return "no karma yet".to_string(); return match self.locale {
Locale::En => "no karma yet".to_string(),
Locale::Es => "todavía no hay karma".to_string(),
Locale::Fr => "pas encore de karma".to_string(),
};
} }
let items: Vec<String> = entries let items: Vec<String> = entries
.iter() .iter()
.map(|(k, v)| format!("{} ({v})", dehighlight(k))) .map(|(k, v)| format!("{} ({v})", dehighlight(k)))
.collect(); .collect();
format!( let label = match (self.locale, bottom) {
"karma {}: {}", (Locale::En, false) => "karma top",
if bottom { "bottom" } else { "top" }, (Locale::En, true) => "karma bottom",
items.join(", ") (Locale::Es, false) => "top de karma",
) (Locale::Es, true) => "peor karma",
(Locale::Fr, false) => "top karma",
(Locale::Fr, true) => "pire karma",
};
format!("{label}: {}", items.join(", "))
} }
} }
@ -127,10 +156,12 @@ impl Module for Karma {
.log_event(&format_event(t, thing, *delta, chat.sender, r)); .log_event(&format_event(t, thing, *delta, chat.sender, r));
let new = self.store.bump(thing, *delta, r, chat.sender); let new = self.store.bump(thing, *delta, r, chat.sender);
if *delta > 0 && MILESTONES.contains(&new) { if *delta > 0 && MILESTONES.contains(&new) {
announces.push(Action::reply(format!( let dh = dehighlight(thing);
"🎉 {} reached {new} karma!", announces.push(Action::reply(match self.locale {
dehighlight(thing) Locale::En => format!("🎉 {dh} reached {new} karma!"),
))); Locale::Es => format!("🎉 ¡{dh} alcanzó {new} de karma!"),
Locale::Fr => format!("🎉 {dh} a atteint {new} karma !"),
}));
} }
} }
if let Err(e) = self.store.save() { if let Err(e) = self.store.save() {
@ -188,7 +219,16 @@ pub fn extract_reason(text: &str) -> Option<String> {
} }
let mut reason = toks[bumps[0] + 1..].join(" "); let mut reason = toks[bumps[0] + 1..].join(" ");
reason = reason.trim().to_string(); reason = reason.trim().to_string();
for prefix in ["for ", "because ", "cause ", "b/c "] { for prefix in [
"for ",
"because ",
"cause ",
"b/c ", // en
"porque ",
"por ", // es
"parce que ",
"pour ", // fr
] {
if let Some(r) = reason.strip_prefix(prefix) { if let Some(r) = reason.strip_prefix(prefix) {
reason = r.to_string(); reason = r.to_string();
break; break;

View file

@ -11,13 +11,14 @@ pub mod weather;
pub mod wiki; pub mod wiki;
use super::module::Module; use super::module::Module;
use crate::i18n::Locale;
pub fn all(network: &str) -> Vec<Box<dyn Module>> { pub fn all(network: &str, locale: Locale) -> Vec<Box<dyn Module>> {
vec![ vec![
Box::new(builtins::Builtins), Box::new(builtins::Builtins),
Box::new(dice::Dice::new()), Box::new(dice::Dice::new()),
Box::new(hash::Hash), Box::new(hash::Hash),
Box::new(karma::Karma::new(network)), Box::new(karma::Karma::new(network, locale)),
Box::new(crypto::Crypto), Box::new(crypto::Crypto),
Box::new(define::Define), Box::new(define::Define),
Box::new(down::Down), Box::new(down::Down),

View file

@ -18,6 +18,7 @@ pub struct Config {
pub password: Option<String>, pub password: Option<String>,
pub sasl_user: Option<String>, pub sasl_user: Option<String>,
pub sasl_pass: Option<String>, pub sasl_pass: Option<String>,
pub locale: String,
} }
impl Default for Config { impl Default for Config {
@ -36,6 +37,7 @@ impl Default for Config {
password: None, password: None,
sasl_user: None, sasl_user: None,
sasl_pass: None, sasl_pass: None,
locale: "en".to_string(),
} }
} }
} }
@ -160,6 +162,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
"password" => c.password = (!value.is_empty()).then(|| value.to_string()), "password" => c.password = (!value.is_empty()).then(|| value.to_string()),
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
"locale" | "lang" => c.locale = value.to_string(),
other => log(&format!("{where_}: unknown key '{other}'")), other => log(&format!("{where_}: unknown key '{other}'")),
} }
} }
@ -217,6 +220,9 @@ fn apply_env(c: &mut Config) {
if let Ok(v) = std::env::var("IRC_SASL_PASS") { if let Ok(v) = std::env::var("IRC_SASL_PASS") {
c.sasl_pass = Some(v); c.sasl_pass = Some(v);
} }
if let Ok(v) = std::env::var("IRC_LOCALE") {
c.locale = v;
}
} }
fn parse_bool(value: &str) -> bool { fn parse_bool(value: &str) -> bool {

33
src/i18n.rs Normal file
View file

@ -0,0 +1,33 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Locale {
#[default]
En,
Es,
Fr,
}
impl Locale {
/// Accepts codes like `es`, `ES`, `es-AR`, `fr_FR`; anything unknown is English.
pub fn from_code(s: &str) -> Locale {
let base = s
.trim()
.to_ascii_lowercase()
.split(['-', '_'])
.next()
.unwrap_or("")
.to_string();
match base.as_str() {
"es" => Locale::Es,
"fr" => Locale::Fr,
_ => Locale::En,
}
}
pub fn code(self) -> &'static str {
match self {
Locale::En => "en",
Locale::Es => "es",
Locale::Fr => "fr",
}
}
}

View file

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::json::Json; use crate::i18n::Locale;
use crate::json::{self, Json};
pub struct Row { pub struct Row {
pub thing: String, pub thing: String,
@ -253,7 +254,104 @@ fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec<Giver> {
v v
} }
pub fn render(boards: &[Board], updated_epoch: u64) -> String { /// The user-facing strings, one set per locale. IRC-supplied content (nicks,
/// reasons) is never translated — only the chrome around it.
struct Lex {
tagline: &'static str,
s_tracked: &'static str,
s_net: &'static str,
s_gifts: &'static str,
s_givers: &'static str,
network: &'static str,
ledger: &'static str,
recent: &'static str,
top_givers: &'static str,
gave: &'static str,
someone: &'static str,
gift_one: &'static str,
gift_many: &'static str,
empty_title: &'static str,
empty_sub: &'static str,
empty_activity: &'static str,
empty_givers: &'static str,
give: &'static str,
built: &'static str,
updated: &'static str,
}
fn lex(l: Locale) -> Lex {
match l {
Locale::En => Lex {
tagline: "reputation earned one <code>nick++</code> at a time, across the channels.",
s_tracked: "tracked",
s_net: "net karma",
s_gifts: "gifts logged",
s_givers: "givers",
network: "network",
ledger: "the ledger",
recent: "recent activity",
top_givers: "top givers",
gave: "gave",
someone: "someone",
gift_one: "gift",
gift_many: "gifts",
empty_title: "No karma yet.",
empty_sub: "Say <code>nick++</code> in the channel and this board comes alive.",
empty_activity: "Nothing yet. The next <code>nick++</code> shows up here.",
empty_givers: "No one has handed out karma yet. Be the first.",
give: "give karma by saying <code>nick++</code> in the channel",
built: "built by",
updated: "updated",
},
Locale::Es => Lex {
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
s_tracked: "seguidos",
s_net: "karma neto",
s_gifts: "regalos",
s_givers: "donantes",
network: "red",
ledger: "el ranking",
recent: "actividad reciente",
top_givers: "top donantes",
gave: "le dio a",
someone: "alguien",
gift_one: "regalo",
gift_many: "regalos",
empty_title: "Aún no hay karma.",
empty_sub: "Escribí <code>nick++</code> en el canal y este tablero cobra vida.",
empty_activity: "Todavía nada. El próximo <code>nick++</code> aparece acá.",
empty_givers: "Nadie repartió karma todavía. Sé el primero.",
give: "da karma escribiendo <code>nick++</code> en el canal",
built: "hecho con",
updated: "actualizado",
},
Locale::Fr => Lex {
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
s_tracked: "suivis",
s_net: "karma net",
s_gifts: "dons",
s_givers: "donneurs",
network: "réseau",
ledger: "le classement",
recent: "activité récente",
top_givers: "meilleurs donneurs",
gave: "a donné à",
someone: "quelqu'un",
gift_one: "don",
gift_many: "dons",
empty_title: "Pas encore de karma.",
empty_sub: "Dis <code>nick++</code> dans le canal et ce tableau prend vie.",
empty_activity: "Rien pour l'instant. Le prochain <code>nick++</code> apparaît ici.",
empty_givers: "Personne n'a encore donné de karma. Sois le premier.",
give: "donne du karma en disant <code>nick++</code> dans le canal",
built: "fait avec",
updated: "mis à jour",
},
}
}
pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
let lx = lex(locale);
let things: usize = boards.iter().map(|b| b.rows.len()).sum(); let things: usize = boards.iter().map(|b| b.rows.len()).sum();
let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum(); let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum();
@ -271,28 +369,24 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String {
let mut s = String::from(HEAD); let mut s = String::from(HEAD);
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1; let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
// hero
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>"); s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>"); s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
s.push_str( s.push_str(&format!("<p class=\"tag\">{}</p>", lx.tagline));
"<p class=\"tag\">reputation earned one <code>nick++</code> at a time, across the channels.</p>",
);
s.push_str("<div class=\"stats\">"); s.push_str("<div class=\"stats\">");
s.push_str(&stat(&things.to_string(), "tracked")); s.push_str(&stat(&things.to_string(), lx.s_tracked));
s.push_str(&stat(&signed(total_points), "net karma")); s.push_str(&stat(&signed(total_points), lx.s_net));
s.push_str(&stat(&gifts.to_string(), "gifts logged")); s.push_str(&stat(&gifts.to_string(), lx.s_gifts));
s.push_str(&stat(&givers_set.len().to_string(), "givers")); s.push_str(&stat(&givers_set.len().to_string(), lx.s_givers));
s.push_str("</div></header>"); s.push_str("</div></header>");
if things == 0 { if things == 0 {
s.push_str(EMPTY); s.push_str(&format!(
s.push_str(&footer(updated_epoch)); "<div class=\"empty fade\">{}<br>{}</div>",
s.push_str(SCRIPT); lx.empty_title, lx.empty_sub
s.push_str(TAIL); ));
return s; return finish(s, updated_epoch, locale, &lx);
} }
// per-network board(s): champion podium + the rest as a ledger
for b in boards { for b in boards {
if b.rows.is_empty() { if b.rows.is_empty() {
continue; continue;
@ -300,23 +394,31 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String {
s.push_str("<section class=\"board fade\">"); s.push_str("<section class=\"board fade\">");
if multi { if multi {
s.push_str(&format!( s.push_str(&format!(
"<div class=\"eyebrow\">network<span>{}</span></div>", "<div class=\"eyebrow\">{}<span>{}</span></div>",
lx.network,
esc(&b.network) esc(&b.network)
)); ));
} }
s.push_str(&podium(b)); s.push_str(&podium(b));
s.push_str(&ledger(b)); s.push_str(&ledger(b, &lx));
s.push_str("</section>"); s.push_str("</section>");
} }
// activity + givers band
s.push_str("<div class=\"band\">"); s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi)); s.push_str(&activity(&feed, multi, &lx));
s.push_str(&givers_panel(&feed)); s.push_str(&givers_panel(&feed, &lx));
s.push_str("</div>"); s.push_str("</div>");
s.push_str(&footer(updated_epoch)); finish(s, updated_epoch, locale, &lx)
s.push_str(SCRIPT); }
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
s.push_str(&footer(epoch, lx));
s.push_str("<script>var C=");
s.push_str(&time_cfg(locale));
s.push_str(";\n");
s.push_str(SCRIPT_BODY);
s.push_str("</script>");
s.push_str(TAIL); s.push_str(TAIL);
s s
} }
@ -363,11 +465,14 @@ fn podium(b: &Board) -> String {
out out
} }
fn ledger(b: &Board) -> String { fn ledger(b: &Board, lx: &Lex) -> String {
if b.rows.len() <= 3 { if b.rows.len() <= 3 {
return String::new(); return String::new();
} }
let mut out = String::from("<div class=\"eyebrow\">the ledger</div><ol class=\"list\">"); let mut out = format!(
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
lx.ledger
);
for (i, r) in b.rows.iter().enumerate().skip(3) { for (i, r) in b.rows.iter().enumerate().skip(3) {
let rank = i + 1; let rank = i + 1;
let spark = sparkline(&series_for(&r.thing, r.count, &b.events)); let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
@ -394,20 +499,22 @@ fn ledger(b: &Board) -> String {
out out
} }
fn activity(feed: &[(&str, &Event)], multi: bool) -> String { fn activity(feed: &[(&str, &Event)], multi: bool, lx: &Lex) -> String {
let mut out = String::from( let mut out = format!(
"<section class=\"panel fade\"><div class=\"eyebrow live-h\">\ "<section class=\"panel fade\"><div class=\"eyebrow live-h\">\
<span class=\"live\"></span>recent activity</div>", <span class=\"live\"></span>{}</div>",
lx.recent
); );
if feed.is_empty() { if feed.is_empty() {
out.push_str( out.push_str(&format!(
"<div class=\"empty-panel\">Nothing yet. The next <code>nick++</code> shows up here.</div></section>", "<div class=\"empty-panel\">{}</div></section>",
); lx.empty_activity
));
return out; return out;
} }
out.push_str("<ul class=\"timeline\">"); out.push_str("<ul class=\"timeline\">");
for (net, e) in feed.iter().take(40) { for (net, e) in feed.iter().take(40) {
let actor = e.by.clone().unwrap_or_else(|| "someone".to_string()); let actor = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
let dchip = if e.delta >= 0 { "++" } else { "--" }; let dchip = if e.delta >= 0 { "++" } else { "--" };
let why = match &e.why { let why = match &e.why {
Some(w) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)), Some(w) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
@ -420,12 +527,13 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
}; };
out.push_str(&format!( out.push_str(&format!(
"<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\ "<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\
<b>{}</b> <span class=\"verb\">gave</span> <b>{}</b> \ <b>{}</b> <span class=\"verb\">{}</span> <b>{}</b> \
<span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\ <span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\
<time class=\"ago\" data-t=\"{}\"></time></div></li>", <time class=\"ago\" data-t=\"{}\"></time></div></li>",
klass(e.delta), klass(e.delta),
avatar(&actor, "sm"), avatar(&actor, "sm"),
esc(&actor), esc(&actor),
lx.gave,
esc(&e.thing), esc(&e.thing),
klass(e.delta), klass(e.delta),
e.t, e.t,
@ -435,28 +543,35 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
out out
} }
fn givers_panel(feed: &[(&str, &Event)]) -> String { fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
let givers = top_givers(feed, 8); let givers = top_givers(feed, 8);
let mut out = let mut out = format!(
String::from("<section class=\"panel fade\"><div class=\"eyebrow\">top givers</div>"); "<section class=\"panel fade\"><div class=\"eyebrow\">{}</div>",
if givers.is_empty() { lx.top_givers
out.push_str(
"<div class=\"empty-panel\">No one has handed out karma yet. Be the first.</div></section>",
); );
if givers.is_empty() {
out.push_str(&format!(
"<div class=\"empty-panel\">{}</div></section>",
lx.empty_givers
));
return out; return out;
} }
out.push_str("<ol class=\"list givers\">"); out.push_str("<ol class=\"list givers\">");
for (i, g) in givers.iter().enumerate() { for (i, g) in givers.iter().enumerate() {
let unit = if g.gives == 1 {
lx.gift_one
} else {
lx.gift_many
};
out.push_str(&format!( out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{}</span>{}\ "<li class=\"row\"><span class=\"rank\">{}</span>{}\
<div class=\"who\"><div class=\"nick\">{}</div>\ <div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{} gift{}</span></div>\ <span class=\"why\">{} {unit}</span></div>\
<span class=\"score {}\">{}</span></li>", <span class=\"score {}\">{}</span></li>",
i + 1, i + 1,
avatar(&g.name, "sm"), avatar(&g.name, "sm"),
esc(&g.name), esc(&g.name),
g.gives, g.gives,
if g.gives == 1 { "" } else { "s" },
klass(g.points), klass(g.points),
signed(g.points), signed(g.points),
)); ));
@ -465,11 +580,41 @@ fn givers_panel(feed: &[(&str, &Event)]) -> String {
out out
} }
fn footer(epoch: u64) -> String { fn footer(epoch: u64, lx: &Lex) -> String {
format!( format!(
"<footer class=\"fade\">give karma by saying <code>nick++</code> in the channel \ "<footer class=\"fade\">{} &middot; {} \
&middot; built by <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \ <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
&middot; updated <time class=\"ago\" data-t=\"{epoch}\"></time></footer>" &middot; {} <time class=\"ago\" data-t=\"{epoch}\"></time></footer>",
lx.give, lx.built, lx.updated
)
}
fn time_cfg(l: Locale) -> String {
let (just, pre, post, u): (&str, &str, &str, [&str; 7]) = match l {
Locale::En => ("just now", "", " ago", ["y", "mo", "w", "d", "h", "m", "s"]),
Locale::Es => (
"ahora",
"hace ",
"",
["a", "mes", "sem", "d", "h", "min", "s"],
),
Locale::Fr => (
"à l'instant",
"il y a ",
"",
["a", "mois", "sem", "j", "h", "min", "s"],
),
};
let units = u
.iter()
.map(|x| format!("\"{}\"", json::escape(x)))
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"just\":\"{}\",\"pre\":\"{}\",\"post\":\"{}\",\"u\":[{units}]}}",
json::escape(just),
json::escape(pre),
json::escape(post)
) )
} }
@ -499,7 +644,6 @@ code{font-family:var(--mono);font-size:.88em;background:var(--panel2);border:1px
border-radius:6px;padding:1px 6px;color:var(--gold)} border-radius:6px;padding:1px 6px;color:var(--gold)}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px} :focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
/* hero */
.hero{text-align:center;padding:14px 0 30px} .hero{text-align:center;padding:14px 0 30px}
.prompt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px} .prompt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px}
.prompt::before{content:"$ ";color:var(--up)} .prompt::before{content:"$ ";color:var(--up)}
@ -507,7 +651,7 @@ border-radius:6px;padding:1px 6px;color:var(--gold)}
margin:0;letter-spacing:-.04em; margin:0;letter-spacing:-.04em;
background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent} background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent}
.cursor{color:var(--gold);-webkit-text-fill-color:var(--gold)} .cursor{color:var(--gold);-webkit-text-fill-color:var(--gold)}
.tag{color:var(--muted);max-width:44ch;margin:16px auto 0;text-wrap:balance} .tag{color:var(--muted);max-width:46ch;margin:16px auto 0;text-wrap:balance}
.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:26px} .stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:26px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:14px; .stat{background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px} padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px}
@ -518,7 +662,6 @@ padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px}
margin:34px 2px 14px;display:flex;align-items:center;gap:10px} margin:34px 2px 14px;display:flex;align-items:center;gap:10px}
.eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em} .eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em}
/* podium */
.podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end; .podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end;
gap:14px;flex-wrap:wrap} gap:14px;flex-wrap:wrap}
.pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line); .pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);
@ -533,24 +676,20 @@ box-shadow:0 0 0 1px rgba(245,182,66,.18),0 20px 50px -24px rgba(245,182,66,.4)}
.pillar .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px} .pillar .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px}
.pillar .by{color:var(--faint)} .pillar .by{color:var(--faint)}
/* avatars */
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%; .ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
font-family:var(--mono);font-weight:600;color:#fff;flex:none; font-family:var(--mono);font-weight:600;color:#fff;flex:none;
background:hsl(var(--h) 42% 42%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)} background:hsl(var(--h) 42% 42%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)}
.ava.lg{width:56px;height:56px;font-size:19px} .ava.lg{width:56px;height:56px;font-size:19px}
.ava.sm{width:34px;height:34px;font-size:13px} .ava.sm{width:34px;height:34px;font-size:13px}
/* score colors */
.score.up,.chip.up{color:var(--up)}.score.down,.chip.down{color:var(--down)}.score.zero{color:var(--muted)} .score.up,.chip.up{color:var(--up)}.score.down,.chip.down{color:var(--down)}.score.zero{color:var(--muted)}
/* sparkline */
.spark{width:104px;height:30px;flex:none} .spark{width:104px;height:30px;flex:none}
.spark polyline{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round} .spark polyline{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
.spark.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)} .spark.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)}
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)} .spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
.pillar .spark{width:150px;margin-top:4px} .pillar .spark{width:150px;margin-top:4px}
/* ledger + list rows */
.list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px} .list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
.row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line); .row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);
border-radius:13px;padding:11px 15px} border-radius:13px;padding:11px 15px}
@ -563,7 +702,6 @@ font-variant-numeric:tabular-nums}
.row .score{font-family:var(--mono);font-weight:700;font-size:18px;font-variant-numeric:tabular-nums; .row .score{font-family:var(--mono);font-weight:700;font-size:18px;font-variant-numeric:tabular-nums;
min-width:46px;text-align:right;flex:none} min-width:46px;text-align:right;flex:none}
/* activity + givers band */
.band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px} .band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px}
@media(min-width:720px){.band{grid-template-columns:1.35fr 1fr;align-items:start}} @media(min-width:720px){.band{grid-template-columns:1.35fr 1fr;align-items:start}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:6px 18px 18px} .panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:6px 18px 18px}
@ -584,8 +722,6 @@ border-radius:6px;padding:0 6px;margin-left:2px}
.ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums} .ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2} footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2}
/* empty (no karma at all) */
.empty{text-align:center;color:var(--muted);padding:56px 10px} .empty{text-align:center;color:var(--muted);padding:56px 10px}
@media(max-width:560px){ @media(max-width:560px){
@ -609,23 +745,18 @@ footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;lin
<div class="page"> <div class="page">
"##; "##;
const EMPTY: &str = r##"<div class="empty fade">No karma yet.<br>Say <code>nick++</code> in the channel and this board comes alive.</div>"##; const SCRIPT_BODY: &str = r##"(function(){
const SCRIPT: &str = r##"<script>
(function(){
function ago(sec){ function ago(sec){
var d=Math.max(0,Math.floor(Date.now()/1000)-sec); var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
if(d<45)return"just now"; if(d<45)return C.just;
var u=[[31536000,"y"],[2592000,"mo"],[604800,"w"],[86400,"d"],[3600,"h"],[60,"m"]]; var u=[[31536000,0],[2592000,1],[604800,2],[86400,3],[3600,4],[60,5]];
for(var i=0;i<u.length;i++){if(d>=u[i][0])return Math.floor(d/u[i][0])+u[i][1]+" ago";} for(var i=0;i<u.length;i++){if(d>=u[i][0]){return C.pre+Math.floor(d/u[i][0])+C.u[u[i][1]]+C.post;}}
return Math.floor(d/1)+"s ago"; return C.pre+d+C.u[6]+C.post;
} }
document.querySelectorAll("time.ago[data-t]").forEach(function(t){ document.querySelectorAll("time.ago[data-t]").forEach(function(t){
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v); var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
}); });
setTimeout(function(){location.reload();},60000); setTimeout(function(){location.reload();},60000);
})(); })();"##;
</script>
"##;
const TAIL: &str = "</div></body></html>\n"; const TAIL: &str = "</div></body></html>\n";

View file

@ -3,6 +3,7 @@
pub mod bot; pub mod bot;
pub mod config; pub mod config;
pub mod http; pub mod http;
pub mod i18n;
pub mod irc; pub mod irc;
pub mod json; pub mod json;
pub mod karma_web; pub mod karma_web;

View file

@ -1,5 +1,6 @@
use rustbot::bot::module::{Action, Chat, Command, Module}; use rustbot::bot::module::{Action, Chat, Command, Module};
use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma}; use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma};
use rustbot::i18n::Locale;
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
Chat { Chat {
@ -208,3 +209,53 @@ fn on_message_appends_to_the_event_log() {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log); let _ = std::fs::remove_file(&log);
} }
#[test]
fn spanish_reason_connector_is_stripped() {
assert_eq!(
extract_reason("rust++ porque es rápido").as_deref(),
Some("es rápido")
);
assert_eq!(
extract_reason("rust++ por los tests").as_deref(),
Some("los tests")
);
}
#[test]
fn replies_in_spanish_for_es_locale() {
let path = std::env::temp_dir().join("rubot-test-karma-es.json");
let log = std::env::temp_dir().join("rubot-test-karma-es.events.jsonl");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
let mut k = Karma::with_path_locale(path.clone(), Locale::Es);
k.on_message(&chat("alice", "rust++ porque es bueno"));
// report
let cmd = Command {
sender: "z",
reply_to: "#argentina",
name: "karma",
args: &["rust"],
prefix: ">",
network: "libera",
};
let r = match k.on_command(&cmd).as_slice() {
[Action::Reply(s)] => visible(s),
_ => panic!("expected one reply"),
};
assert!(r.contains("tiene 1 de karma"), "{r}");
assert!(r.contains("(#1 de 1)"), "{r}");
assert!(r.contains("último: \"es bueno\""), "{r}");
// unseen thing
let cmd2 = Command {
args: &["ghost"],
..cmd
};
let ghost = match k.on_command(&cmd2).as_slice() {
[Action::Reply(s)] => visible(s),
_ => panic!("expected one reply"),
};
assert!(ghost.contains("todavía no hay karma para"), "{ghost}");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
}

View file

@ -1,3 +1,4 @@
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, render}; use rustbot::karma_web::{parse_board, parse_events, render};
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board { fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
@ -51,11 +52,8 @@ fn renders_hero_podium_and_scores() {
"libera", "libera",
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
); );
let html = render(&[b], 0); let html = render(&[b], 0, Locale::En);
assert!( assert!(html.contains(">karma<"), "wordmark missing");
html.contains(">karma<"),
"wordmark missing: has no karma mark"
);
assert!(html.contains("podium"), "no podium"); assert!(html.contains("podium"), "no podium");
assert!(html.contains(">rust<"), "champion nick missing"); assert!(html.contains(">rust<"), "champion nick missing");
assert!(html.contains(">+5<"), "score missing"); assert!(html.contains(">+5<"), "score missing");
@ -67,34 +65,42 @@ fn renders_activity_and_givers_from_events() {
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n"; {\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log); let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log);
let html = render(&[b], 0); let html = render(&[b], 0, Locale::En);
assert!(html.contains("recent activity"), "no activity section"); assert!(html.contains("recent activity"), "no activity section");
assert!(html.contains("top givers"), "no givers section"); assert!(html.contains("top givers"), "no givers section");
assert!(html.contains("gave"), "no feed verb"); assert!(html.contains("gave"), "no feed verb");
// alice made two gifts -> shown in givers panel assert!(html.contains("2 gifts"), "giver count wrong");
assert!( assert!(html.contains("data-t=\"200\""), "feed newest-first missing");
html.contains("2 gifts"),
"giver count wrong: {}",
html.contains("gift")
);
assert!(
html.contains("data-t=\"200\""),
"feed sorted/newest-first missing"
);
} }
#[test] #[test]
fn empty_states_are_friendly() { fn empty_states_are_friendly() {
// no karma at all let html = render(&[], 0, Locale::En);
let html = render(&[], 0);
assert!(html.contains("No karma yet"), "{html}"); assert!(html.contains("No karma yet"), "{html}");
// karma but no event history yet
let b = parse_board("libera", r#"{"romaka":2}"#); let b = parse_board("libera", r#"{"romaka":2}"#);
let html = render(&[b], 0); let html = render(&[b], 0, Locale::En);
assert!(html.contains("Nothing yet"), "empty activity state missing"); assert!(html.contains("Nothing yet"), "empty activity state missing");
assert!(html.contains("Be the first"), "empty givers state missing"); assert!(html.contains("Be the first"), "empty givers state missing");
} }
#[test]
fn renders_spanish_chrome() {
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"nadia\",\"why\":\"buen PR\"}\n";
let b = board_with_events("libera", r#"{"rust":1}"#, log);
let html = render(&[b], 0, Locale::Es);
assert!(html.contains("karma neto"), "es stat label missing");
assert!(
html.contains("actividad reciente"),
"es activity heading missing"
);
assert!(html.contains("top donantes"), "es givers heading missing");
assert!(html.contains("le dio a"), "es feed verb missing");
// relative-time config for the client is Spanish
assert!(html.contains("\"pre\":\"hace \""), "es time config missing");
// user content is never translated
assert!(html.contains("buen PR") && html.contains(">rust<"));
}
#[test] #[test]
fn escapes_untrusted_irc_input_everywhere() { fn escapes_untrusted_irc_input_everywhere() {
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n"; let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
@ -103,7 +109,7 @@ fn escapes_untrusted_irc_input_everywhere() {
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#, r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
log, log,
); );
let html = render(&[b], 0); let html = render(&[b], 0, Locale::En);
assert!(!html.contains("<img"), "raw img tag leaked"); assert!(!html.contains("<img"), "raw img tag leaked");
assert!(html.contains("&lt;script&gt;"), "nick not escaped"); assert!(html.contains("&lt;script&gt;"), "nick not escaped");
assert!(html.contains("a&amp;b"), "giver not escaped"); assert!(html.contains("a&amp;b"), "giver not escaped");