diff --git a/.gitignore b/.gitignore index e29861c..c9cb4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ rustbot.conf weather.*.json karma.*.json karma.*.events.jsonl +karma.*.meta.json *.json.tmp karma-web.service karma-web.timer diff --git a/README.md b/README.md index 2cb57d3..bec330f 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,14 @@ lists them at runtime and `help ` describes one. The live karma board is published at **** — a hero 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, -`karma-web`, reads the per-network `karma.*.json` totals plus the append-only -`karma.*.events.jsonl` history the bot writes on every bump, 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. +sparklines, a recent-activity feed, and a top-givers board. Each network's board +shows where its karma comes from (server + channels, e.g. `#argentina`). A +second binary, `karma-web`, reads the per-network `karma.*.json` totals, the +append-only `karma.*.events.jsonl` history the bot writes on every bump, and a +`karma.*.meta.json` source sidecar (server + channels, written by the bot at +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 cargo build --release --bin karma-web diff --git a/src/bin/karma-web.rs b/src/bin/karma-web.rs index 282eb3f..e0d6df4 100644 --- a/src/bin/karma-web.rs +++ b/src/bin/karma-web.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; 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, parse_meta, render, Board}; fn main() { let mut args = std::env::args().skip(1); @@ -28,12 +28,22 @@ fn main() { .strip_prefix("karma.") .and_then(|s| s.strip_suffix(".json")) { + // real network names have no dot; a dot means karma..meta.json etc. + if net.contains('.') { + continue; + } if let Ok(body) = fs::read_to_string(&p) { let mut board = parse_board(net, &body); let ev = p.with_file_name(format!("karma.{net}.events.jsonl")); if let Ok(evbody) = fs::read_to_string(&ev) { 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); } } diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 522cce5..26a6bdf 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -28,6 +28,8 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); + modules::karma::write_meta(&config.name, &config.server, &config.channels); + let mods = modules::all( &config.name, crate::i18n::Locale::from_code(&config.locale), diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs index d516ec0..dfc3850 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -41,16 +41,7 @@ pub struct Karma { impl Karma { pub fn new(network: &str, locale: Locale, url: Option) -> 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_locale( - PathBuf::from(dir).join(format!("karma.{safe}.json")), - locale, - ) - .with_url(url) + Karma::with_path_locale(data_path(network, "json"), locale).with_url(url) } pub fn with_path(path: PathBuf) -> Karma { @@ -430,6 +421,40 @@ fn events_path_for(path: &Path) -> PathBuf { 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::>() + .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 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/karma_web.rs b/src/karma_web.rs index 5c3441d..8a7d1fc 100644 --- a/src/karma_web.rs +++ b/src/karma_web.rs @@ -22,6 +22,8 @@ pub struct Board { pub network: String, pub rows: Vec, pub events: Vec, + pub server: String, + pub channels: Vec, } 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(), rows, events: Vec::new(), + server: String::new(), + channels: Vec::new(), } } +/// Parse the `karma..meta.json` sidecar the bot writes: where this +/// network's karma comes from. +pub fn parse_meta(body: &str) -> (String, Vec) { + 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 { let mut out = Vec::new(); for line in body.lines() { @@ -262,7 +288,7 @@ struct Lex { s_net: &'static str, s_gifts: &'static str, s_givers: &'static str, - network: &'static str, + from: &'static str, ledger: &'static str, recent: &'static str, top_givers: &'static str, @@ -287,7 +313,7 @@ fn lex(l: Locale) -> Lex { s_net: "net karma", s_gifts: "gifts logged", s_givers: "givers", - network: "network", + from: "from", ledger: "the ledger", recent: "recent activity", top_givers: "top givers", @@ -309,7 +335,7 @@ fn lex(l: Locale) -> Lex { s_net: "karma neto", s_gifts: "regalos", s_givers: "donantes", - network: "red", + from: "desde", ledger: "el ranking", recent: "actividad reciente", top_givers: "top donantes", @@ -331,7 +357,7 @@ fn lex(l: Locale) -> Lex { s_net: "karma net", s_gifts: "dons", s_givers: "donneurs", - network: "réseau", + from: "depuis", ledger: "le classement", recent: "activité récente", top_givers: "meilleurs donneurs", @@ -392,13 +418,7 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String { continue; } s.push_str("
"); - if multi { - s.push_str(&format!( - "
{}{}
", - lx.network, - esc(&b.network) - )); - } + s.push_str(&source_line(b, &lx)); s.push_str(&podium(b)); s.push_str(&ledger(b, &lx)); s.push_str("
"); @@ -423,6 +443,31 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String { 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 = Vec::new(); + if !b.channels.is_empty() { + let chans = b + .channels + .iter() + .map(|c| esc(c)) + .collect::>() + .join(" "); + parts.push(format!("{chans}")); + } + if !b.server.is_empty() { + parts.push(format!("{}", esc(&b.server))); + } + if parts.is_empty() { + parts.push(format!("{}", esc(&b.network))); + } + format!( + "
{}{}
", + lx.from, + parts.join("·") + ) +} + fn stat(num: &str, label: &str) -> String { format!( "
{}{}
", @@ -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); margin:34px 2px 14px;display:flex;align-items:center;gap:10px} .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; gap:14px;flex-wrap:wrap} diff --git a/tests/karma_web.rs b/tests/karma_web.rs index 6c37d1f..689d64c 100644 --- a/tests/karma_web.rs +++ b/tests/karma_web.rs @@ -1,5 +1,5 @@ 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 { let mut b = parse_board(net, store); @@ -101,6 +101,32 @@ fn renders_spanish_chrome() { 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] fn escapes_untrusted_irc_input_everywhere() { let log = "{\"t\":1,\"o\":\"