karma: karmaweb command + web-board link on top lists, via configurable karma_url
All checks were successful
ci / check (push) Successful in 47s

This commit is contained in:
Jean Chevronnet 2026-07-30 00:39:06 +00:00
parent 739fc8ad94
commit 7f6327270b
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 92 additions and 7 deletions

View file

@ -68,7 +68,7 @@ lists them at runtime and `help <command>` describes one.
|---|---| |---|---|
| `ping` / `echo <text>` / `hello` | basics | | `ping` / `echo <text>` / `hello` | basics |
| `roll [NdM]` | roll dice (default `1d6`) | | `roll [NdM]` | roll dice (default `1d6`) |
| `karma [thing]` / `karmabottom` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom; milestones announced | | `karma [thing]` / `karmabottom` / `karmaweb` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom (with a link to the web board when `karma_url` is set); `karmaweb` prints that link; milestones announced |
| `md5` / `sha1` / `sha256` / `hash <text>` | hex digests | | `md5` / `sha1` / `sha256` / `hash <text>` | hex digests |
| `add <location>` then `w [location]` | weather (wttr.in), per-user location | | `add <location>` then `w [location]` | weather (wttr.in), per-user location |
| `crypto <sym>` | coin price in USD (Bitstamp) | | `crypto <sym>` | coin price in USD (Bitstamp) |
@ -124,7 +124,9 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default `channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
`locale` (`en`/`es`/`fr`, default `en`). Whole-line `#`/`;` comments only — `locale` (`en`/`es`/`fr`, default `en`), and `karma_url` (public web board —
enables the `karmaweb` command and a link on the `karma`/`karmabottom` top
lists; empty disables both). Whole-line `#`/`;` comments only —
inline comments would clash with channel `#`s. inline comments would clash with channel `#`s.
### Locales ### Locales

View file

@ -28,7 +28,11 @@ 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();
let mods = modules::all(&config.name, crate::i18n::Locale::from_code(&config.locale)); let mods = modules::all(
&config.name,
crate::i18n::Locale::from_code(&config.locale),
(!config.karma_url.is_empty()).then(|| config.karma_url.clone()),
);
let mut routes: HashMap<&'static str, usize> = HashMap::new(); let mut routes: HashMap<&'static str, usize> = HashMap::new();
for (i, m) in mods.iter().enumerate() { for (i, m) in mods.iter().enumerate() {
for spec in m.commands() { for spec in m.commands() {

View file

@ -20,6 +20,11 @@ const COMMANDS: &[CommandSpec] = &[
usage: "karmabottom", usage: "karmabottom",
about: "the lowest-karma things", about: "the lowest-karma things",
}, },
CommandSpec {
name: "karmaweb",
usage: "karmaweb",
about: "link to the web karma board",
},
]; ];
const MAX_THING_LEN: usize = 32; const MAX_THING_LEN: usize = 32;
@ -31,10 +36,11 @@ const ZWSP: char = '\u{200B}';
pub struct Karma { pub struct Karma {
store: Store, store: Store,
locale: Locale, locale: Locale,
url: Option<String>,
} }
impl Karma { impl Karma {
pub fn new(network: &str, locale: Locale) -> Karma { pub fn new(network: &str, locale: Locale, url: Option<String>) -> Karma {
let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string());
let safe: String = network let safe: String = network
.chars() .chars()
@ -44,6 +50,7 @@ impl Karma {
PathBuf::from(dir).join(format!("karma.{safe}.json")), PathBuf::from(dir).join(format!("karma.{safe}.json")),
locale, locale,
) )
.with_url(url)
} }
pub fn with_path(path: PathBuf) -> Karma { pub fn with_path(path: PathBuf) -> Karma {
@ -54,9 +61,15 @@ impl Karma {
Karma { Karma {
store: Store::load(path), store: Store::load(path),
locale, locale,
url: None,
} }
} }
pub fn with_url(mut self, url: Option<String>) -> Karma {
self.url = url;
self
}
pub fn get(&self, thing: &str) -> i64 { pub fn get(&self, thing: &str) -> i64 {
self.store.entry(thing).map(|e| e.count).unwrap_or(0) self.store.entry(thing).map(|e| e.count).unwrap_or(0)
} }
@ -112,7 +125,31 @@ impl Karma {
(Locale::Fr, false) => "top karma", (Locale::Fr, false) => "top karma",
(Locale::Fr, true) => "pire karma", (Locale::Fr, true) => "pire karma",
}; };
format!("{label}: {}", items.join(", ")) let mut out = format!("{label}: {}", items.join(", "));
if let Some(u) = &self.url {
let more = match self.locale {
Locale::En => "full board",
Locale::Es => "tablero completo",
Locale::Fr => "tableau complet",
};
out.push_str(&format!(" · {more}: {u}"));
}
out
}
fn web_link(&self) -> String {
match &self.url {
Some(u) => match self.locale {
Locale::En => format!("karma board: {u}"),
Locale::Es => format!("tablero de karma: {u}"),
Locale::Fr => format!("tableau de karma : {u}"),
},
None => match self.locale {
Locale::En => "no web board configured".to_string(),
Locale::Es => "no hay tablero web configurado".to_string(),
Locale::Fr => "pas de tableau web configuré".to_string(),
},
}
} }
} }
@ -132,6 +169,7 @@ impl Module for Karma {
None => self.leaderboard(false), None => self.leaderboard(false),
}, },
"karmabottom" => self.leaderboard(true), "karmabottom" => self.leaderboard(true),
"karmaweb" => self.web_link(),
_ => return vec![], _ => return vec![],
}; };
vec![Action::reply(reply)] vec![Action::reply(reply)]

View file

@ -13,12 +13,12 @@ pub mod wiki;
use super::module::Module; use super::module::Module;
use crate::i18n::Locale; use crate::i18n::Locale;
pub fn all(network: &str, locale: Locale) -> Vec<Box<dyn Module>> { pub fn all(network: &str, locale: Locale, karma_url: Option<String>) -> Vec<Box<dyn Module>> {
vec![ vec![
Box::new(builtins::Builtins), Box::new(builtins::Builtins),
Box::new(dice::Dice::new()), Box::new(dice::Dice::new()),
Box::new(hash::Hash), Box::new(hash::Hash),
Box::new(karma::Karma::new(network, locale)), Box::new(karma::Karma::new(network, locale, karma_url)),
Box::new(crypto::Crypto), Box::new(crypto::Crypto),
Box::new(define::Define), Box::new(define::Define),
Box::new(down::Down), Box::new(down::Down),

View file

@ -19,6 +19,7 @@ pub struct Config {
pub sasl_user: Option<String>, pub sasl_user: Option<String>,
pub sasl_pass: Option<String>, pub sasl_pass: Option<String>,
pub locale: String, pub locale: String,
pub karma_url: String,
} }
impl Default for Config { impl Default for Config {
@ -38,6 +39,7 @@ impl Default for Config {
sasl_user: None, sasl_user: None,
sasl_pass: None, sasl_pass: None,
locale: "en".to_string(), locale: "en".to_string(),
karma_url: String::new(),
} }
} }
} }
@ -163,6 +165,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
"locale" | "lang" => c.locale = value.to_string(), "locale" | "lang" => c.locale = value.to_string(),
"karma_url" | "web_url" => c.karma_url = value.to_string(),
other => log(&format!("{where_}: unknown key '{other}'")), other => log(&format!("{where_}: unknown key '{other}'")),
} }
} }
@ -223,6 +226,9 @@ fn apply_env(c: &mut Config) {
if let Ok(v) = std::env::var("IRC_LOCALE") { if let Ok(v) = std::env::var("IRC_LOCALE") {
c.locale = v; c.locale = v;
} }
if let Ok(v) = std::env::var("IRC_KARMA_URL") {
c.karma_url = v;
}
} }
fn parse_bool(value: &str) -> bool { fn parse_bool(value: &str) -> bool {

View file

@ -259,3 +259,38 @@ fn replies_in_spanish_for_es_locale() {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log); let _ = std::fs::remove_file(&log);
} }
#[test]
fn karmaweb_command_and_leaderboard_suffix() {
let path = std::env::temp_dir().join("rubot-test-karma-web.json");
let log = std::env::temp_dir().join("rubot-test-karma-web.events.jsonl");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
let mut k =
Karma::with_path(path.clone()).with_url(Some("https://karma.devtronic.pro/".to_string()));
// dedicated command returns the link
let w = reply_of(&mut k, "karmaweb", &[]);
assert!(w.contains("https://karma.devtronic.pro/"), "{w}");
// link is appended to the aggregate list...
k.on_message(&chat("a", "rust++ python++"));
let top = reply_of(&mut k, "karma", &[]);
assert!(top.starts_with("karma top:"), "{top}");
assert!(
top.contains("full board: https://karma.devtronic.pro/"),
"{top}"
);
// ...but not to a single-thing lookup (kept clean)
let one = reply_of(&mut k, "karma", &["rust"]);
assert!(!one.contains("http"), "{one}");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
}
#[test]
fn karmaweb_without_url_is_graceful() {
let path = std::env::temp_dir().join("rubot-test-karma-nourl.json");
let _ = std::fs::remove_file(&path);
let mut k = Karma::with_path(path.clone());
assert!(reply_of(&mut k, "karmaweb", &[]).contains("no web board configured"));
let _ = std::fs::remove_file(&path);
}