diff --git a/README.md b/README.md index bec330f..47cf5d3 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,12 @@ lists them at runtime and `help ` describes one. 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, 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. diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 26a6bdf..408ecda 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -22,14 +22,13 @@ pub struct Bot { sasl_started: bool, modules: Vec>, routes: HashMap<&'static str, usize>, + topics: HashMap, } 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)> = 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): : + "332" => self.set_topic(msg.param(1), msg.trailing()), + // RPL_NOTOPIC: :No topic is set + "331" => self.set_topic(msg.param(1), Some("")), + // live topic change: :who TOPIC : + "TOPIC" => self.set_topic(msg.param(0), msg.trailing()), "PRIVMSG" => self.handle_privmsg(msg)?, _ => {} } diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs index dfc3850..28299c5 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -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)]) { 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::>() .join(","); let body = format!( diff --git a/src/karma_web.rs b/src/karma_web.rs index 8a7d1fc..b832d78 100644 --- a/src/karma_web.rs +++ b/src/karma_web.rs @@ -18,12 +18,17 @@ pub struct Event { pub why: Option, } +pub struct Channel { + pub name: String, + pub topic: Option, +} + pub struct Board { pub network: String, pub rows: Vec, pub events: Vec, pub server: String, - pub channels: Vec, + pub channels: Vec, } 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..meta.json` sidecar the bot writes: where this -/// network's karma comes from. -pub fn parse_meta(body: &str) -> (String, Vec) { +/// 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()); }; @@ -67,8 +73,27 @@ pub fn parse_meta(body: &str) -> (String, Vec) { 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::>() .join(" "); parts.push(format!("{chans}")); @@ -461,11 +486,54 @@ fn source_line(b: &Board, lx: &Lex) -> String { if parts.is_empty() { parts.push(format!("{}", esc(&b.network))); } - format!( + let mut out = format!( "
{}{}
", lx.from, parts.join("ยท") - ) + ); + out.push_str(&topics_block(b)); + out +} + +fn topics_block(b: &Board) -> String { + let multi = b.channels.len() > 1; + let items: Vec = b + .channels + .iter() + .filter_map(|c| c.topic.as_ref().map(|t| (c, t))) + .map(|(c, t)| { + let prefix = if multi { + format!("{} ", esc(&c.name)) + } else { + String::new() + }; + format!( + "
{prefix}{}
", + linkify(t) + ) + }) + .collect(); + if items.is_empty() { + String::new() + } else { + format!("
{}
", 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!("{u}") + } else { + esc(tok) + } + }) + .collect::>() + .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} diff --git a/tests/karma_web.rs b/tests/karma_web.rs index 689d64c..a20855d 100644 --- a/tests/karma_web.rs +++ b/tests/karma_web.rs @@ -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("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(" javascript:alert(1)".to_string()), + }]; + let html = render(&[b], 0, Locale::En); + assert!(!html.contains("