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
|
|
@ -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)?,
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue