karma-web: show each board's source channel + server from a bot-written meta sidecar
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 00:48:57 +00:00
parent 7f6327270b
commit 4ccca54963
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 145 additions and 29 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ rustbot.conf
weather.*.json weather.*.json
karma.*.json karma.*.json
karma.*.events.jsonl karma.*.events.jsonl
karma.*.meta.json
*.json.tmp *.json.tmp
karma-web.service karma-web.service
karma-web.timer karma-web.timer

View file

@ -81,12 +81,14 @@ lists them at runtime and `help <command>` describes one.
The live karma board is published at **<https://karma.devtronic.pro>** — a hero The live karma board is published at **<https://karma.devtronic.pro>** — a hero
with live stats, a top-3 podium, a ranked ledger with per-thing trend with live stats, a top-3 podium, a ranked ledger with per-thing trend
sparklines, a recent-activity feed, and a top-givers board. A second binary, sparklines, a recent-activity feed, and a top-givers board. Each network's board
`karma-web`, reads the per-network `karma.*.json` totals plus the append-only shows where its karma comes from (server + channels, e.g. `#argentina`). A
`karma.*.events.jsonl` history the bot writes on every bump, and renders one second binary, `karma-web`, reads the per-network `karma.*.json` totals, the
self-contained HTML page (no CSS/JS/font dependencies, all IRC-supplied text append-only `karma.*.events.jsonl` history the bot writes on every bump, and a
HTML-escaped). A systemd timer regenerates it every minute and nginx serves it `karma.*.meta.json` source sidecar (server + channels, written by the bot at
over TLS. startup), and renders one self-contained HTML page (no CSS/JS/font dependencies,
all IRC-supplied text HTML-escaped). A systemd timer regenerates it every minute
and nginx serves it over TLS.
```sh ```sh
cargo build --release --bin karma-web cargo build --release --bin karma-web

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::i18n::Locale; use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, render, Board}; use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Board};
fn main() { fn main() {
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@ -28,12 +28,22 @@ fn main() {
.strip_prefix("karma.") .strip_prefix("karma.")
.and_then(|s| s.strip_suffix(".json")) .and_then(|s| s.strip_suffix(".json"))
{ {
// real network names have no dot; a dot means karma.<net>.meta.json etc.
if net.contains('.') {
continue;
}
if let Ok(body) = fs::read_to_string(&p) { if let Ok(body) = fs::read_to_string(&p) {
let mut board = parse_board(net, &body); let mut board = parse_board(net, &body);
let ev = p.with_file_name(format!("karma.{net}.events.jsonl")); let ev = p.with_file_name(format!("karma.{net}.events.jsonl"));
if let Ok(evbody) = fs::read_to_string(&ev) { if let Ok(evbody) = fs::read_to_string(&ev) {
board.events = parse_events(&evbody); board.events = parse_events(&evbody);
} }
let meta = p.with_file_name(format!("karma.{net}.meta.json"));
if let Ok(mbody) = fs::read_to_string(&meta) {
let (server, channels) = parse_meta(&mbody);
board.server = server;
board.channels = channels;
}
boards.push(board); boards.push(board);
} }
} }

View file

@ -28,6 +28,8 @@ 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();
modules::karma::write_meta(&config.name, &config.server, &config.channels);
let mods = modules::all( let mods = modules::all(
&config.name, &config.name,
crate::i18n::Locale::from_code(&config.locale), crate::i18n::Locale::from_code(&config.locale),

View file

@ -41,16 +41,7 @@ pub struct Karma {
impl Karma { impl Karma {
pub fn new(network: &str, locale: Locale, url: Option<String>) -> Karma { pub fn new(network: &str, locale: Locale, url: Option<String>) -> Karma {
let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); Karma::with_path_locale(data_path(network, "json"), locale).with_url(url)
let safe: String = network
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
Karma::with_path_locale(
PathBuf::from(dir).join(format!("karma.{safe}.json")),
locale,
)
.with_url(url)
} }
pub fn with_path(path: PathBuf) -> Karma { pub fn with_path(path: PathBuf) -> Karma {
@ -430,6 +421,40 @@ fn events_path_for(path: &Path) -> PathBuf {
PathBuf::from(format!("{base}.events.jsonl")) PathBuf::from(format!("{base}.events.jsonl"))
} }
fn data_dir() -> PathBuf {
PathBuf::from(std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
}
fn safe_network(network: &str) -> String {
network
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn data_path(network: &str, ext: &str) -> PathBuf {
data_dir().join(format!("karma.{}.{ext}", safe_network(network)))
}
/// Record where a network's karma comes from (server + channels) so the web
/// page can show its source. Best-effort — a failure just omits the line.
pub fn write_meta(network: &str, server: &str, channels: &[String]) {
let chans = channels
.iter()
.map(|c| format!("\"{}\"", json::escape(c)))
.collect::<Vec<_>>()
.join(",");
let body = format!(
"{{\"server\":\"{}\",\"channels\":[{chans}]}}\n",
json::escape(server)
);
let path = data_path(network, "meta.json");
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
let _ = fs::write(&tmp, body).and_then(|_| fs::rename(&tmp, &path));
}
fn now() -> u64 { fn now() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)

View file

@ -22,6 +22,8 @@ pub struct Board {
pub network: String, pub network: String,
pub rows: Vec<Row>, pub rows: Vec<Row>,
pub events: Vec<Event>, pub events: Vec<Event>,
pub server: String,
pub channels: Vec<String>,
} }
pub fn parse_board(network: &str, body: &str) -> Board { pub fn parse_board(network: &str, body: &str) -> Board {
@ -46,9 +48,33 @@ pub fn parse_board(network: &str, body: &str) -> Board {
network: network.to_string(), network: network.to_string(),
rows, rows,
events: Vec::new(), events: Vec::new(),
server: String::new(),
channels: Vec::new(),
} }
} }
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
/// network's karma comes from.
pub fn parse_meta(body: &str) -> (String, Vec<String>) {
let Some(j) = Json::parse(body) else {
return (String::new(), Vec::new());
};
let server = j
.get("server")
.and_then(Json::as_str)
.unwrap_or("")
.to_string();
let channels = match j.get("channels") {
Some(Json::Arr(items)) => items
.iter()
.filter_map(Json::as_str)
.map(str::to_string)
.collect(),
_ => Vec::new(),
};
(server, channels)
}
pub fn parse_events(body: &str) -> Vec<Event> { pub fn parse_events(body: &str) -> Vec<Event> {
let mut out = Vec::new(); let mut out = Vec::new();
for line in body.lines() { for line in body.lines() {
@ -262,7 +288,7 @@ struct Lex {
s_net: &'static str, s_net: &'static str,
s_gifts: &'static str, s_gifts: &'static str,
s_givers: &'static str, s_givers: &'static str,
network: &'static str, from: &'static str,
ledger: &'static str, ledger: &'static str,
recent: &'static str, recent: &'static str,
top_givers: &'static str, top_givers: &'static str,
@ -287,7 +313,7 @@ fn lex(l: Locale) -> Lex {
s_net: "net karma", s_net: "net karma",
s_gifts: "gifts logged", s_gifts: "gifts logged",
s_givers: "givers", s_givers: "givers",
network: "network", from: "from",
ledger: "the ledger", ledger: "the ledger",
recent: "recent activity", recent: "recent activity",
top_givers: "top givers", top_givers: "top givers",
@ -309,7 +335,7 @@ fn lex(l: Locale) -> Lex {
s_net: "karma neto", s_net: "karma neto",
s_gifts: "regalos", s_gifts: "regalos",
s_givers: "donantes", s_givers: "donantes",
network: "red", from: "desde",
ledger: "el ranking", ledger: "el ranking",
recent: "actividad reciente", recent: "actividad reciente",
top_givers: "top donantes", top_givers: "top donantes",
@ -331,7 +357,7 @@ fn lex(l: Locale) -> Lex {
s_net: "karma net", s_net: "karma net",
s_gifts: "dons", s_gifts: "dons",
s_givers: "donneurs", s_givers: "donneurs",
network: "réseau", from: "depuis",
ledger: "le classement", ledger: "le classement",
recent: "activité récente", recent: "activité récente",
top_givers: "meilleurs donneurs", top_givers: "meilleurs donneurs",
@ -392,13 +418,7 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
continue; continue;
} }
s.push_str("<section class=\"board fade\">"); s.push_str("<section class=\"board fade\">");
if multi { s.push_str(&source_line(b, &lx));
s.push_str(&format!(
"<div class=\"eyebrow\">{}<span>{}</span></div>",
lx.network,
esc(&b.network)
));
}
s.push_str(&podium(b)); s.push_str(&podium(b));
s.push_str(&ledger(b, &lx)); s.push_str(&ledger(b, &lx));
s.push_str("</section>"); s.push_str("</section>");
@ -423,6 +443,31 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
s s
} }
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
fn source_line(b: &Board, lx: &Lex) -> String {
let mut parts: Vec<String> = Vec::new();
if !b.channels.is_empty() {
let chans = b
.channels
.iter()
.map(|c| esc(c))
.collect::<Vec<_>>()
.join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>"));
}
if !b.server.is_empty() {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.server)));
}
if parts.is_empty() {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
}
format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
lx.from,
parts.join("<span class=\"sep\">·</span>")
)
}
fn stat(num: &str, label: &str) -> String { fn stat(num: &str, label: &str) -> String {
format!( format!(
"<div class=\"stat\"><span class=\"num\">{}</span><span class=\"lab\">{}</span></div>", "<div class=\"stat\"><span class=\"num\">{}</span><span class=\"lab\">{}</span></div>",
@ -661,6 +706,11 @@ padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px}
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted); .eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
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}
.source{display:flex;align-items:center;flex-wrap:wrap;gap:9px;margin:26px 2px 16px;font-size:12px}
.source .lbl{text-transform:uppercase;letter-spacing:.16em;color:var(--muted);font-size:11px}
.source .chans{font-family:var(--mono);color:var(--gold);font-weight:600}
.source .host{font-family:var(--mono);color:var(--faint)}
.source .sep{color:var(--faint)}
.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}

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale; use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, render}; use rustbot::karma_web::{parse_board, parse_events, parse_meta, 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 {
let mut b = parse_board(net, store); let mut b = parse_board(net, store);
@ -101,6 +101,32 @@ fn renders_spanish_chrome() {
assert!(html.contains("buen PR") && html.contains(">rust<")); assert!(html.contains("buen PR") && html.contains(">rust<"));
} }
#[test]
fn parses_meta_server_and_channels() {
let (server, channels) =
parse_meta(r##"{"server":"irc.libera.chat","channels":["#argentina","#ar"]}"##);
assert_eq!(server, "irc.libera.chat");
assert_eq!(channels, vec!["#argentina".to_string(), "#ar".to_string()]);
// missing/garbage meta degrades to empty
assert_eq!(parse_meta("not json"), (String::new(), Vec::new()));
}
#[test]
fn renders_source_channel_and_server() {
let mut b = parse_board("libera", r#"{"romaka":2}"#);
b.server = "irc.libera.chat".to_string();
b.channels = vec!["#argentina".to_string()];
let en = render(&[b], 0, Locale::En);
assert!(en.contains("#argentina"), "channel missing: {en}");
assert!(en.contains("irc.libera.chat"), "server missing");
assert!(en.contains(">from<"), "from label missing");
// localized label
let mut b2 = parse_board("libera", r#"{"romaka":2}"#);
b2.channels = vec!["#argentina".to_string()];
let es = render(&[b2], 0, Locale::Es);
assert!(es.contains(">desde<") && es.contains("#argentina"), "{es}");
}
#[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";