From 21e36b99614ae4ed290e54baf11a14df281b0975 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 20:30:04 +0000 Subject: [PATCH] Add karma module + on_message hook --- .gitignore | 3 +- README.md | 1 + src/bot/commands.rs | 37 +++++++++- src/bot/module.rs | 13 ++++ src/bot/modules/karma.rs | 144 +++++++++++++++++++++++++++++++++++++++ src/bot/modules/mod.rs | 2 + src/json.rs | 16 +++++ tests/karma.rs | 77 +++++++++++++++++++++ 8 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/bot/modules/karma.rs create mode 100644 tests/karma.rs diff --git a/.gitignore b/.gitignore index af888df..1f4c9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ Cargo.lock rustbot.service rustbot.conf -weather.*.json \ No newline at end of file +weather.*.json +karma.*.json \ No newline at end of file diff --git a/README.md b/README.md index b32a642..259fd43 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ lists them at runtime and `help ` describes one. |---|---| | `ping` / `echo ` / `hello` | basics | | `roll [NdM]` | roll dice (default `1d6`) | +| `karma ` (bump with `thing++` / `thing--` in chat) | show/track karma | | `md5` / `sha1` / `sha256` / `hash ` | hex digests | | `add ` then `w [location]` | weather (wttr.in), per-user location | | `crypto ` | coin price in USD (Bitstamp) | diff --git a/src/bot/commands.rs b/src/bot/commands.rs index a2316e6..1bb30e2 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -1,6 +1,6 @@ use std::io; -use super::module::{Action, Command}; +use super::module::{Action, Chat, Command}; use super::Bot; use crate::irc::{now_unix, parse_server_time, Message}; @@ -56,6 +56,8 @@ impl Bot { sender.to_string() }; + self.dispatch_message(sender, &reply_to, text)?; + let Some(rest) = text.strip_prefix(&self.config.command_prefix) else { return Ok(()); }; @@ -66,6 +68,39 @@ impl Bot { self.handle_command(&reply_to, sender, &command, &args) } + fn dispatch_message(&mut self, sender: &str, reply_to: &str, text: &str) -> io::Result<()> { + let prefix = self.config.command_prefix.clone(); + let network = self.config.name.clone(); + let chat = Chat { + sender, + reply_to, + text, + prefix: &prefix, + network: &network, + }; + for i in 0..self.modules.len() { + let actions = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.modules[i].on_message(&chat) + })) { + Ok(actions) => actions, + Err(_) => { + crate::log(&format!( + "module '{}' panicked on message", + self.modules[i].name() + )); + Vec::new() + } + }; + for action in actions { + match action { + Action::Reply(t) => self.conn.privmsg(reply_to, &t)?, + Action::Raw(l) => self.conn.send_raw(&l)?, + } + } + } + Ok(()) + } + fn handle_command( &mut self, reply_to: &str, diff --git a/src/bot/module.rs b/src/bot/module.rs index c47f364..b9fcf6e 100644 --- a/src/bot/module.rs +++ b/src/bot/module.rs @@ -14,6 +14,14 @@ pub struct Command<'a> { pub network: &'a str, } +pub struct Chat<'a> { + pub sender: &'a str, + pub reply_to: &'a str, + pub text: &'a str, + pub prefix: &'a str, + pub network: &'a str, +} + pub enum Action { Reply(String), Raw(String), @@ -29,4 +37,9 @@ pub trait Module { fn name(&self) -> &'static str; fn commands(&self) -> &'static [CommandSpec]; fn on_command(&mut self, cmd: &Command) -> Vec; + + fn on_message(&mut self, chat: &Chat) -> Vec { + let _ = chat; + Vec::new() + } } diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs new file mode 100644 index 0000000..f5e0a6e --- /dev/null +++ b/src/bot/modules/karma.rs @@ -0,0 +1,144 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +use crate::bot::module::{Action, Chat, Command, CommandSpec, Module}; +use crate::json::{self, Json}; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "karma", + usage: "karma ", + about: "show karma; bump it in chat with thing++ / thing--", +}]; + +pub struct Karma { + store: Store, +} + +impl Karma { + pub fn new(network: &str) -> Karma { + let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); + let safe: String = network + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + Karma::with_path(PathBuf::from(dir).join(format!("karma.{safe}.json"))) + } + + pub fn with_path(path: PathBuf) -> Karma { + Karma { + store: Store::load(path), + } + } + + pub fn get(&self, thing: &str) -> i64 { + self.store.get(thing) + } +} + +impl Module for Karma { + fn name(&self) -> &'static str { + "karma" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(thing) = cmd.args.first() else { + return vec![Action::reply(format!("usage: {}karma ", cmd.prefix))]; + }; + vec![Action::reply(format!( + "{thing} has {} karma", + self.store.get(thing) + ))] + } + + fn on_message(&mut self, chat: &Chat) -> Vec { + let changes = parse_changes(chat.text, chat.sender); + if changes.is_empty() { + return Vec::new(); + } + for (thing, delta) in changes { + self.store.bump(&thing, delta); + } + if let Err(e) = self.store.save() { + crate::log(&format!( + "karma: could not save {}: {e}", + self.store.path.display() + )); + } + Vec::new() + } +} + +pub fn parse_changes(text: &str, sender: &str) -> Vec<(String, i64)> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for raw in text.split_whitespace() { + let tok = raw.trim_end_matches([',', '.', '!', '?', ';', ':']); + let (thing, delta) = if let Some(t) = tok.strip_suffix("++") { + (t, 1) + } else if let Some(t) = tok.strip_suffix("--") { + (t, -1) + } else { + continue; + }; + let thing = thing.trim_start_matches('(').trim_end_matches(')'); + if thing.is_empty() { + continue; + } + let key = thing.to_lowercase(); + if key == sender.to_lowercase() { + continue; + } + if seen.insert(key) { + out.push((thing.to_string(), delta)); + } + } + out +} + +struct Store { + map: HashMap, + path: PathBuf, +} + +impl Store { + fn load(path: PathBuf) -> Store { + let map = fs::read_to_string(&path) + .ok() + .and_then(|s| Json::parse(&s)) + .map(|j| { + let mut m = HashMap::new(); + if let Json::Obj(entries) = j { + for (k, v) in entries { + if let Some(n) = v.as_f64() { + m.insert(k, n as i64); + } + } + } + m + }) + .unwrap_or_default(); + Store { map, path } + } + + fn save(&self) -> std::io::Result<()> { + let items: Vec = self + .map + .iter() + .map(|(k, v)| format!("\"{}\":{}", json::escape(k), v)) + .collect(); + fs::write(&self.path, format!("{{{}}}\n", items.join(","))) + } + + fn bump(&mut self, thing: &str, delta: i64) { + *self.map.entry(thing.to_lowercase()).or_insert(0) += delta; + } + + fn get(&self, thing: &str) -> i64 { + *self.map.get(&thing.to_lowercase()).unwrap_or(&0) + } +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index c5278f4..5e44b0b 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -4,6 +4,7 @@ pub mod define; pub mod dice; pub mod down; pub mod hash; +pub mod karma; pub mod tinyurl; pub mod urban; pub mod weather; @@ -16,6 +17,7 @@ pub fn all(network: &str) -> Vec> { Box::new(builtins::Builtins), Box::new(dice::Dice::new()), Box::new(hash::Hash), + Box::new(karma::Karma::new(network)), Box::new(crypto::Crypto), Box::new(define::Define), Box::new(down::Down), diff --git a/src/json.rs b/src/json.rs index ca28f4c..a5ec31a 100644 --- a/src/json.rs +++ b/src/json.rs @@ -50,6 +50,22 @@ impl Json { } } +pub fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + struct Parser<'a> { it: Peekable>, } diff --git a/tests/karma.rs b/tests/karma.rs new file mode 100644 index 0000000..56eca5d --- /dev/null +++ b/tests/karma.rs @@ -0,0 +1,77 @@ +use rustbot::bot::module::{Action, Chat, Command, Module}; +use rustbot::bot::modules::karma::{parse_changes, Karma}; + +fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { + Chat { + sender, + reply_to: "#c", + text, + prefix: ">", + network: "t", + } +} + +#[test] +fn parses_increments_and_decrements() { + assert_eq!( + parse_changes("foo++ bar-- baz", "someone"), + vec![("foo".to_string(), 1), ("bar".to_string(), -1)] + ); +} + +#[test] +fn strips_trailing_punctuation_and_parens() { + let c = parse_changes("nice work rust++, and (team)++!", "someone"); + assert!(c.contains(&("rust".to_string(), 1)), "{c:?}"); + assert!(c.contains(&("team".to_string(), 1)), "{c:?}"); +} + +#[test] +fn no_self_karma_case_insensitive() { + assert!(parse_changes("me++", "me").is_empty()); + assert!(parse_changes("Me++", "me").is_empty()); +} + +#[test] +fn dedupes_within_a_message() { + assert_eq!( + parse_changes("foo++ foo++ foo++", "x"), + vec![("foo".to_string(), 1)] + ); +} + +#[test] +fn bumps_persist_case_insensitively() { + let path = std::env::temp_dir().join("rubot-test-karma-a.json"); + let _ = std::fs::remove_file(&path); + let mut k = Karma::with_path(path.clone()); + k.on_message(&chat("alice", "Rust++ rust++ python--")); + + let reloaded = Karma::with_path(path.clone()); + assert_eq!(reloaded.get("rust"), 1); // deduped to one bump + assert_eq!(reloaded.get("RUST"), 1); + assert_eq!(reloaded.get("python"), -1); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn command_reports_karma() { + let path = std::env::temp_dir().join("rubot-test-karma-b.json"); + let _ = std::fs::remove_file(&path); + let mut k = Karma::with_path(path.clone()); + k.on_message(&chat("a", "foo++ foo++")); + let cmd = Command { + sender: "a", + reply_to: "#c", + name: "karma", + args: &["foo"], + prefix: ">", + network: "t", + }; + let out = match k.on_command(&cmd).as_slice() { + [Action::Reply(s)] => s.clone(), + _ => panic!("expected one reply"), + }; + assert!(out.contains("foo has 1 karma"), "{out}"); + let _ = std::fs::remove_file(&path); +}