Add karma module + on_message hook
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-29 20:30:04 +00:00
parent 12961f66da
commit 21e36b9961
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
8 changed files with 291 additions and 2 deletions

View file

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

View file

@ -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<Action>;
fn on_message(&mut self, chat: &Chat) -> Vec<Action> {
let _ = chat;
Vec::new()
}
}

144
src/bot/modules/karma.rs Normal file
View file

@ -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 <thing>",
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<Action> {
let Some(thing) = cmd.args.first() else {
return vec![Action::reply(format!("usage: {}karma <thing>", cmd.prefix))];
};
vec![Action::reply(format!(
"{thing} has {} karma",
self.store.get(thing)
))]
}
fn on_message(&mut self, chat: &Chat) -> Vec<Action> {
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<String, i64>,
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<String> = 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)
}
}

View file

@ -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<dyn Module>> {
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),

View file

@ -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<Chars<'a>>,
}