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

@ -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}