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 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. Each network's board 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 shows where its karma comes from — server, channels, and each channel's IRC
second binary, `karma-web`, reads the per-network `karma.*.json` totals, the topic (e.g. `#argentina`). A second binary, `karma-web`, reads the per-network
append-only `karma.*.events.jsonl` history the bot writes on every bump, and a `karma.*.json` totals, the append-only `karma.*.events.jsonl` history the bot
`karma.*.meta.json` source sidecar (server + channels, written by the bot at writes on every bump, and a `karma.*.meta.json` source sidecar (server +
startup), and renders one self-contained HTML page (no CSS/JS/font dependencies, 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 all IRC-supplied text HTML-escaped). A systemd timer regenerates it every minute
and nginx serves it over TLS. and nginx serves it over TLS.

View file

@ -22,14 +22,13 @@ pub struct Bot {
sasl_started: bool, sasl_started: bool,
modules: Vec<Box<dyn Module>>, modules: Vec<Box<dyn Module>>,
routes: HashMap<&'static str, usize>, routes: HashMap<&'static str, usize>,
topics: HashMap<String, String>,
} }
impl Bot { 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),
@ -48,7 +47,7 @@ impl Bot {
} }
} }
Bot { let bot = Bot {
config, config,
conn, conn,
current_nick, current_nick,
@ -59,7 +58,38 @@ impl Bot {
sasl_started: false, sasl_started: false,
modules: mods, modules: mods,
routes, 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<()> { pub fn run(&mut self) -> io::Result<()> {
@ -104,6 +134,12 @@ impl Bot {
let nick = self.current_nick.clone(); let nick = self.current_nick.clone();
self.conn.nick(&nick)?; 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)?, "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 /// 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. /// 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 let chans = channels
.iter() .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<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
let body = format!( let body = format!(

View file

@ -18,12 +18,17 @@ pub struct Event {
pub why: Option<String>, pub why: Option<String>,
} }
pub struct Channel {
pub name: String,
pub topic: Option<String>,
}
pub struct Board { 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 server: String,
pub channels: Vec<String>, pub channels: Vec<Channel>,
} }
pub fn parse_board(network: &str, body: &str) -> Board { 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 /// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
/// network's karma comes from. /// network's karma comes from. Channels may be bare strings (old format) or
pub fn parse_meta(body: &str) -> (String, Vec<String>) { /// `{"name","topic"}` objects.
pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
let Some(j) = Json::parse(body) else { let Some(j) = Json::parse(body) else {
return (String::new(), Vec::new()); return (String::new(), Vec::new());
}; };
@ -67,8 +73,27 @@ pub fn parse_meta(body: &str) -> (String, Vec<String>) {
let channels = match j.get("channels") { let channels = match j.get("channels") {
Some(Json::Arr(items)) => items Some(Json::Arr(items)) => items
.iter() .iter()
.filter_map(Json::as_str) .filter_map(|it| {
.map(str::to_string) 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(), .collect(),
_ => Vec::new(), _ => Vec::new(),
}; };
@ -450,7 +475,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
let chans = b let chans = b
.channels .channels
.iter() .iter()
.map(|c| esc(c)) .map(|c| esc(&c.name))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>")); parts.push(format!("<span class=\"chans\">{chans}</span>"));
@ -461,11 +486,54 @@ fn source_line(b: &Board, lx: &Lex) -> String {
if parts.is_empty() { if parts.is_empty() {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network))); parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
} }
format!( let mut out = format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>", "<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
lx.from, lx.from,
parts.join("<span class=\"sep\">·</span>") 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 { 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 .chans{font-family:var(--mono);color:var(--gold);font-weight:600}
.source .host{font-family:var(--mono);color:var(--faint)} .source .host{font-family:var(--mono);color:var(--faint)}
.source .sep{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; .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}

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale; 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 { 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);
@ -102,31 +102,66 @@ fn renders_spanish_chrome() {
} }
#[test] #[test]
fn parses_meta_server_and_channels() { fn parses_meta_channels_with_and_without_topic() {
let (server, channels) = // new format: objects with an optional topic
parse_meta(r##"{"server":"irc.libera.chat","channels":["#argentina","#ar"]}"##); 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!(server, "irc.libera.chat");
assert_eq!(channels, vec!["#argentina".to_string(), "#ar".to_string()]); assert_eq!(channels.len(), 2);
// missing/garbage meta degrades to empty assert_eq!(channels[0].name, "#argentina");
assert_eq!(parse_meta("not json"), (String::new(), Vec::new())); 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] #[test]
fn renders_source_channel_and_server() { fn renders_source_channel_server_and_topic() {
let mut b = parse_board("libera", r#"{"romaka":2}"#); let mut b = parse_board("libera", r#"{"romaka":2}"#);
b.server = "irc.libera.chat".to_string(); 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); 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("irc.libera.chat"), "server missing");
assert!(en.contains(">from<"), "from label 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}"#); 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); let es = render(&[b2], 0, Locale::Es);
assert!(es.contains(">desde<") && es.contains("#argentina"), "{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] #[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";