karma: reasons, milestones, no-highlight replies
All checks were successful
ci / check (push) Successful in 47s

This commit is contained in:
Jean Chevronnet 2026-07-29 22:29:01 +00:00
parent af9d5eed25
commit e801b04d6e
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
3 changed files with 222 additions and 87 deletions

View file

@ -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<String> = entries.iter().map(|(k, v)| format!("{k} ({v})")).collect();
let items: Vec<String> = 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<String> {
let toks: Vec<&str> = text.split_whitespace().collect();
let bumps: Vec<usize> = 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<String>,
by: Option<String>,
}
struct Store {
map: HashMap<String, i64>,
map: HashMap<String, Entry>,
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<String> = 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),
}
}