From 6af92c121798e19a12a4d65b87a337f4b3299906 Mon Sep 17 00:00:00 2001 From: reverse Date: Thu, 30 Jul 2026 01:27:39 +0000 Subject: [PATCH] karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API --- README.md | 33 ++- src/bin/karma-web.rs | 24 +- src/bot/mod.rs | 54 +++- src/bot/modules/karma.rs | 20 +- src/config.rs | 6 + src/karma_web.rs | 559 +++++++++++++++++++++++++++++++++++++-- tests/karma_web.rs | 119 ++++++--- 7 files changed, 714 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 47cf5d3..4c60a63 100644 --- a/README.md +++ b/README.md @@ -79,17 +79,24 @@ lists them at runtime and `help ` describes one. ## Karma web page -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. Each network's board -shows where its karma comes from — server, channels, and each channel's IRC -topic (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 + topics, which the bot refreshes as it joins and as topics change), -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. +The live karma board is published at ****. It has a +hero with live stats, a top-3 podium, a ranked ledger (filter + sort, click a +row to expand its full history), per-thing trend sparklines, a "rising this +week" strip, a karma-over-time chart, a recent-activity feed, top givers, most +debated, and a milestones timeline. Each network's board shows where its karma +comes from — an online/offline dot, server, joinable channel links, live user +counts, and each channel's IRC topic (URLs linkified). The page carries Open +Graph tags for rich link previews, and the same data is exposed as JSON at +`/karma.json`. + +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, webchat base, a liveness heartbeat, +and per-channel topic + user count, which the bot refreshes as it joins, on +topic/NAMES changes, and on each server PING). It renders one self-contained +HTML page (no CSS/JS/font dependencies, all IRC-supplied text HTML-escaped) plus +`karma.json`. A systemd timer regenerates them every minute and nginx serves +over TLS. ```sh cargo build --release --bin karma-web @@ -127,7 +134,9 @@ 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`, `name` (a network's log label), and -`locale` (`en`/`es`/`fr`, default `en`), and `karma_url` (public web board — +`locale` (`en`/`es`/`fr`, default `en`), `webchat` (per-network webchat base +for the board's channel "join" links, e.g. `https://web.libera.chat/`), and +`karma_url` (public web board — enables the `karmaweb` command and a link on the `karma`/`karmabottom` top lists; empty disables both). Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. diff --git a/src/bin/karma-web.rs b/src/bin/karma-web.rs index e0d6df4..65ec8f0 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, parse_meta, render, Board}; +use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Board}; fn main() { let mut args = std::env::args().skip(1); @@ -40,9 +40,11 @@ fn main() { } 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; + let m = parse_meta(&mbody); + board.server = m.server; + board.webchat = m.webchat; + board.seen = m.seen; + board.channels = m.channels; } boards.push(board); } @@ -58,12 +60,22 @@ fn main() { match out { Some(path) => { - let tmp = format!("{path}.tmp"); - if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) { + if let Err(e) = write_atomic(&path, &html) { eprintln!("karma-web: could not write {path}: {e}"); std::process::exit(1); } + // emit the JSON API next to the page + if let Some(dir) = PathBuf::from(&path).parent() { + let json_path = dir.join("karma.json").to_string_lossy().into_owned(); + let _ = write_atomic(&json_path, &render_json(&boards, epoch)); + } } None => print!("{html}"), } } + +fn write_atomic(path: &str, body: &str) -> std::io::Result<()> { + let tmp = format!("{path}.tmp"); + fs::write(&tmp, body)?; + fs::rename(&tmp, path) +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 408ecda..5bce0fd 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -23,6 +23,8 @@ pub struct Bot { modules: Vec>, routes: HashMap<&'static str, usize>, topics: HashMap, + users: HashMap, + names_building: HashMap, } impl Bot { @@ -59,28 +61,44 @@ impl Bot { modules: mods, routes, topics: HashMap::new(), + users: HashMap::new(), + names_building: HashMap::new(), }; bot.write_source_meta(); bot } /// Rewrite the per-network source sidecar the web board reads: server, - /// the configured channels, and whatever topics we've learned so far. + /// webchat base, a liveness timestamp, and per-channel topic + user count. fn write_source_meta(&self) { - let channels: Vec<(String, Option)> = self + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let channels: Vec = self .config .channels .iter() .map(|c| { - let topic = self - .topics - .get(&c.to_lowercase()) - .filter(|t| !t.is_empty()) - .cloned(); - (c.clone(), topic) + let key = c.to_lowercase(); + let mut obj = format!("{{\"name\":\"{}\"", crate::json::escape(c)); + if let Some(t) = self.topics.get(&key).filter(|t| !t.is_empty()) { + obj.push_str(&format!(",\"topic\":\"{}\"", crate::json::escape(t))); + } + if let Some(u) = self.users.get(&key) { + obj.push_str(&format!(",\"users\":{u}")); + } + obj.push('}'); + obj }) .collect(); - modules::karma::write_meta(&self.config.name, &self.config.server, &channels); + let body = format!( + "{{\"server\":\"{}\",\"webchat\":\"{}\",\"seen\":{now},\"channels\":[{}]}}\n", + crate::json::escape(&self.config.server), + crate::json::escape(&self.config.webchat), + channels.join(",") + ); + modules::karma::write_meta(&self.config.name, &body); } fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) { @@ -117,6 +135,8 @@ impl Bot { "PING" => { let token = msg.trailing().unwrap_or("").to_string(); self.conn.pong(&token)?; + // heartbeat: refresh the sidecar's liveness timestamp + self.write_source_meta(); } "CAP" => self.handle_cap(msg)?, "AUTHENTICATE" => self.handle_authenticate(msg)?, @@ -140,6 +160,22 @@ impl Bot { "331" => self.set_topic(msg.param(1), Some("")), // live topic change: :who TOPIC : "TOPIC" => self.set_topic(msg.param(0), msg.trailing()), + // RPL_NAMREPLY: :n1 n2 n3 (may repeat) + "353" => { + if let (Some(chan), Some(names)) = (msg.param(2), msg.trailing()) { + let n = names.split_whitespace().count() as u32; + *self.names_building.entry(chan.to_lowercase()).or_insert(0) += n; + } + } + // RPL_ENDOFNAMES: finalize the count for this channel + "366" => { + if let Some(chan) = msg.param(1) { + let key = chan.to_lowercase(); + let count = self.names_building.remove(&key).unwrap_or(0); + self.users.insert(key, count); + self.write_source_meta(); + } + } "PRIVMSG" => self.handle_privmsg(msg)?, _ => {} } diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs index 28299c5..7e3db18 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -438,23 +438,9 @@ fn data_path(network: &str, ext: &str) -> PathBuf { /// 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, Option)]) { - let chans = channels - .iter() - .map(|(name, topic)| match topic { - Some(t) => format!( - "{{\"name\":\"{}\",\"topic\":\"{}\"}}", - json::escape(name), - json::escape(t) - ), - None => format!("{{\"name\":\"{}\"}}", json::escape(name)), - }) - .collect::>() - .join(","); - let body = format!( - "{{\"server\":\"{}\",\"channels\":[{chans}]}}\n", - json::escape(server) - ); +/// Atomically write the per-network source sidecar the web board reads. The +/// caller (`Bot`) owns the JSON schema (server, webchat, seen, channels...). +pub fn write_meta(network: &str, body: &str) { let path = data_path(network, "meta.json"); let mut tmp = path.clone().into_os_string(); tmp.push(".tmp"); diff --git a/src/config.rs b/src/config.rs index 6056028..b89e8ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ pub struct Config { pub sasl_pass: Option, pub locale: String, pub karma_url: String, + pub webchat: String, } impl Default for Config { @@ -40,6 +41,7 @@ impl Default for Config { sasl_pass: None, locale: "en".to_string(), karma_url: String::new(), + webchat: String::new(), } } } @@ -166,6 +168,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), "locale" | "lang" => c.locale = value.to_string(), "karma_url" | "web_url" => c.karma_url = value.to_string(), + "webchat" => c.webchat = value.to_string(), other => log(&format!("{where_}: unknown key '{other}'")), } } @@ -229,6 +232,9 @@ fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_KARMA_URL") { c.karma_url = v; } + if let Ok(v) = std::env::var("IRC_WEBCHAT") { + c.webchat = v; + } } fn parse_bool(value: &str) -> bool { diff --git a/src/karma_web.rs b/src/karma_web.rs index b832d78..2d3745f 100644 --- a/src/karma_web.rs +++ b/src/karma_web.rs @@ -21,6 +21,14 @@ pub struct Event { pub struct Channel { pub name: String, pub topic: Option, + pub users: Option, +} + +pub struct Meta { + pub server: String, + pub webchat: String, + pub seen: u64, + pub channels: Vec, } pub struct Board { @@ -28,6 +36,8 @@ pub struct Board { pub rows: Vec, pub events: Vec, pub server: String, + pub webchat: String, + pub seen: u64, pub channels: Vec, } @@ -54,6 +64,8 @@ pub fn parse_board(network: &str, body: &str) -> Board { rows, events: Vec::new(), server: String::new(), + webchat: String::new(), + seen: 0, channels: Vec::new(), } } @@ -61,15 +73,17 @@ pub fn parse_board(network: &str, body: &str) -> Board { /// Parse the `karma..meta.json` sidecar the bot writes: where this /// network's karma comes from. Channels may be bare strings (old format) or /// `{"name","topic"}` objects. -pub fn parse_meta(body: &str) -> (String, Vec) { - let Some(j) = Json::parse(body) else { - return (String::new(), Vec::new()); +pub fn parse_meta(body: &str) -> Meta { + let empty = || Meta { + server: String::new(), + webchat: String::new(), + seen: 0, + channels: Vec::new(), }; - let server = j - .get("server") - .and_then(Json::as_str) - .unwrap_or("") - .to_string(); + let Some(j) = Json::parse(body) else { + return empty(); + }; + let string = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or("").to_string(); let channels = match j.get("channels") { Some(Json::Arr(items)) => items .iter() @@ -78,6 +92,7 @@ pub fn parse_meta(body: &str) -> (String, Vec) { return (!name.is_empty()).then(|| Channel { name: name.to_string(), topic: None, + users: None, }); } let name = it.get("name").and_then(Json::as_str)?; @@ -89,15 +104,22 @@ pub fn parse_meta(body: &str) -> (String, Vec) { .and_then(Json::as_str) .filter(|s| !s.is_empty()) .map(str::to_string); + let users = it.get("users").and_then(Json::as_f64).map(|n| n as u32); Some(Channel { name: name.to_string(), topic, + users, }) }) .collect(), _ => Vec::new(), }; - (server, channels) + Meta { + server: string("server"), + webchat: string("webchat"), + seen: j.get("seen").and_then(Json::as_f64).unwrap_or(0.0) as u64, + channels, + } } pub fn parse_events(body: &str) -> Vec { @@ -328,6 +350,20 @@ struct Lex { give: &'static str, built: &'static str, updated: &'static str, + online: &'static str, + offline: &'static str, + rising: &'static str, + over_time: &'static str, + most_debated: &'static str, + milestones: &'static str, + reasons_recent: &'static str, + history: &'static str, + reached: &'static str, + bumps: &'static str, + filter_ph: &'static str, + sort_karma: &'static str, + sort_name: &'static str, + sort_active: &'static str, } fn lex(l: Locale) -> Lex { @@ -353,6 +389,20 @@ fn lex(l: Locale) -> Lex { give: "give karma by saying nick++ in the channel", built: "built by", updated: "updated", + online: "online", + offline: "offline", + rising: "rising this week", + over_time: "karma over time", + most_debated: "most debated", + milestones: "milestones", + reasons_recent: "recent reasons", + history: "history", + reached: "reached", + bumps: "bumps", + filter_ph: "filter…", + sort_karma: "karma", + sort_name: "name", + sort_active: "active", }, Locale::Es => Lex { tagline: "reputación ganada de a un nick++, en todos los canales.", @@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex { give: "da karma escribiendo nick++ en el canal", built: "hecho con", updated: "actualizado", + online: "en línea", + offline: "desconectado", + rising: "en alza esta semana", + over_time: "karma en el tiempo", + most_debated: "más debatidos", + milestones: "hitos", + reasons_recent: "razones recientes", + history: "historial", + reached: "llegó a", + bumps: "cambios", + filter_ph: "filtrar…", + sort_karma: "karma", + sort_name: "nombre", + sort_active: "activos", }, Locale::Fr => Lex { tagline: "la réputation gagnée un nick++ à la fois, sur les canaux.", @@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex { give: "donne du karma en disant nick++ dans le canal", built: "fait avec", updated: "mis à jour", + online: "en ligne", + offline: "hors ligne", + rising: "en hausse cette semaine", + over_time: "karma dans le temps", + most_debated: "les plus débattus", + milestones: "jalons", + reasons_recent: "raisons récentes", + history: "historique", + reached: "a atteint", + bumps: "changements", + filter_ph: "filtrer…", + sort_karma: "karma", + sort_name: "nom", + sort_active: "actifs", }, } } @@ -417,7 +495,10 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String { .collect(); let gifts = feed.len(); - let mut s = String::from(HEAD); + let now = updated_epoch; + let mut s = HEAD + .replace("__LANG__", locale.code()) + .replace("__OG__", &og_tags(boards, &lx)); let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1; s.push_str("
rubot@devtronic
"); @@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String { continue; } s.push_str("
"); - s.push_str(&source_line(b, &lx)); - s.push_str(&podium(b)); + s.push_str(&source_line(b, now, &lx)); + s.push_str(&podium(b, &lx)); s.push_str(&ledger(b, &lx)); s.push_str("
"); } + s.push_str(&rising(&feed, now, &lx)); + s.push_str(&over_time(&feed, now, &lx)); + s.push_str("
"); s.push_str(&activity(&feed, multi, &lx)); s.push_str(&givers_panel(&feed, &lx)); s.push_str("
"); + s.push_str("
"); + s.push_str(&most_debated(&feed, &lx)); + s.push_str(&milestones(&feed, &lx)); + s.push_str(&reasons_wall(&feed, &lx)); + s.push_str("
"); + finish(s, updated_epoch, locale, &lx) } +/// A machine-readable snapshot of the board(s) — served alongside the page as +/// `karma.json` so other tools/bots can read the data. +pub fn render_json(boards: &[Board], updated_epoch: u64) -> String { + let nets: Vec = boards + .iter() + .map(|b| { + let channels: Vec = b + .channels + .iter() + .map(|c| { + let mut o = format!("{{\"name\":\"{}\"", json::escape(&c.name)); + if let Some(t) = &c.topic { + o.push_str(&format!(",\"topic\":\"{}\"", json::escape(t))); + } + if let Some(u) = c.users { + o.push_str(&format!(",\"users\":{u}")); + } + o.push('}'); + o + }) + .collect(); + let things: Vec = b + .rows + .iter() + .map(|r| { + let mut o = format!( + "{{\"thing\":\"{}\",\"karma\":{}", + json::escape(&r.thing), + r.count + ); + if let Some(w) = &r.reason { + o.push_str(&format!(",\"reason\":\"{}\"", json::escape(w))); + } + if let Some(by) = &r.by { + o.push_str(&format!(",\"by\":\"{}\"", json::escape(by))); + } + o.push('}'); + o + }) + .collect(); + format!( + "{{\"network\":\"{}\",\"server\":\"{}\",\"channels\":[{}],\"things\":[{}]}}", + json::escape(&b.network), + json::escape(&b.server), + channels.join(","), + things.join(",") + ) + }) + .collect(); + format!( + "{{\"generated\":{updated_epoch},\"networks\":[{}]}}\n", + nets.join(",") + ) +} + +fn og_tags(boards: &[Board], lx: &Lex) -> String { + let things: usize = boards.iter().map(|b| b.rows.len()).sum(); + let top = boards + .iter() + .flat_map(|b| &b.rows) + .max_by_key(|r| r.count) + .map(|r| format!(" · {} {}", esc(&r.thing), signed(r.count))) + .unwrap_or_default(); + let desc = format!("{things} {}{top}", lx.s_tracked); + format!( + "\ + \ + \ + \ + " + ) +} + fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String { s.push_str(&footer(epoch, lx)); s.push_str(" javascript:alert(1)".to_string()), + users: None, }]; let html = render(&[b], 0, Locale::En); assert!(!html.contains("