This commit is contained in:
parent
12961f66da
commit
21e36b9961
8 changed files with 291 additions and 2 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ Cargo.lock
|
||||||
rustbot.service
|
rustbot.service
|
||||||
rustbot.conf
|
rustbot.conf
|
||||||
weather.*.json
|
weather.*.json
|
||||||
|
karma.*.json
|
||||||
|
|
@ -68,6 +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>` (bump with `thing++` / `thing--` in chat) | show/track karma |
|
||||||
| `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) |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use super::module::{Action, Command};
|
use super::module::{Action, Chat, Command};
|
||||||
use super::Bot;
|
use super::Bot;
|
||||||
use crate::irc::{now_unix, parse_server_time, Message};
|
use crate::irc::{now_unix, parse_server_time, Message};
|
||||||
|
|
||||||
|
|
@ -56,6 +56,8 @@ impl Bot {
|
||||||
sender.to_string()
|
sender.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.dispatch_message(sender, &reply_to, text)?;
|
||||||
|
|
||||||
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
@ -66,6 +68,39 @@ impl Bot {
|
||||||
self.handle_command(&reply_to, sender, &command, &args)
|
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(
|
fn handle_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
reply_to: &str,
|
reply_to: &str,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ pub struct Command<'a> {
|
||||||
pub network: &'a str,
|
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 {
|
pub enum Action {
|
||||||
Reply(String),
|
Reply(String),
|
||||||
Raw(String),
|
Raw(String),
|
||||||
|
|
@ -29,4 +37,9 @@ pub trait Module {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
fn commands(&self) -> &'static [CommandSpec];
|
fn commands(&self) -> &'static [CommandSpec];
|
||||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
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
144
src/bot/modules/karma.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ pub mod define;
|
||||||
pub mod dice;
|
pub mod dice;
|
||||||
pub mod down;
|
pub mod down;
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
|
pub mod karma;
|
||||||
pub mod tinyurl;
|
pub mod tinyurl;
|
||||||
pub mod urban;
|
pub mod urban;
|
||||||
pub mod weather;
|
pub mod weather;
|
||||||
|
|
@ -16,6 +17,7 @@ pub fn all(network: &str) -> Vec<Box<dyn Module>> {
|
||||||
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)),
|
||||||
Box::new(crypto::Crypto),
|
Box::new(crypto::Crypto),
|
||||||
Box::new(define::Define),
|
Box::new(define::Define),
|
||||||
Box::new(down::Down),
|
Box::new(down::Down),
|
||||||
|
|
|
||||||
16
src/json.rs
16
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> {
|
struct Parser<'a> {
|
||||||
it: Peekable<Chars<'a>>,
|
it: Peekable<Chars<'a>>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
tests/karma.rs
Normal file
77
tests/karma.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue