diff --git a/docs/configuration.md b/docs/configuration.md index ccbc444..59a5cc1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,6 +155,10 @@ Successful consecutive UTC-day claims also report a per-user streak; missing a day resets the streak to one. The streak is tracked by Daily separately from Duck Hunt's XP and level progression. +Karma totals are tracked per network/channel as well as globally. Existing +legacy Karma values remain global totals; channel-specific totals begin at zero +for those pre-existing items and are populated by new channel updates. + The optional `welcome` plugin listens for users joining a channel. It applies the configured probability to each join and then enforces a per-channel cooldown, so it can add personality without greeting every person in a busy diff --git a/docs/plugins.md b/docs/plugins.md index 8d0fd4f..15c7ff7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -795,8 +795,10 @@ project-- - karma tracks case-insensitive thing++ and thing-- changes. - thing++ and thing-- also work as standalone ordinary chat messages. GoBot confirms each update in one compact line, such as - `🆙 Karma boost! thing +1 (total +2)`; command messages are not treated as - karma changes. + `🆙 Karma boost! thing gained 1 karma ✨ (🎯 1 in #chat | 🌐 2 global)`; + command messages are not treated as karma changes. +- In a channel, Karma reports the channel total and the global total, for + example `🎯 9 in #chat | 🌐 19 global`; private messages report global Karma. - Positive karma milestones at +10, +25, +50, and +100 add a highlighted trophy notice when the total crosses the threshold. - dice accepts NdN notation or a single number such as !roll 20, meaning 1d20. diff --git a/plugins/karma.go b/plugins/karma.go index c2a3be6..e5eb3d6 100644 --- a/plugins/karma.go +++ b/plugins/karma.go @@ -2,9 +2,11 @@ package plugins import ( "encoding/json" + "errors" "fmt" "regexp" "strings" + "sync" "github.com/variablenix/GoBot/bot" "github.com/variablenix/GoBot/storage" @@ -15,6 +17,10 @@ type Karma struct { rx *regexp.Regexp } +const karmaChannelsBucket = "karma_channels" + +var karmaMu sync.Mutex + func (p *Karma) Name() string { return "karma" } func (p *Karma) Commands() []string { return []string{"karma"} } func (p *Karma) Help() string { return "!karma — show karma; thing++ or thing-- changes it" } @@ -26,7 +32,11 @@ func (p *Karma) Init(_ bot.PluginConfig, d *storage.DB) error { func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool { cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix) if !ok { - if updates := p.applyTextChanges(m.Text); len(updates) > 0 { + channel := "" + if m.IsChannel { + channel = m.Target + } + if updates := p.applyTextChanges(b.Config.NetworkName, channel, m.Text); len(updates) > 0 { b.Send(m.ReplyTarget(), formatKarmaUpdates(updates)) return true } @@ -40,11 +50,14 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool { b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !karma ")) return true } - v := 0 - if p.db != nil { - if raw, e := p.db.Get("karma", key); e == nil { - _ = json.Unmarshal(raw, &v) - } + channel := "" + if m.IsChannel { + channel = m.Target + } + channelValue, globalValue := p.readTotals(b.Config.NetworkName, channel, key) + v := globalValue + if channel != "" { + v = channelValue } color := ircYellow if v > 0 { @@ -52,19 +65,25 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool { } else if v < 0 { color = ircRed } - b.Send(m.ReplyTarget(), fmt.Sprintf("%s has karma of %s%+d%s", key, color, v, ircReset)) + message := fmt.Sprintf("%s has karma of %s%+d%s", key, color, v, ircReset) + if channel != "" { + message += fmt.Sprintf(" (🎯 %d in %s | 🌐 %d global)", channelValue, channel, globalValue) + } + b.Send(m.ReplyTarget(), message) return true } type karmaUpdate struct { - key string - delta int - value int + key string + delta int + channel string + channelValue int + globalValue int } var karmaMilestones = []int{10, 25, 50, 100} -func (p *Karma) applyTextChanges(text string) []karmaUpdate { +func (p *Karma) applyTextChanges(network, channel, text string) []karmaUpdate { if p.db == nil || p.rx == nil { return nil } @@ -83,11 +102,11 @@ func (p *Karma) applyTextChanges(text string) []karmaUpdate { if text[match[6]:match[7]] == "++" { delta = 1 } - value, err := p.change(key, delta) + channelValue, globalValue, err := p.changeScoped(network, channel, key, delta) if err != nil { continue } - updates = append(updates, karmaUpdate{key: key, delta: delta, value: value}) + updates = append(updates, karmaUpdate{key: key, delta: delta, channel: channel, channelValue: channelValue, globalValue: globalValue}) } return updates } @@ -105,16 +124,25 @@ func formatKarmaUpdates(updates []karmaUpdate) string { if update.delta >= 0 { negative = false } - details = append(details, fmt.Sprintf("%s %+d (total %+d)", update.key, update.delta, update.value)) + details = append(details, formatKarmaUpdateDetail(update)) } message := "" if positive { - message = ircColor(ircGreen, "🆙 Karma boost! "+strings.Join(details, ", ")+" ✨ 🎯 🌟 💫") + message = ircColor(ircGreen, "🆙 Karma boost! "+strings.Join(details, ", ")+" ✨ 🌟 💫") } else if negative { message = ircColor(ircRed, "Karma dip! "+strings.Join(details, ", ")+" 📉 🌀 💥 😬") } else { message = ircColor(ircCyan, "Karma update! "+strings.Join(details, ", ")+" ✨ 📊 🔄 🌟") } + scopes := make([]string, 0) + for _, update := range updates { + if scope := formatKarmaScope(update); scope != "" { + scopes = append(scopes, scope) + } + } + if len(scopes) > 0 { + message += " " + strings.Join(scopes, " ") + } milestones := make([]string, 0) for _, update := range updates { @@ -129,19 +157,39 @@ func formatKarmaUpdates(updates []karmaUpdate) string { } func crossedKarmaMilestone(update karmaUpdate) int { - if update.delta <= 0 || update.value <= 0 { + if update.delta <= 0 || update.channel == "" || update.channelValue <= 0 { return 0 } - previous := update.value - update.delta + previous := update.channelValue - update.delta reached := 0 for _, milestone := range karmaMilestones { - if previous < milestone && update.value >= milestone { + if previous < milestone && update.channelValue >= milestone { reached = milestone } } return reached } +func formatKarmaUpdateDetail(update karmaUpdate) string { + if update.channel == "" { + return fmt.Sprintf("%s %+d (global total %+d)", update.key, update.delta, update.globalValue) + } + action := "gained" + amount := update.delta + if amount < 0 { + action = "lost" + amount = -amount + } + return fmt.Sprintf("%s %s %d karma", update.key, action, amount) +} + +func formatKarmaScope(update karmaUpdate) string { + if update.channel == "" { + return "" + } + return fmt.Sprintf("(🎯 %d in %s | 🌐 %d global)", update.channelValue, update.channel, update.globalValue) +} + func isKarmaWordByte(value byte) bool { return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || @@ -149,19 +197,77 @@ func isKarmaWordByte(value byte) bool { value == '_' || value == '-' } -func (p *Karma) change(key string, delta int) (int, error) { +func (p *Karma) readTotals(network, channel, key string) (int, int) { if p.db == nil { - return 0, fmt.Errorf("karma storage is unavailable") + return 0, 0 } - value := 0 - if raw, err := p.db.Get("karma", key); err == nil { - if err := json.Unmarshal(raw, &value); err != nil { - return 0, err + karmaMu.Lock() + defer karmaMu.Unlock() + global := readKarmaValue(p.db, "karma", key) + if channel == "" { + return global, global + } + return readKarmaValue(p.db, karmaChannelsBucket, karmaChannelKey(network, channel, key)), global +} + +func (p *Karma) changeScoped(network, channel, key string, delta int) (int, int, error) { + if p.db == nil { + return 0, 0, fmt.Errorf("karma storage is unavailable") + } + karmaMu.Lock() + defer karmaMu.Unlock() + global, err := readKarmaValueStrict(p.db, "karma", key) + if err != nil { + return 0, 0, err + } + if channel == "" { + global += delta + if err := p.db.Set("karma", key, global); err != nil { + return 0, 0, err } + return global, global, nil + } + channelKey := karmaChannelKey(network, channel, key) + channelValue, err := readKarmaValueStrict(p.db, karmaChannelsBucket, channelKey) + if err != nil { + return 0, 0, err + } + global += delta + channelValue += delta + if err := p.db.SetMany( + storage.Entry{Bucket: "karma", Key: key, Value: global}, + storage.Entry{Bucket: karmaChannelsBucket, Key: channelKey, Value: channelValue}, + ); err != nil { + return 0, 0, err + } + return channelValue, global, nil +} + +func (p *Karma) change(key string, delta int) (int, error) { + _, global, err := p.changeScoped("", "", key, delta) + return global, err +} + +func readKarmaValue(db *storage.DB, bucket, key string) int { + value, _ := readKarmaValueStrict(db, bucket, key) + return value +} + +func readKarmaValueStrict(db *storage.DB, bucket, key string) (int, error) { + raw, err := db.Get(bucket, key) + if errors.Is(err, storage.ErrNotFound) { + return 0, nil } - value += delta - if err := p.db.Set("karma", key, value); err != nil { + if err != nil { + return 0, err + } + value := 0 + if err := json.Unmarshal(raw, &value); err != nil { return 0, err } return value, nil } + +func karmaChannelKey(network, channel, key string) string { + return strings.ToLower(strings.TrimSpace(network)) + "\x00" + strings.ToLower(strings.TrimSpace(channel)) + "\x00" + key +} diff --git a/plugins/karma_test.go b/plugins/karma_test.go index fab0e7a..5b07095 100644 --- a/plugins/karma_test.go +++ b/plugins/karma_test.go @@ -28,11 +28,11 @@ func TestKarmaRegex(t *testing.T) { } func TestKarmaUpdateMessageIsColorfulAndCompact(t *testing.T) { - message := formatKarmaUpdates([]karmaUpdate{{key: "echo", delta: 1, value: 4}}) + message := formatKarmaUpdates([]karmaUpdate{{key: "echo", delta: 1, globalValue: 4}}) if !strings.Contains(message, "🆙 Karma boost! echo") { t.Fatalf("unexpected karma message: %q", message) } - if !strings.Contains(message, "✨ 🎯 🌟 💫") { + if !strings.Contains(message, "✨ 🌟 💫") { t.Fatalf("expected spaced positive karma emojis: %q", message) } if !strings.Contains(message, "\x03") { @@ -40,15 +40,22 @@ func TestKarmaUpdateMessageIsColorfulAndCompact(t *testing.T) { } } +func TestKarmaUpdateIncludesChannelAndGlobalTotals(t *testing.T) { + message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, channel: "#chat", channelValue: 9, globalValue: 19}}) + if !strings.Contains(message, "project gained 1 karma") || !strings.Contains(message, "(🎯 9 in #chat | 🌐 19 global)") { + t.Fatalf("message %q does not contain scoped totals", message) + } +} + func TestKarmaDecorationsKeepEmojiSeparated(t *testing.T) { cases := []struct { name string updates []karmaUpdate want string }{ - {name: "positive", updates: []karmaUpdate{{key: "thing", delta: 1, value: 4}}, want: "✨ 🎯 🌟 💫"}, - {name: "negative", updates: []karmaUpdate{{key: "thing", delta: -1, value: -1}}, want: "📉 🌀 💥 😬"}, - {name: "mixed", updates: []karmaUpdate{{key: "thing", delta: 1, value: 1}, {key: "other", delta: -1, value: -1}}, want: "✨ 📊 🔄 🌟"}, + {name: "positive", updates: []karmaUpdate{{key: "thing", delta: 1, globalValue: 4}}, want: "✨ 🌟 💫"}, + {name: "negative", updates: []karmaUpdate{{key: "thing", delta: -1, globalValue: -1}}, want: "📉 🌀 💥 😬"}, + {name: "mixed", updates: []karmaUpdate{{key: "thing", delta: 1, globalValue: 1}, {key: "other", delta: -1, globalValue: -1}}, want: "✨ 📊 🔄 🌟"}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { @@ -60,7 +67,7 @@ func TestKarmaDecorationsKeepEmojiSeparated(t *testing.T) { } func TestKarmaMilestoneDecoration(t *testing.T) { - message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, value: 25}}) + message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, channel: "#chat", channelValue: 25, globalValue: 25}}) if !strings.Contains(message, "project has reached +25 karma! 🏆") { t.Fatalf("missing milestone notice: %q", message) } @@ -68,14 +75,72 @@ func TestKarmaMilestoneDecoration(t *testing.T) { t.Fatalf("expected milestone color formatting: %q", message) } - if message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, value: 26}}); strings.Contains(message, "has reached") { + if message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, channel: "#chat", channelValue: 26, globalValue: 26}}); strings.Contains(message, "has reached") { t.Fatalf("milestone repeated without crossing a threshold: %q", message) } - if message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: -1, value: -25}}); strings.Contains(message, "has reached") { + if message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: -1, channel: "#chat", channelValue: -25, globalValue: -25}}); strings.Contains(message, "has reached") { t.Fatalf("negative karma unexpectedly received a positive milestone: %q", message) } } +func TestKarmaTracksChannelAndGlobalTotals(t *testing.T) { + db, err := storage.Open(filepath.Join(t.TempDir(), "karma.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + p := &Karma{} + if err := p.Init(bot.PluginConfig{}, db); err != nil { + t.Fatal(err) + } + + if updates := p.applyTextChanges("primary", "#chat", "project++ project++"); len(updates) != 2 { + t.Fatalf("expected two channel updates, got %v", updates) + } + if updates := p.applyTextChanges("primary", "#other", "project++"); len(updates) != 1 { + t.Fatalf("expected one second-channel update, got %v", updates) + } + + channel, global := p.readTotals("primary", "#chat", "project") + if channel != 2 || global != 3 { + t.Fatalf("#chat totals = (%d, %d), want (2, 3)", channel, global) + } + channel, global = p.readTotals("primary", "#other", "project") + if channel != 1 || global != 3 { + t.Fatalf("#other totals = (%d, %d), want (1, 3)", channel, global) + } + channel, global = p.readTotals("secondary", "#chat", "project") + if channel != 0 || global != 3 { + t.Fatalf("secondary #chat totals = (%d, %d), want (0, 3)", channel, global) + } +} + +func TestKarmaPreservesLegacyGlobalTotals(t *testing.T) { + db, err := storage.Open(filepath.Join(t.TempDir(), "karma.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + p := &Karma{} + if err := p.Init(bot.PluginConfig{}, db); err != nil { + t.Fatal(err) + } + if _, err := p.change("legacy", 5); err != nil { + t.Fatal(err) + } + channel, global := p.readTotals("primary", "#chat", "legacy") + if channel != 0 || global != 5 { + t.Fatalf("legacy totals = (%d, %d), want (0, 5)", channel, global) + } + if updates := p.applyTextChanges("primary", "#chat", "legacy++"); len(updates) != 1 { + t.Fatalf("expected one migrated update, got %v", updates) + } + channel, global = p.readTotals("primary", "#chat", "legacy") + if channel != 1 || global != 6 { + t.Fatalf("updated legacy totals = (%d, %d), want (1, 6)", channel, global) + } +} + func TestKarmaChangesPersist(t *testing.T) { db, err := storage.Open(filepath.Join(t.TempDir(), "karma.db")) if err != nil { @@ -86,10 +151,10 @@ func TestKarmaChangesPersist(t *testing.T) { if err := p.Init(bot.PluginConfig{}, db); err != nil { t.Fatal(err) } - if updates := p.applyTextChanges("notgo++inside"); len(updates) != 0 { + if updates := p.applyTextChanges("primary", "#test", "notgo++inside"); len(updates) != 0 { t.Fatalf("expected embedded update to be ignored, got %v", updates) } - updates := p.applyTextChanges("ouchnet++ ouchnet++ ouchnet--") + updates := p.applyTextChanges("primary", "#test", "ouchnet++ ouchnet++ ouchnet--") if len(updates) != 3 { t.Fatalf("expected three updates, got %v", updates) } @@ -97,4 +162,8 @@ func TestKarmaChangesPersist(t *testing.T) { if err != nil || value != 1 { t.Fatalf("expected persisted karma +1, got %d, %v", value, err) } + channel, global := p.readTotals("primary", "#test", "ouchnet") + if channel != 1 || global != 1 { + t.Fatalf("scoped totals = (%d, %d), want (1, 1)", channel, global) + } } diff --git a/storage/db.go b/storage/db.go index 7c55d3b..3a12afa 100644 --- a/storage/db.go +++ b/storage/db.go @@ -12,6 +12,12 @@ var ErrNotFound = errors.New("key not found") type DB struct{ db *bbolt.DB } +type Entry struct { + Bucket string + Key string + Value interface{} +} + func Decode(data []byte, value interface{}) error { return json.Unmarshal(data, value) } @@ -58,6 +64,32 @@ func (d *DB) Set(bucket, key string, value interface{}) error { return bk.Put([]byte(key), b) }) } + +func (d *DB) SetMany(entries ...Entry) error { + encoded := make([][]byte, len(entries)) + for i, entry := range entries { + if entry.Bucket == "" || entry.Key == "" { + return fmt.Errorf("storage entry requires a bucket and key") + } + value, err := json.Marshal(entry.Value) + if err != nil { + return err + } + encoded[i] = value + } + return d.db.Update(func(tx *bbolt.Tx) error { + for i, entry := range entries { + bucket, err := tx.CreateBucketIfNotExists([]byte(entry.Bucket)) + if err != nil { + return err + } + if err := bucket.Put([]byte(entry.Key), encoded[i]); err != nil { + return err + } + } + return nil + }) +} func (d *DB) Delete(bucket, key string) error { return d.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) diff --git a/storage/db_test.go b/storage/db_test.go new file mode 100644 index 0000000..cb9f341 --- /dev/null +++ b/storage/db_test.go @@ -0,0 +1,41 @@ +package storage + +import "testing" + +func TestSetManyWritesMultipleBuckets(t *testing.T) { + db, err := Open(t.TempDir() + "/bot.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := db.SetMany( + Entry{Bucket: "global", Key: "project", Value: 19}, + Entry{Bucket: "channel", Key: "primary\x00#chat\x00project", Value: 9}, + ); err != nil { + t.Fatalf("SetMany returned error: %v", err) + } + global, err := db.Get("global", "project") + if err != nil || string(global) != "19" { + t.Fatalf("global value = %q, %v; want 19", global, err) + } + channel, err := db.Get("channel", "primary\x00#chat\x00project") + if err != nil || string(channel) != "9" { + t.Fatalf("channel value = %q, %v; want 9", channel, err) + } +} + +func TestSetManyRejectsIncompleteEntryWithoutWriting(t *testing.T) { + db, err := Open(t.TempDir() + "/bot.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := db.SetMany(Entry{Bucket: "global", Key: "project", Value: 19}, Entry{Bucket: "", Key: "bad", Value: 1}); err == nil { + t.Fatal("SetMany unexpectedly accepted an incomplete entry") + } + if _, err := db.Get("global", "project"); err != ErrNotFound { + t.Fatalf("partial write found: %v", err) + } +}