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
All checks were successful
ci / check (push) Successful in 46s
This commit is contained in:
parent
7f6327270b
commit
4ccca54963
7 changed files with 145 additions and 29 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue