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
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`,
@ -123,8 +123,24 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
on), `password`, `sasl_user`, `sasl_pass`, and `name` (a network's log label).
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
`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 `.`).

View file

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

View file

@ -28,7 +28,7 @@ impl Bot {
pub fn new(config: Config, conn: Connection) -> Bot {
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();
for (i, m) in mods.iter().enumerate() {
for spec in m.commands() {

View file

@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
use crate::i18n::Locale;
use crate::json::{self, Json};
const COMMANDS: &[CommandSpec] = &[
@ -29,21 +30,30 @@ const ZWSP: char = '\u{200B}';
pub struct Karma {
store: Store,
locale: Locale,
}
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 safe: String = network
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.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 {
Karma::with_path_locale(path, Locale::En)
}
pub fn with_path_locale(path: PathBuf, locale: Locale) -> Karma {
Karma {
store: Store::load(path),
locale,
}
}
@ -52,19 +62,30 @@ impl Karma {
}
fn report(&self, thing: &str) -> String {
let dh = dehighlight(thing);
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 mut out = format!(
"{} has {} karma (#{rank} of {total})",
dehighlight(thing),
e.count
);
let n = e.count;
let mut out = match self.locale {
Locale::En => format!("{dh} has {n} karma (#{rank} of {total})"),
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 {
let last = match self.locale {
Locale::En => "last",
Locale::Es => "último",
Locale::Fr => "dernier",
};
match &e.by {
Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))),
None => out.push_str(&format!(" — last: \"{reason}\"")),
Some(by) => out.push_str(&format!("{last}: \"{reason}\" ({})", dehighlight(by))),
None => out.push_str(&format!("{last}: \"{reason}\"")),
}
}
out
@ -73,17 +94,25 @@ impl Karma {
fn leaderboard(&self, bottom: bool) -> String {
let entries = self.store.ranked(TOP_N, bottom);
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
.iter()
.map(|(k, v)| format!("{} ({v})", dehighlight(k)))
.collect();
format!(
"karma {}: {}",
if bottom { "bottom" } else { "top" },
items.join(", ")
)
let label = match (self.locale, bottom) {
(Locale::En, false) => "karma top",
(Locale::En, true) => "karma bottom",
(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));
let new = self.store.bump(thing, *delta, r, chat.sender);
if *delta > 0 && MILESTONES.contains(&new) {
announces.push(Action::reply(format!(
"🎉 {} reached {new} karma!",
dehighlight(thing)
)));
let dh = dehighlight(thing);
announces.push(Action::reply(match self.locale {
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() {
@ -188,7 +219,16 @@ pub fn extract_reason(text: &str) -> Option<String> {
}
let mut reason = toks[bumps[0] + 1..].join(" ");
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) {
reason = r.to_string();
break;

View file

@ -11,13 +11,14 @@ pub mod weather;
pub mod wiki;
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![
Box::new(builtins::Builtins),
Box::new(dice::Dice::new()),
Box::new(hash::Hash),
Box::new(karma::Karma::new(network)),
Box::new(karma::Karma::new(network, locale)),
Box::new(crypto::Crypto),
Box::new(define::Define),
Box::new(down::Down),

View file

@ -18,6 +18,7 @@ pub struct Config {
pub password: Option<String>,
pub sasl_user: Option<String>,
pub sasl_pass: Option<String>,
pub locale: String,
}
impl Default for Config {
@ -36,6 +37,7 @@ impl Default for Config {
password: None,
sasl_user: 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()),
"sasl_user" => c.sasl_user = (!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}'")),
}
}
@ -217,6 +220,9 @@ fn apply_env(c: &mut Config) {
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
c.sasl_pass = Some(v);
}
if let Ok(v) = std::env::var("IRC_LOCALE") {
c.locale = v;
}
}
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 crate::json::Json;
use crate::i18n::Locale;
use crate::json::{self, Json};
pub struct Row {
pub thing: String,
@ -253,7 +254,104 @@ fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec<Giver> {
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 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 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("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
s.push_str(
"<p class=\"tag\">reputation earned one <code>nick++</code> at a time, across the channels.</p>",
);
s.push_str(&format!("<p class=\"tag\">{}</p>", lx.tagline));
s.push_str("<div class=\"stats\">");
s.push_str(&stat(&things.to_string(), "tracked"));
s.push_str(&stat(&signed(total_points), "net karma"));
s.push_str(&stat(&gifts.to_string(), "gifts logged"));
s.push_str(&stat(&givers_set.len().to_string(), "givers"));
s.push_str(&stat(&things.to_string(), lx.s_tracked));
s.push_str(&stat(&signed(total_points), lx.s_net));
s.push_str(&stat(&gifts.to_string(), lx.s_gifts));
s.push_str(&stat(&givers_set.len().to_string(), lx.s_givers));
s.push_str("</div></header>");
if things == 0 {
s.push_str(EMPTY);
s.push_str(&footer(updated_epoch));
s.push_str(SCRIPT);
s.push_str(TAIL);
return s;
s.push_str(&format!(
"<div class=\"empty fade\">{}<br>{}</div>",
lx.empty_title, lx.empty_sub
));
return finish(s, updated_epoch, locale, &lx);
}
// per-network board(s): champion podium + the rest as a ledger
for b in boards {
if b.rows.is_empty() {
continue;
@ -300,23 +394,31 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String {
s.push_str("<section class=\"board fade\">");
if multi {
s.push_str(&format!(
"<div class=\"eyebrow\">network<span>{}</span></div>",
"<div class=\"eyebrow\">{}<span>{}</span></div>",
lx.network,
esc(&b.network)
));
}
s.push_str(&podium(b));
s.push_str(&ledger(b));
s.push_str(&ledger(b, &lx));
s.push_str("</section>");
}
// activity + givers band
s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi));
s.push_str(&givers_panel(&feed));
s.push_str(&activity(&feed, multi, &lx));
s.push_str(&givers_panel(&feed, &lx));
s.push_str("</div>");
s.push_str(&footer(updated_epoch));
s.push_str(SCRIPT);
finish(s, updated_epoch, locale, &lx)
}
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
}
@ -363,11 +465,14 @@ fn podium(b: &Board) -> String {
out
}
fn ledger(b: &Board) -> String {
fn ledger(b: &Board, lx: &Lex) -> String {
if b.rows.len() <= 3 {
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) {
let rank = i + 1;
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
@ -394,20 +499,22 @@ fn ledger(b: &Board) -> String {
out
}
fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
let mut out = String::from(
fn activity(feed: &[(&str, &Event)], multi: bool, lx: &Lex) -> String {
let mut out = format!(
"<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() {
out.push_str(
"<div class=\"empty-panel\">Nothing yet. The next <code>nick++</code> shows up here.</div></section>",
);
out.push_str(&format!(
"<div class=\"empty-panel\">{}</div></section>",
lx.empty_activity
));
return out;
}
out.push_str("<ul class=\"timeline\">");
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 why = match &e.why {
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!(
"<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}\
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
klass(e.delta),
avatar(&actor, "sm"),
esc(&actor),
lx.gave,
esc(&e.thing),
klass(e.delta),
e.t,
@ -435,28 +543,35 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
out
}
fn givers_panel(feed: &[(&str, &Event)]) -> String {
fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
let givers = top_givers(feed, 8);
let mut out =
String::from("<section class=\"panel fade\"><div class=\"eyebrow\">top givers</div>");
if givers.is_empty() {
out.push_str(
"<div class=\"empty-panel\">No one has handed out karma yet. Be the first.</div></section>",
let mut out = format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div>",
lx.top_givers
);
if givers.is_empty() {
out.push_str(&format!(
"<div class=\"empty-panel\">{}</div></section>",
lx.empty_givers
));
return out;
}
out.push_str("<ol class=\"list givers\">");
for (i, g) in givers.iter().enumerate() {
let unit = if g.gives == 1 {
lx.gift_one
} else {
lx.gift_many
};
out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{}</span>{}\
<div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{} gift{}</span></div>\
<span class=\"why\">{} {unit}</span></div>\
<span class=\"score {}\">{}</span></li>",
i + 1,
avatar(&g.name, "sm"),
esc(&g.name),
g.gives,
if g.gives == 1 { "" } else { "s" },
klass(g.points),
signed(g.points),
));
@ -465,11 +580,41 @@ fn givers_panel(feed: &[(&str, &Event)]) -> String {
out
}
fn footer(epoch: u64) -> String {
fn footer(epoch: u64, lx: &Lex) -> String {
format!(
"<footer class=\"fade\">give karma by saying <code>nick++</code> in the channel \
&middot; built by <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
&middot; updated <time class=\"ago\" data-t=\"{epoch}\"></time></footer>"
"<footer class=\"fade\">{} &middot; {} \
<a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
&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)}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
/* hero */
.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::before{content:"$ ";color:var(--up)}
@ -507,7 +651,7 @@ border-radius:6px;padding:1px 6px;color:var(--gold)}
margin:0;letter-spacing:-.04em;
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)}
.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}
.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}
@ -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}
.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;
gap:14px;flex-wrap:wrap}
.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 .by{color:var(--faint)}
/* avatars */
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
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)}
.ava.lg{width:56px;height:56px;font-size:19px}
.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)}
/* sparkline */
.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.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)}
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
.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}
.row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);
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;
min-width:46px;text-align:right;flex:none}
/* activity + givers band */
.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}}
.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}
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}
@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">
"##;
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: &str = r##"<script>
(function(){
const SCRIPT_BODY: &str = r##"(function(){
function ago(sec){
var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
if(d<45)return"just now";
var u=[[31536000,"y"],[2592000,"mo"],[604800,"w"],[86400,"d"],[3600,"h"],[60,"m"]];
for(var i=0;i<u.length;i++){if(d>=u[i][0])return Math.floor(d/u[i][0])+u[i][1]+" ago";}
return Math.floor(d/1)+"s ago";
if(d<45)return C.just;
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 C.pre+Math.floor(d/u[i][0])+C.u[u[i][1]]+C.post;}}
return C.pre+d+C.u[6]+C.post;
}
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
});
setTimeout(function(){location.reload();},60000);
})();
</script>
"##;
})();"##;
const TAIL: &str = "</div></body></html>\n";

View file

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

View file

@ -1,5 +1,6 @@
use rustbot::bot::module::{Action, Chat, Command, Module};
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> {
Chat {
@ -208,3 +209,53 @@ fn on_message_appends_to_the_event_log() {
let _ = std::fs::remove_file(&path);
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};
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",
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
);
let html = render(&[b], 0);
assert!(
html.contains(">karma<"),
"wordmark missing: has no karma mark"
);
let html = render(&[b], 0, Locale::En);
assert!(html.contains(">karma<"), "wordmark missing");
assert!(html.contains("podium"), "no podium");
assert!(html.contains(">rust<"), "champion nick 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\
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
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("top givers"), "no givers section");
assert!(html.contains("gave"), "no feed verb");
// alice made two gifts -> shown in givers panel
assert!(
html.contains("2 gifts"),
"giver count wrong: {}",
html.contains("gift")
);
assert!(
html.contains("data-t=\"200\""),
"feed sorted/newest-first missing"
);
assert!(html.contains("2 gifts"), "giver count wrong");
assert!(html.contains("data-t=\"200\""), "feed newest-first missing");
}
#[test]
fn empty_states_are_friendly() {
// no karma at all
let html = render(&[], 0);
let html = render(&[], 0, Locale::En);
assert!(html.contains("No karma yet"), "{html}");
// karma but no event history yet
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("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]
fn escapes_untrusted_irc_input_everywhere() {
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"}}"#,
log,
);
let html = render(&[b], 0);
let html = render(&[b], 0, Locale::En);
assert!(!html.contains("<img"), "raw img tag leaked");
assert!(html.contains("&lt;script&gt;"), "nick not escaped");
assert!(html.contains("a&amp;b"), "giver not escaped");