karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 01:27:39 +00:00
parent 26496b5147
commit 6af92c1217
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 714 additions and 101 deletions

View file

@ -79,17 +79,24 @@ lists them at runtime and `help <command>` describes one.
## Karma web page
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
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 **<https://karma.devtronic.pro>**. 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.

View file

@ -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)
}

View file

@ -23,6 +23,8 @@ pub struct Bot {
modules: Vec<Box<dyn Module>>,
routes: HashMap<&'static str, usize>,
topics: HashMap<String, String>,
users: HashMap<String, u32>,
names_building: HashMap<String, u32>,
}
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<String>)> = self
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let channels: Vec<String> = 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 <channel> :<topic>
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
// RPL_NAMREPLY: <nick> <sym> <channel> :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)?,
_ => {}
}

View file

@ -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<String>)]) {
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::<Vec<_>>()
.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");

View file

@ -20,6 +20,7 @@ pub struct Config {
pub sasl_pass: Option<String>,
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 {

View file

@ -21,6 +21,14 @@ pub struct Event {
pub struct Channel {
pub name: String,
pub topic: Option<String>,
pub users: Option<u32>,
}
pub struct Meta {
pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>,
}
pub struct Board {
@ -28,6 +36,8 @@ pub struct Board {
pub rows: Vec<Row>,
pub events: Vec<Event>,
pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>,
}
@ -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.<net>.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<Channel>) {
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<Channel>) {
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<Channel>) {
.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<Event> {
@ -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 <code>nick++</code> 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 <code>nick++</code>, en todos los canales.",
@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex {
give: "da karma escribiendo <code>nick++</code> 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 <code>nick++</code> à la fois, sur les canaux.",
@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex {
give: "donne du karma en disant <code>nick++</code> 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("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
continue;
}
s.push_str("<section class=\"board fade\">");
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("</section>");
}
s.push_str(&rising(&feed, now, &lx));
s.push_str(&over_time(&feed, now, &lx));
s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi, &lx));
s.push_str(&givers_panel(&feed, &lx));
s.push_str("</div>");
s.push_str("<div class=\"band3\">");
s.push_str(&most_debated(&feed, &lx));
s.push_str(&milestones(&feed, &lx));
s.push_str(&reasons_wall(&feed, &lx));
s.push_str("</div>");
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<String> = boards
.iter()
.map(|b| {
let channels: Vec<String> = 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<String> = 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!(
"<meta property=\"og:type\" content=\"website\">\
<meta property=\"og:title\" content=\"karma · devtronic\">\
<meta property=\"og:description\" content=\"{desc}\">\
<meta name=\"twitter:card\" content=\"summary\">\
<meta name=\"description\" content=\"{desc}\">"
)
}
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
s.push_str(&footer(epoch, lx));
s.push_str("<script>var C=");
@ -469,13 +632,29 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
}
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
fn source_line(b: &Board, lx: &Lex) -> String {
fn source_line(b: &Board, now: u64, lx: &Lex) -> String {
let online = b.seen > 0 && now.saturating_sub(b.seen) < 300;
let dot = format!(
"<span class=\"dot {}\" title=\"{}\"></span>",
if online { "on" } else { "off" },
if online { lx.online } else { lx.offline }
);
let mut parts: Vec<String> = Vec::new();
if !b.channels.is_empty() {
let chans = b
.channels
.iter()
.map(|c| esc(&c.name))
.map(|c| {
let users = match c.users {
Some(u) => format!(" <span class=\"users\">{u} {}</span>", lx.online),
None => String::new(),
};
format!(
"<a class=\"chan\" href=\"{}\" rel=\"noopener\" target=\"_blank\">{}</a>{users}",
join_url(b, &c.name),
esc(&c.name)
)
})
.collect::<Vec<_>>()
.join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>"));
@ -487,7 +666,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
}
let mut out = format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
"<div class=\"source\">{dot}<span class=\"lbl\">{}</span>{}</div>",
lx.from,
parts.join("<span class=\"sep\">·</span>")
);
@ -495,6 +674,19 @@ fn source_line(b: &Board, lx: &Lex) -> String {
out
}
/// A clickable join target for a channel: the configured webchat, else an
/// `ircs://` URL. Returned already escaped for use in an `href`.
fn join_url(b: &Board, channel: &str) -> String {
let raw = if !b.webchat.is_empty() {
format!("{}{}", b.webchat, channel)
} else if !b.server.is_empty() {
format!("ircs://{}/{}", b.server, channel.replace('#', "%23"))
} else {
String::new()
};
esc(&raw)
}
fn topics_block(b: &Board) -> String {
let multi = b.channels.len() > 1;
let items: Vec<String> = b
@ -544,7 +736,52 @@ fn stat(num: &str, label: &str) -> String {
)
}
fn podium(b: &Board) -> String {
fn hist_html(thing: &str, events: &[Event], lx: &Lex) -> String {
let key = thing.to_lowercase();
let mut evs: Vec<&Event> = events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.collect();
if evs.is_empty() {
return String::new();
}
evs.sort_by_key(|e| std::cmp::Reverse(e.t));
let items: String = evs
.iter()
.take(20)
.map(|e| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
let chip = if e.delta >= 0 { "++" } else { "--" };
let why = match &e.why {
Some(w) => format!(" <span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
None => String::new(),
};
format!(
"<li class=\"hev\"><span class=\"chip {kl}\">{chip}</span> <b>{}</b>{why} \
<time class=\"ago\" data-t=\"{}\"></time></li>",
esc(&by),
e.t,
kl = klass(e.delta),
)
})
.collect();
format!("<ol class=\"hist\" hidden>{items}</ol>")
}
/// `(class-suffix, title-attr)` marking an element expandable when it has a
/// non-empty history block.
fn expand_attrs(hist: &str, lx: &Lex) -> (String, String) {
if hist.is_empty() {
(String::new(), String::new())
} else {
(
" expand".to_string(),
format!(" title=\"{}\"", esc(lx.history)),
)
}
}
fn podium(b: &Board, lx: &Lex) -> String {
let top = b.rows.iter().take(3).collect::<Vec<_>>();
let mut out = String::from("<ol class=\"podium\">");
for (i, r) in top.iter().enumerate() {
@ -565,9 +802,11 @@ fn podium(b: &Board) -> String {
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
_ => String::new(),
};
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!(
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>",
"<li class=\"pillar r{rank}{expand}\"{title}><div class=\"crown\">{crown}</div>{}\
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}{hist}</li>",
avatar(&r.thing, "lg"),
esc(&r.thing),
klass(r.count),
@ -582,8 +821,19 @@ fn ledger(b: &Board, lx: &Lex) -> String {
if b.rows.len() <= 3 {
return String::new();
}
let controls = format!(
"<div class=\"controls\">\
<input id=\"q\" class=\"filter\" type=\"search\" placeholder=\"{ph}\" autocomplete=\"off\" aria-label=\"{ph}\">\
<div class=\"sorts\"><button type=\"button\" data-sort=\"score\" class=\"on\">{}</button>\
<button type=\"button\" data-sort=\"name\">{}</button>\
<button type=\"button\" data-sort=\"active\">{}</button></div></div>",
lx.sort_karma,
lx.sort_name,
lx.sort_active,
ph = esc(lx.filter_ph),
);
let mut out = format!(
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
"<div class=\"eyebrow\">{}</div>{controls}<ol class=\"list ledger\">",
lx.ledger
);
for (i, r) in b.rows.iter().enumerate().skip(3) {
@ -598,14 +848,25 @@ fn ledger(b: &Board, lx: &Lex) -> String {
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
_ => String::new(),
};
let key = r.thing.to_lowercase();
let active = b
.events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.count();
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\
{spark}<span class=\"score {}\">{}</span></li>",
avatar(&r.thing, "sm"),
esc(&r.thing),
klass(r.count),
signed(r.count),
"<li class=\"row thing{expand}\"{title} data-name=\"{namel}\" data-score=\"{score}\" data-active=\"{active}\">\
<span class=\"rank\">{rank}</span>{av}\
<div class=\"who\"><div class=\"nick\">{name}</div>{reason}</div>\
{spark}<span class=\"score {kl}\">{sg}</span>{hist}</li>",
namel = esc(&key),
score = r.count,
av = avatar(&r.thing, "sm"),
name = esc(&r.thing),
kl = klass(r.count),
sg = signed(r.count),
));
}
out.push_str("</ol>");
@ -693,6 +954,182 @@ fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
out
}
fn rising(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
let week = now.saturating_sub(7 * 86400);
let mut m: HashMap<String, (String, i64)> = HashMap::new();
for (_, e) in feed {
if e.t >= week {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0));
ent.1 += e.delta;
}
}
let mut v: Vec<(String, i64)> = m.into_values().filter(|(_, d)| *d != 0).collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.abs()
.cmp(&a.1.abs())
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(8);
let chips: String = v
.iter()
.map(|(name, d)| {
format!(
"<span class=\"tchip {}\">{} <b>{}</b></span>",
klass(*d),
esc(name),
signed(*d)
)
})
.collect();
format!(
"<section class=\"strip fade\"><div class=\"eyebrow\">🔥 {}</div><div class=\"chips\">{chips}</div></section>",
lx.rising
)
}
fn over_time(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
const DAYS: usize = 30;
let day = 86400u64;
let start = now.saturating_sub((DAYS as u64 - 1) * day);
let mut buckets = [0u32; DAYS];
for (_, e) in feed {
if e.t >= start {
let idx = ((e.t - start) / day) as usize;
if idx < DAYS {
buckets[idx] += 1;
}
}
}
let max = *buckets.iter().max().unwrap_or(&0);
if max == 0 {
return String::new();
}
let bars: String = buckets
.iter()
.map(|c| {
let h = (*c as f64 / max as f64 * 100.0).round() as u32;
format!(
"<span class=\"bar\" style=\"height:{}%\" title=\"{c}\"></span>",
h.max(3)
)
})
.collect();
let total: u32 = buckets.iter().sum();
format!(
"<section class=\"chart fade\"><div class=\"eyebrow\">{} \
<span class=\"count\">{total} · {DAYS}d</span></div><div class=\"bars\">{bars}</div></section>",
lx.over_time
)
}
fn most_debated(feed: &[(&str, &Event)], lx: &Lex) -> String {
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
for (_, e) in feed {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0, 0));
ent.1 += 1;
ent.2 += e.delta;
}
let mut v: Vec<(String, i64, i64)> = m.into_values().collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(6);
let items: String = v
.iter()
.map(|(name, bumps, net)| {
format!(
"<li class=\"row\"><div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{bumps} {}</span></div><span class=\"score {}\">{}</span></li>",
esc(name),
lx.bumps,
klass(*net),
signed(*net)
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.most_debated
)
}
fn milestones(feed: &[(&str, &Event)], lx: &Lex) -> String {
const LEVELS: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
let mut evs: Vec<&(&str, &Event)> = feed.iter().collect();
evs.sort_by_key(|(_, e)| e.t);
let mut cum: HashMap<String, i64> = HashMap::new();
let mut hits: Vec<(String, i64, u64)> = Vec::new();
for (_, e) in evs {
let c = cum.entry(e.thing.to_lowercase()).or_insert(0);
let before = *c;
*c += e.delta;
let after = *c;
for &lv in LEVELS {
if before < lv && after >= lv {
hits.push((e.thing.clone(), lv, e.t));
}
}
}
if hits.is_empty() {
return String::new();
}
hits.sort_by_key(|(_, _, t)| std::cmp::Reverse(*t));
hits.truncate(8);
let items: String = hits
.iter()
.map(|(name, lv, t)| {
format!(
"<li class=\"hev\">🎉 <b>{}</b> {} <b>{lv}</b> \
<time class=\"ago\" data-t=\"{t}\"></time></li>",
esc(name),
lx.reached
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.milestones
)
}
fn reasons_wall(feed: &[(&str, &Event)], lx: &Lex) -> String {
let items: String = feed
.iter()
.filter(|(_, e)| e.why.is_some())
.take(6)
.map(|(_, e)| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
format!(
"<li class=\"quote\"><div class=\"q\">\u{201C}{}\u{201D}</div>\
<div class=\"qm\"><b>{}</b> \u{2192} <b>{}</b> \
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
esc(e.why.as_deref().unwrap_or("")),
esc(&by),
esc(&e.thing),
e.t
)
})
.collect();
if items.is_empty() {
return String::new();
}
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ul class=\"quotes\">{items}</ul></section>",
lx.reasons_recent
)
}
fn footer(epoch: u64, lx: &Lex) -> String {
format!(
"<footer class=\"fade\">{} &middot; {} \
@ -732,11 +1169,12 @@ fn time_cfg(l: Locale) -> String {
}
const HEAD: &str = r##"<!doctype html>
<html lang="en">
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>karma · devtronic</title>
__OG__
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
<style>
:root{
@ -844,6 +1282,41 @@ 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}
.dot{width:9px;height:9px;border-radius:50%;flex:none}
.dot.on{background:var(--up);box-shadow:0 0 0 3px rgba(95,211,159,.16)}
.dot.off{background:var(--faint)}
.source .chan{font-family:var(--mono);color:var(--gold);font-weight:600}
.source .chan:hover{text-decoration:underline}
.source .users{font-family:var(--mono);color:var(--faint);font-size:11px}
.strip{margin:30px 2px 6px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.tchip{font-family:var(--mono);font-size:13px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:5px 12px}
.tchip.up{color:var(--up)}.tchip.down{color:var(--down)}
.tchip b{font-weight:700}
.chart{margin:24px 2px 6px}
.chart .eyebrow{margin:0 0 10px}
.chart .count{margin-left:auto;color:var(--faint);font-family:var(--mono);font-size:11px}
.bars{display:flex;align-items:flex-end;gap:3px;height:64px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
.bar{flex:1;background:linear-gradient(180deg,var(--gold),rgba(245,182,66,.3));border-radius:2px;min-height:2px}
.band3{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px}
@media(min-width:720px){.band3{grid-template-columns:1fr 1fr 1fr}}
.quotes{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
.quote .q{color:var(--fg);font-size:13.5px;line-height:1.5}
.quote .qm{color:var(--muted);font-size:12px;margin-top:2px}
.quote .qm b{font-family:var(--mono);font-weight:600}
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 2px 12px}
.filter{flex:1;min-width:150px;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font-size:14px}
.filter:focus{outline:none;border-color:var(--gold)}
.sorts{display:flex;gap:6px}
.sorts button{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 11px;cursor:pointer}
.sorts button.on{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:600}
.row.thing,.pillar.expand{cursor:pointer}
.row.thing{flex-wrap:wrap}
.hist{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;flex-basis:100%;width:100%}
.pillar .hist{text-align:left}
.hev{font-size:12.5px;color:var(--muted);word-break:break-word}
.hev b{font-family:var(--mono);color:var(--fg);font-weight:600}
.hev .chip{font-size:11px;padding:0 5px}
.empty{text-align:center;color:var(--muted);padding:56px 10px}
@media(max-width:560px){
@ -878,6 +1351,38 @@ 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);
});
// click a row/pillar to toggle its history
document.querySelectorAll(".row.thing,.pillar.expand").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest("a"))return;
var h=el.querySelector(".hist");if(h)h.hidden=!h.hidden;
});
});
// filter the ledger
var q=document.getElementById("q");
if(q){q.addEventListener("input",function(){
var t=q.value.toLowerCase();
document.querySelectorAll("ol.ledger>li.row").forEach(function(li){
li.style.display=li.textContent.toLowerCase().indexOf(t)>=0?"":"none";
});
});}
// sort the ledger
document.querySelectorAll(".sorts button").forEach(function(btn){
btn.addEventListener("click",function(){
var key=btn.getAttribute("data-sort");
var list=document.querySelector("ol.ledger");if(!list)return;
document.querySelectorAll(".sorts button").forEach(function(b){b.classList.remove("on");});
btn.classList.add("on");
var rows=Array.prototype.slice.call(list.querySelectorAll("li.row"));
rows.sort(function(a,b){
if(key=="name")return a.getAttribute("data-name").localeCompare(b.getAttribute("data-name"));
var an=parseInt(a.getAttribute(key=="active"?"data-active":"data-score"),10);
var bn=parseInt(b.getAttribute(key=="active"?"data-active":"data-score"),10);
return bn-an;
});
rows.forEach(function(r){list.appendChild(r);});
});
});
setTimeout(function(){location.reload();},60000);
})();"##;

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel};
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Channel};
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
let mut b = parse_board(net, store);
@ -102,51 +102,64 @@ fn renders_spanish_chrome() {
}
#[test]
fn parses_meta_channels_with_and_without_topic() {
// new format: objects with an optional topic
let (server, channels) = parse_meta(
r##"{"server":"irc.libera.chat","channels":[{"name":"#argentina","topic":"Bienvenidos"},{"name":"#ar"}]}"##,
fn parses_meta_channels_topic_and_users() {
let m = parse_meta(
r##"{"server":"irc.libera.chat","webchat":"https://web.libera.chat/","seen":123,"channels":[{"name":"#argentina","topic":"Bienvenidos","users":42},{"name":"#ar"}]}"##,
);
assert_eq!(server, "irc.libera.chat");
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].name, "#argentina");
assert_eq!(channels[0].topic.as_deref(), Some("Bienvenidos"));
assert!(channels[1].topic.is_none());
// old format (bare strings) still parses
let (_, old) = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
assert_eq!(old[0].name, "#c");
assert!(old[0].topic.is_none());
assert_eq!(m.server, "irc.libera.chat");
assert_eq!(m.webchat, "https://web.libera.chat/");
assert_eq!(m.seen, 123);
assert_eq!(m.channels.len(), 2);
assert_eq!(m.channels[0].name, "#argentina");
assert_eq!(m.channels[0].topic.as_deref(), Some("Bienvenidos"));
assert_eq!(m.channels[0].users, Some(42));
assert!(m.channels[1].topic.is_none() && m.channels[1].users.is_none());
// old bare-string format still parses
let old = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
assert_eq!(old.channels[0].name, "#c");
// garbage degrades to empty
let (s, c) = parse_meta("not json");
assert!(s.is_empty() && c.is_empty());
let g = parse_meta("not json");
assert!(g.server.is_empty() && g.channels.is_empty());
}
#[test]
fn renders_source_channel_server_and_topic() {
fn renders_source_topic_users_join_and_online() {
let mut b = parse_board("libera", r#"{"romaka":2}"#);
b.server = "irc.libera.chat".to_string();
b.webchat = "https://web.libera.chat/".to_string();
b.seen = 1000;
b.channels = vec![Channel {
name: "#argentina".to_string(),
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
users: Some(42),
}];
let en = render(&[b], 0, Locale::En);
let en = render(&[b], 1100, Locale::En); // now close to seen -> online
assert!(en.contains("#argentina"), "channel missing");
assert!(en.contains("irc.libera.chat"), "server missing");
assert!(en.contains(">from<"), "from label missing");
// the topic text shows, and its URL becomes a safe link
assert!(en.contains("Argentina en Libera"), "topic text missing");
assert!(en.contains("Argentina en Libera"), "topic missing");
assert!(
en.contains("<a href=\"https://qadlo.example/\""),
"topic url not linkified: {en}"
"topic url not linkified"
);
// spanish label still works with no topic
let mut b2 = parse_board("libera", r#"{"romaka":2}"#);
b2.channels = vec![Channel {
name: "#argentina".to_string(),
topic: None,
}];
let es = render(&[b2], 0, Locale::Es);
assert!(es.contains(">desde<") && es.contains("#argentina"), "{es}");
assert!(en.contains("42 online"), "user count missing");
assert!(
en.contains("https://web.libera.chat/#argentina"),
"join link missing"
);
assert!(en.contains("class=\"dot on\""), "online dot missing");
// og description present
assert!(
en.contains("property=\"og:description\""),
"og tags missing"
);
}
#[test]
fn online_dot_goes_offline_when_stale() {
let mut b = parse_board("t", r#"{"x":1}"#);
b.seen = 1000;
let html = render(&[b], 9_999_999, Locale::En);
assert!(html.contains("class=\"dot off\""), "should be offline");
}
#[test]
@ -155,6 +168,7 @@ fn topic_cannot_inject_markup() {
b.channels = vec![Channel {
name: "#c".to_string(),
topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
users: None,
}];
let html = render(&[b], 0, Locale::En);
assert!(!html.contains("<script>evil"), "topic markup leaked");
@ -162,6 +176,51 @@ fn topic_cannot_inject_markup() {
assert!(html.contains("&lt;script&gt;evil"), "topic not escaped");
}
#[test]
fn renders_analytics_sections() {
let now = 2_000_000u64;
let mut log = String::new();
for i in 0..12u64 {
let t = now - (12 - i) * 3600;
let why = if i == 0 {
",\"why\":\"clean code\""
} else {
""
};
log.push_str(&format!(
"{{\"t\":{t},\"o\":\"rust\",\"d\":1,\"by\":\"nadia\"{why}}}\n"
));
}
log.push_str(&format!(
"{{\"t\":{},\"o\":\"php\",\"d\":-1,\"by\":\"sam\",\"why\":\"footgun\"}}\n",
now - 1000
));
let b = board_with_events("libera", r#"{"rust":12,"php":-1}"#, &log);
let html = render(&[b], now, Locale::En);
assert!(html.contains("rising this week"), "no rising strip");
assert!(html.contains("karma over time"), "no chart");
assert!(html.contains("most debated"), "no most-debated");
assert!(
html.contains("milestones") && html.contains("reached"),
"no milestones"
);
assert!(html.contains("recent reasons"), "no reasons wall");
assert!(
html.contains("clean code") && html.contains("footgun"),
"reasons missing"
);
}
#[test]
fn render_json_is_machine_readable() {
let b = parse_board("libera", r#"{"rust":{"n":5,"why":"clean","by":"a"}}"#);
let j = render_json(&[b], 42);
assert!(j.contains("\"generated\":42"), "{j}");
assert!(j.contains("\"network\":\"libera\""), "{j}");
assert!(j.contains("\"thing\":\"rust\",\"karma\":5"), "{j}");
assert!(j.contains("\"reason\":\"clean\""), "{j}");
}
#[test]
fn escapes_untrusted_irc_input_everywhere() {
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";