diff --git a/README.md b/README.md index c1c4d03..7c9b4b1 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ lists them at runtime and `help ` describes one. |---|---| | `ping` / `echo ` / `hello` | basics | | `roll [NdM]` | roll dice (default `1d6`) | -| `karma [thing]` / `karmabottom` (bump with `thing++` / `thing--`) | karma + rank, or the top/bottom list | +| `karma [thing]` / `karmabottom` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom; milestones announced | | `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/modules/karma.rs b/src/bot/modules/karma.rs index fc42c85..eab4ca4 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -19,7 +19,10 @@ const COMMANDS: &[CommandSpec] = &[ ]; const MAX_THING_LEN: usize = 32; +const MAX_REASON_LEN: usize = 120; const TOP_N: usize = 5; +const MILESTONES: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]; +const ZWSP: char = '\u{200B}'; pub struct Karma { store: Store, @@ -42,18 +45,26 @@ impl Karma { } pub fn get(&self, thing: &str) -> i64 { - self.store.get(thing) + self.store.entry(thing).map(|e| e.count).unwrap_or(0) } fn report(&self, thing: &str) -> String { - if !self.store.has(thing) { - return format!("no karma for '{thing}' yet"); - } + let Some(e) = self.store.entry(thing) else { + return format!("no karma for '{}' yet", dehighlight(thing)); + }; let (rank, total) = self.store.rank(thing); - format!( - "{thing} has {} karma (#{rank} of {total})", - self.store.get(thing) - ) + let mut out = format!( + "{} has {} karma (#{rank} of {total})", + dehighlight(thing), + e.count + ); + if let Some(reason) = &e.reason { + match &e.by { + Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))), + None => out.push_str(&format!(" — last: \"{reason}\"")), + } + } + out } fn leaderboard(&self, bottom: bool) -> String { @@ -61,7 +72,10 @@ impl Karma { if entries.is_empty() { return "no karma yet".to_string(); } - let items: Vec = entries.iter().map(|(k, v)| format!("{k} ({v})")).collect(); + let items: Vec = entries + .iter() + .map(|(k, v)| format!("{} ({v})", dehighlight(k))) + .collect(); format!( "karma {}: {}", if bottom { "bottom" } else { "top" }, @@ -96,8 +110,22 @@ impl Module for Karma { if changes.is_empty() { return Vec::new(); } - for (thing, delta) in changes { - self.store.bump(&thing, delta); + // A reason is only attributed when the message has a single bump. + let reason = extract_reason(chat.text); + let mut announces = Vec::new(); + for (thing, delta) in &changes { + let r = if changes.len() == 1 { + reason.as_deref() + } else { + None + }; + let new = self.store.bump(thing, *delta, r, chat.sender); + if *delta > 0 && MILESTONES.contains(&new) { + announces.push(Action::reply(format!( + "🎉 {} reached {new} karma!", + dehighlight(thing) + ))); + } } if let Err(e) = self.store.save() { crate::log(&format!( @@ -105,7 +133,7 @@ impl Module for Karma { self.store.path.display() )); } - Vec::new() + announces } } @@ -136,13 +164,62 @@ pub fn parse_changes(text: &str, sender: &str) -> Vec<(String, i64)> { out } +/// The reason trailing a bump, but only when the message has exactly one bump +/// (otherwise it's ambiguous which thing the reason belongs to). +pub fn extract_reason(text: &str) -> Option { + let toks: Vec<&str> = text.split_whitespace().collect(); + let bumps: Vec = toks + .iter() + .enumerate() + .filter(|(_, t)| { + let t = t.trim_end_matches([',', '.', '!', '?', ';', ':']); + t.ends_with("++") || t.ends_with("--") + }) + .map(|(i, _)| i) + .collect(); + if bumps.len() != 1 { + return None; + } + let mut reason = toks[bumps[0] + 1..].join(" "); + reason = reason.trim().to_string(); + for prefix in ["for ", "because ", "cause ", "b/c "] { + if let Some(r) = reason.strip_prefix(prefix) { + reason = r.to_string(); + break; + } + } + let reason = reason.trim_start_matches(['#', ':', '-', ' ']).trim(); + if reason.is_empty() { + None + } else { + Some(reason.chars().take(MAX_REASON_LEN).collect()) + } +} + fn is_valid_target(thing: &str) -> bool { let len = thing.chars().count(); (2..=MAX_THING_LEN).contains(&len) && !thing.chars().all(|c| c.is_ascii_digit()) } +/// Insert a zero-width space after the first character so echoing a name into +/// the channel doesn't ping that nick. +fn dehighlight(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) => format!("{first}{ZWSP}{}", chars.as_str()), + None => String::new(), + } +} + +#[derive(Clone, Default)] +struct Entry { + count: i64, + reason: Option, + by: Option, +} + struct Store { - map: HashMap, + map: HashMap, path: PathBuf, } @@ -155,9 +232,7 @@ impl Store { 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.insert(k, entry_from_json(&v)); } } m @@ -170,7 +245,15 @@ impl Store { let items: Vec = self .map .iter() - .map(|(k, v)| format!("\"{}\":{}", json::escape(k), v)) + .map(|(k, e)| { + format!( + "\"{}\":{{\"n\":{},\"why\":\"{}\",\"by\":\"{}\"}}", + json::escape(k), + e.count, + json::escape(e.reason.as_deref().unwrap_or("")), + json::escape(e.by.as_deref().unwrap_or("")) + ) + }) .collect(); let body = format!("{{{}}}\n", items.join(",")); let mut tmp = self.path.clone().into_os_string(); @@ -180,20 +263,23 @@ impl Store { fs::rename(&tmp, &self.path) } - fn bump(&mut self, thing: &str, delta: i64) { - *self.map.entry(thing.to_lowercase()).or_insert(0) += delta; + fn bump(&mut self, thing: &str, delta: i64, reason: Option<&str>, by: &str) -> i64 { + let e = self.map.entry(thing.to_lowercase()).or_default(); + e.count += delta; + if let Some(r) = reason { + e.reason = Some(r.to_string()); + e.by = Some(by.to_string()); + } + e.count } - fn get(&self, thing: &str) -> i64 { - *self.map.get(&thing.to_lowercase()).unwrap_or(&0) - } - - fn has(&self, thing: &str) -> bool { - self.map.contains_key(&thing.to_lowercase()) + fn entry(&self, thing: &str) -> Option<&Entry> { + self.map.get(&thing.to_lowercase()) } fn ranked(&self, n: usize, bottom: bool) -> Vec<(String, i64)> { - let mut v: Vec<(String, i64)> = self.map.iter().map(|(k, val)| (k.clone(), *val)).collect(); + let mut v: Vec<(String, i64)> = + self.map.iter().map(|(k, e)| (k.clone(), e.count)).collect(); v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); if bottom { v.reverse(); @@ -204,8 +290,8 @@ impl Store { fn rank(&self, thing: &str) -> (usize, usize) { let key = thing.to_lowercase(); - let mut v: Vec<(&String, &i64)> = self.map.iter().collect(); - v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + let mut v: Vec<(&String, i64)> = self.map.iter().map(|(k, e)| (k, e.count)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); let rank = v .iter() .position(|(k, _)| **k == key) @@ -214,3 +300,26 @@ impl Store { (rank, v.len()) } } + +fn entry_from_json(v: &Json) -> Entry { + // Backwards compatible: old files stored a bare number per key. + if let Some(n) = v.as_f64() { + return Entry { + count: n as i64, + ..Entry::default() + }; + } + Entry { + count: v.get("n").and_then(Json::as_f64).unwrap_or(0.0) as i64, + reason: v + .get("why") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string), + by: v + .get("by") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string), + } +} diff --git a/tests/karma.rs b/tests/karma.rs index 5a3ca12..22ced9a 100644 --- a/tests/karma.rs +++ b/tests/karma.rs @@ -1,5 +1,5 @@ use rustbot::bot::module::{Action, Chat, Command, Module}; -use rustbot::bot::modules::karma::{parse_changes, Karma}; +use rustbot::bot::modules::karma::{extract_reason, parse_changes, Karma}; fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { Chat { @@ -11,6 +11,25 @@ fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { } } +fn visible(s: &str) -> String { + s.replace('\u{200B}', "") +} + +fn reply_of(k: &mut Karma, name: &str, args: &[&str]) -> String { + let cmd = Command { + sender: "z", + reply_to: "#c", + name, + args, + prefix: ">", + network: "t", + }; + match k.on_command(&cmd).as_slice() { + [Action::Reply(s)] => visible(s), + _ => panic!("expected one reply"), + } +} + #[test] fn parses_increments_and_decrements() { assert_eq!( @@ -40,57 +59,6 @@ fn dedupes_within_a_message() { ); } -#[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); -} - -fn reply_of(k: &mut Karma, name: &str, args: &[&str]) -> String { - let cmd = Command { - sender: "z", - reply_to: "#c", - name, - args, - prefix: ">", - network: "t", - }; - match k.on_command(&cmd).as_slice() { - [Action::Reply(s)] => s.clone(), - _ => panic!("expected one reply"), - } -} - #[test] fn ignores_single_char_and_numeric_targets() { assert_eq!( @@ -100,15 +68,52 @@ fn ignores_single_char_and_numeric_targets() { } #[test] -fn report_shows_rank() { +fn extract_reason_after_single_bump() { + assert_eq!( + extract_reason("rust++ for the clean PR").as_deref(), + Some("the clean PR") + ); + assert_eq!( + extract_reason("rust++ because it's fast").as_deref(), + Some("it's fast") + ); + assert_eq!( + extract_reason("rust++: solid docs").as_deref(), + Some("solid docs") + ); +} + +#[test] +fn extract_reason_none_when_ambiguous_or_absent() { + assert_eq!(extract_reason("great work rust++"), None); + assert_eq!(extract_reason("foo++ bar++ nice"), None); + assert_eq!(extract_reason("just chatting"), None); +} + +#[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); + assert_eq!(reloaded.get("RUST"), 1); + assert_eq!(reloaded.get("python"), -1); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn report_shows_rank_and_reason() { let path = std::env::temp_dir().join("rubot-test-karma-rank.json"); let _ = std::fs::remove_file(&path); let mut k = Karma::with_path(path.clone()); - k.on_message(&chat("a", "alpha++")); - k.on_message(&chat("b", "alpha++")); - k.on_message(&chat("c", "alpha++ beta++")); - assert!(reply_of(&mut k, "karma", &["alpha"]).contains("alpha has 3 karma (#1 of 2)")); - assert!(reply_of(&mut k, "karma", &["beta"]).contains("beta has 1 karma (#2 of 2)")); + k.on_message(&chat("alice", "rust++ for the clean PR")); + k.on_message(&chat("carol", "rust++")); // no reason -> keeps alice's + k.on_message(&chat("bob", "beta++")); + let r = reply_of(&mut k, "karma", &["rust"]); + assert!(r.contains("rust has 2 karma (#1 of 2)"), "{r}"); + assert!(r.contains("last: \"the clean PR\" (alice)"), "{r}"); let _ = std::fs::remove_file(&path); } @@ -140,3 +145,24 @@ fn leaderboard_top_and_bottom() { ); let _ = std::fs::remove_file(&path); } + +#[test] +fn milestone_announced_at_ten() { + let path = std::env::temp_dir().join("rubot-test-karma-ms.json"); + let _ = std::fs::remove_file(&path); + let mut k = Karma::with_path(path.clone()); + for i in 0..9 { + let s = format!("u{i}"); + assert!( + k.on_message(&chat(&s, "rust++")).is_empty(), + "announce at {i}" + ); + } + let a = k.on_message(&chat("u9", "rust++")); + let msg = match a.as_slice() { + [Action::Reply(s)] => visible(s), + _ => panic!("expected a milestone announce"), + }; + assert!(msg.contains("rust reached 10 karma"), "{msg}"); + let _ = std::fs::remove_file(&path); +}