karma-web: capture channel topics (332/TOPIC) and show them on each board
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 00:58:10 +00:00
parent 4ccca54963
commit 26496b5147
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 182 additions and 31 deletions

View file

@ -82,11 +82,12 @@ lists them at runtime and `help <command>` describes one.
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, 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,
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.

View file

@ -22,14 +22,13 @@ pub struct Bot {
sasl_started: bool,
modules: Vec<Box<dyn Module>>,
routes: HashMap<&'static str, usize>,
topics: HashMap<String, String>,
}
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),
@ -48,7 +47,7 @@ impl Bot {
}
}
Bot {
let bot = Bot {
config,
conn,
current_nick,
@ -59,7 +58,38 @@ impl Bot {
sasl_started: false,
modules: mods,
routes,
topics: 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.
fn write_source_meta(&self) {
let channels: Vec<(String, Option<String>)> = self
.config
.channels
.iter()
.map(|c| {
let topic = self
.topics
.get(&c.to_lowercase())
.filter(|t| !t.is_empty())
.cloned();
(c.clone(), topic)
})
.collect();
modules::karma::write_meta(&self.config.name, &self.config.server, &channels);
}
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
let Some(channel) = channel else {
return;
};
self.topics
.insert(channel.to_lowercase(), topic.unwrap_or("").to_string());
self.write_source_meta();
}
pub fn run(&mut self) -> io::Result<()> {
@ -104,6 +134,12 @@ impl Bot {
let nick = self.current_nick.clone();
self.conn.nick(&nick)?;
}
// RPL_TOPIC (on join): <nick> <channel> :<topic>
"332" => self.set_topic(msg.param(1), msg.trailing()),
// RPL_NOTOPIC: <nick> <channel> :No topic is set
"331" => self.set_topic(msg.param(1), Some("")),
// live topic change: :who TOPIC <channel> :<topic>
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
"PRIVMSG" => self.handle_privmsg(msg)?,
_ => {}
}

View file

@ -438,10 +438,17 @@ 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]) {
pub fn write_meta(network: &str, server: &str, channels: &[(String, Option<String>)]) {
let chans = channels
.iter()
.map(|c| format!("\"{}\"", json::escape(c)))
.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!(

View file

@ -18,12 +18,17 @@ pub struct Event {
pub why: Option<String>,
}
pub struct Channel {
pub name: String,
pub topic: Option<String>,
}
pub struct Board {
pub network: String,
pub rows: Vec<Row>,
pub events: Vec<Event>,
pub server: String,
pub channels: Vec<String>,
pub channels: Vec<Channel>,
}
pub fn parse_board(network: &str, body: &str) -> Board {
@ -54,8 +59,9 @@ 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.
pub fn parse_meta(body: &str) -> (String, Vec<String>) {
/// 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());
};
@ -67,8 +73,27 @@ pub fn parse_meta(body: &str) -> (String, Vec<String>) {
let channels = match j.get("channels") {
Some(Json::Arr(items)) => items
.iter()
.filter_map(Json::as_str)
.map(str::to_string)
.filter_map(|it| {
if let Some(name) = it.as_str() {
return (!name.is_empty()).then(|| Channel {
name: name.to_string(),
topic: None,
});
}
let name = it.get("name").and_then(Json::as_str)?;
if name.is_empty() {
return None;
}
let topic = it
.get("topic")
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(Channel {
name: name.to_string(),
topic,
})
})
.collect(),
_ => Vec::new(),
};
@ -450,7 +475,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
let chans = b
.channels
.iter()
.map(|c| esc(c))
.map(|c| esc(&c.name))
.collect::<Vec<_>>()
.join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>"));
@ -461,11 +486,54 @@ fn source_line(b: &Board, lx: &Lex) -> String {
if parts.is_empty() {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
}
format!(
let mut out = format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
lx.from,
parts.join("<span class=\"sep\">·</span>")
);
out.push_str(&topics_block(b));
out
}
fn topics_block(b: &Board) -> String {
let multi = b.channels.len() > 1;
let items: Vec<String> = b
.channels
.iter()
.filter_map(|c| c.topic.as_ref().map(|t| (c, t)))
.map(|(c, t)| {
let prefix = if multi {
format!("<span class=\"ch\">{}</span> ", esc(&c.name))
} else {
String::new()
};
format!(
"<div class=\"topic\">{prefix}<span class=\"tx\">{}</span></div>",
linkify(t)
)
})
.collect();
if items.is_empty() {
String::new()
} else {
format!("<div class=\"topics\">{}</div>", items.join(""))
}
}
/// Escape topic text, turning bare http(s) URLs into links. Only http/https
/// prefixes are linked (no `javascript:` etc.), and the href is escaped.
fn linkify(text: &str) -> String {
text.split(' ')
.map(|tok| {
if tok.starts_with("https://") || tok.starts_with("http://") {
let u = esc(tok);
format!("<a href=\"{u}\" rel=\"nofollow noopener\" target=\"_blank\">{u}</a>")
} else {
esc(tok)
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn stat(num: &str, label: &str) -> String {
@ -711,6 +779,10 @@ margin:34px 2px 14px;display:flex;align-items:center;gap:10px}
.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)}
.topics{margin:-4px 2px 18px;display:flex;flex-direction:column;gap:5px}
.topic{color:var(--muted);font-size:13px;line-height:1.55;word-break:break-word}
.topic .ch{font-family:var(--mono);color:var(--faint);margin-right:4px}
.topic .tx a{color:var(--gold)}
.podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end;
gap:14px;flex-wrap:wrap}

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render};
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel};
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
let mut b = parse_board(net, store);
@ -102,31 +102,66 @@ fn renders_spanish_chrome() {
}
#[test]
fn parses_meta_server_and_channels() {
let (server, channels) =
parse_meta(r##"{"server":"irc.libera.chat","channels":["#argentina","#ar"]}"##);
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"}]}"##,
);
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()));
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());
// garbage degrades to empty
let (s, c) = parse_meta("not json");
assert!(s.is_empty() && c.is_empty());
}
#[test]
fn renders_source_channel_and_server() {
fn renders_source_channel_server_and_topic() {
let mut b = parse_board("libera", r#"{"romaka":2}"#);
b.server = "irc.libera.chat".to_string();
b.channels = vec!["#argentina".to_string()];
b.channels = vec![Channel {
name: "#argentina".to_string(),
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
}];
let en = render(&[b], 0, Locale::En);
assert!(en.contains("#argentina"), "channel missing: {en}");
assert!(en.contains("#argentina"), "channel missing");
assert!(en.contains("irc.libera.chat"), "server missing");
assert!(en.contains(">from<"), "from label missing");
// localized label
// the topic text shows, and its URL becomes a safe link
assert!(en.contains("Argentina en Libera"), "topic text missing");
assert!(
en.contains("<a href=\"https://qadlo.example/\""),
"topic url not linkified: {en}"
);
// spanish label still works with no topic
let mut b2 = parse_board("libera", r#"{"romaka":2}"#);
b2.channels = vec!["#argentina".to_string()];
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}");
}
#[test]
fn topic_cannot_inject_markup() {
let mut b = parse_board("t", r#"{"x":1}"#);
b.channels = vec![Channel {
name: "#c".to_string(),
topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
}];
let html = render(&[b], 0, Locale::En);
assert!(!html.contains("<script>evil"), "topic markup leaked");
assert!(!html.contains("href=\"javascript:"), "js scheme linkified");
assert!(html.contains("&lt;script&gt;evil"), "topic not escaped");
}
#[test]
fn escapes_untrusted_irc_input_everywhere() {
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";