karma-web: capture channel topics (332/TOPIC) and show them on each board
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
4ccca54963
commit
26496b5147
5 changed files with 182 additions and 31 deletions
11
README.md
11
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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("<script>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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue