Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
158 changes: 132 additions & 26 deletions plugins/karma.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package plugins

import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync"

"github.com/variablenix/GoBot/bot"
"github.com/variablenix/GoBot/storage"
Expand All @@ -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 <thing> — show karma; thing++ or thing-- changes it" }
Expand All @@ -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
}
Expand All @@ -40,31 +50,40 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !karma <thing>"))
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 {
color = ircGreen
} 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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -129,39 +157,117 @@ 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' ||
value >= '0' && value <= '9' ||
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
}
Loading