package counter import ( "database/sql" "fmt" "github.com/rs/zerolog/log" "github.com/velour/catbase/config" "math/rand" "regexp" "strconv" "strings" "sync" "github.com/jmoiron/sqlx" "github.com/velour/catbase/bot" "github.com/velour/catbase/bot/msg" ) // This is a counter plugin to count arbitrary things. type CounterPlugin struct { b bot.Bot db *sqlx.DB cfg *config.Config modify sync.Mutex } type Item struct { *sqlx.DB ID int64 Nick string Item string Count int64 UserID sql.NullString } type alias struct { *sqlx.DB ID int64 Item string PointsTo string `db:"points_to"` } // GetItems returns all counters func GetAllItemsByUser(db *sqlx.DB) (map[string][]Item, error) { var items []Item err := db.Select(&items, `select * from counter`) if err != nil { return nil, err } // Don't forget to embed the db into all of that shiz for i := range items { items[i].DB = db } out := map[string][]Item{} for _, it := range items { out[it.Nick] = append(out[it.Nick], it) } return out, nil } // GetItems returns all counters for a subject func GetItems(db *sqlx.DB, nick, id string) ([]Item, error) { var items []Item var err error if id != "" { err = db.Select(&items, `select * from counter where userid = ?`, id) } else { err = db.Select(&items, `select * from counter where nick = ?`, nick) } if err != nil { return nil, err } // Don't forget to embed the db into all of that shiz for i := range items { items[i].DB = db } return items, nil } func LeaderAll(db *sqlx.DB) ([]Item, error) { s := `select id,item,nick,count from (select id,item,nick,count,max(abs(count)) from counter group by item having count(nick) > 1 and max(abs(count)) > 1) order by count desc` var items []Item err := db.Select(&items, s) if err != nil { log.Error().Msgf("Error querying leaderboard: %s", err) return nil, err } for i := range items { items[i].DB = db } return items, nil } func Leader(db *sqlx.DB, itemName string) ([]Item, error) { itemName = strings.ToLower(itemName) s := `select * from counter where item=? order by count desc` var items []Item err := db.Select(&items, s, itemName) if err != nil { return nil, err } for i := range items { items[i].DB = db } return items, nil } func RmAlias(db *sqlx.DB, aliasName string) error { q := `delete from counter_alias where item = ?` tx, err := db.Beginx() if err != nil { return err } _, err = tx.Exec(q, aliasName) if err != nil { if err := tx.Rollback(); err != nil { return err } return err } err = tx.Commit() return err } func MkAlias(db *sqlx.DB, aliasName, pointsTo string) (*alias, error) { aliasName = strings.TrimSpace(strings.ToLower(aliasName)) pointsTo = strings.TrimSpace(strings.ToLower(pointsTo)) res, err := db.Exec(`insert into counter_alias (item, points_to) values (?, ?)`, aliasName, pointsTo) if err != nil { _, err := db.Exec(`update counter_alias set points_to=? where item=?`, pointsTo, aliasName) if err != nil { return nil, err } var a alias if err := db.Get(&a, `select * from counter_alias where item=?`, aliasName); err != nil { return nil, err } return &a, nil } id, _ := res.LastInsertId() return &alias{db, id, aliasName, pointsTo}, nil } // GetUserItem returns a specific counter for all subjects func GetItem(db *sqlx.DB, itemName string) ([]Item, error) { itemName = trimUnicode(itemName) var items []Item var a alias if err := db.Get(&a, `select * from counter_alias where item=?`, itemName); err == nil { itemName = a.PointsTo } else { log.Error().Err(err).Interface("alias", a) } err := db.Select(&items, `select * from counter where item= ?`, itemName) if err != nil { return nil, err } log.Debug(). Str("itemName", itemName). Interface("items", items). Msg("got item") for _, i := range items { i.DB = db } return items, nil } // GetUserItem returns a specific counter for a subject func GetUserItem(db *sqlx.DB, nick, id, itemName string) (Item, error) { itemName = trimUnicode(itemName) var item Item item.DB = db var a alias if err := db.Get(&a, `select * from counter_alias where item=?`, itemName); err == nil { itemName = a.PointsTo } else { log.Error().Err(err).Interface("alias", a) } var err error if id != "" { err = db.Get(&item, `select * from counter where userid = ? and item= ?`, id, itemName) } else { err = db.Get(&item, `select * from counter where nick = ? and item= ?`, nick, itemName) } switch err { case sql.ErrNoRows: item.ID = -1 item.Nick = nick item.Item = itemName item.UserID.Scan(id) case nil: default: return Item{}, err } log.Debug(). Str("nick", nick). Str("id", id). Str("itemName", itemName). Interface("item", item). Msg("got item") return item, nil } // GetUserItem returns a specific counter for a subject // Create saves a counter func (i *Item) Create() error { res, err := i.Exec(`insert into counter (nick, item, count, userid) values (?, ?, ?, ?);`, i.Nick, i.Item, i.Count, i.UserID.String) if err != nil { return err } id, _ := res.LastInsertId() // hackhackhack? i.ID = id return err } // UpdateDelta sets a value // This will create or delete the item if necessary func (i *Item) Update(r *bot.Request, value int64) error { i.Count = value if i.Count == 0 && i.ID != -1 { return i.Delete() } if i.ID == -1 { if err := i.Create(); err != nil { return err } } log.Debug(). Interface("i", i). Int64("value", value). Msg("Updating item") _, err := i.Exec(`update counter set count = ? where id = ?`, i.Count, i.ID) if err == nil { sendUpdate(r, i.Nick, i.Item, i.Count) } return err } // UpdateDelta changes a value according to some delta // This will create or delete the item if necessary func (i *Item) UpdateDelta(r *bot.Request, delta int64) error { i.Count += delta return i.Update(r, i.Count) } // Delete removes a counter from the database func (i *Item) Delete() error { _, err := i.Exec(`delete from counter where id = ?`, i.ID) i.ID = -1 return err } func (p *CounterPlugin) migrate(r bot.Request) (retVal bool) { db := p.db nicks := []string{} err := db.Select(&nicks, `select distinct nick from counter where userid is null`) if err != nil { log.Error().Err(err).Msg("could not get nick list") return } log.Debug().Msgf("Migrating %d nicks to IDs", len(nicks)) tx := db.MustBegin() for _, nick := range nicks { user, err := r.Conn.Profile(nick) if err != nil { continue } if _, err = tx.Exec(`update counter set userid=? where nick=?`, user.ID, nick); err != nil { log.Error().Err(err).Msg("Could not migrate users") continue } } if err := tx.Commit(); err != nil { log.Error().Err(err).Msg("Could not migrate users") } return } func setupDB(b bot.Bot) error { db := b.DB() db.MustExec(`create table if not exists counter ( id integer primary key, nick string, item string, count integer, userid string );`) db.MustExec(`create table if not exists counter_alias ( id integer PRIMARY KEY AUTOINCREMENT, item string NOT NULL UNIQUE, points_to string NOT NULL );`) tx := db.MustBegin() count := 0 err := tx.Get(&count, `SELECT count(*) FROM pragma_table_info('counter') where name='userid'`) if err != nil { return err } if count == 0 { tx.MustExec(`alter table counter add column userid string`) } tx.Commit() return nil } // NewCounterPlugin creates a new CounterPlugin with the Plugin interface func New(b bot.Bot) *CounterPlugin { if err := setupDB(b); err != nil { panic(err) } cp := &CounterPlugin{ b: b, db: b.DB(), cfg: b.Config(), } b.RegisterRegex(cp, bot.Startup, regexp.MustCompile(`.*`), cp.migrate) b.RegisterRegexCmd(cp, bot.Message, mkAliasRegex, cp.mkAliasCmd) b.RegisterRegexCmd(cp, bot.Message, rmAliasRegex, cp.rmAliasCmd) b.RegisterRegexCmd(cp, bot.Message, leaderboardRegex, cp.leaderboardCmd) b.RegisterRegex(cp, bot.Message, teaRegex, cp.teaMatchCmd) b.RegisterRegexCmd(cp, bot.Message, resetRegex, cp.resetCmd) b.RegisterRegexCmd(cp, bot.Message, inspectRegex, cp.inspectCmd) b.RegisterRegexCmd(cp, bot.Message, clearRegex, cp.clearCmd) b.RegisterRegexCmd(cp, bot.Message, countRegex, cp.countCmd) b.RegisterRegex(cp, bot.Message, incrementRegex, cp.incrementCmd) b.RegisterRegex(cp, bot.Message, decrementRegex, cp.decrementCmd) b.RegisterRegex(cp, bot.Message, addToRegex, cp.addToCmd) b.RegisterRegex(cp, bot.Message, removeFromRegex, cp.removeFromCmd) b.Register(cp, bot.Help, cp.help) cp.registerWeb() RegisterUpdate(func(r bot.Request, u Update) { log.Debug().Msgf("Publishing update %v", u) b.PubToASub("counter", u) }) return cp } func trimUnicode(s string) string { return strings.Trim(s, string(rune(0xFE0F))) } var mkAliasRegex = regexp.MustCompile(`(?i)^mkalias (?P\S+) (?P\S+)$`) var rmAliasRegex = regexp.MustCompile(`(?i)^rmalias (?P\S+)$`) var leaderboardRegex = regexp.MustCompile(`(?i)^leaderboard\s?(?P\S+)?$`) var teaRegex = regexp.MustCompile("(?i)^([^.]+)\\. [^.]*\\. ([^.]*\\.?)+$") var resetRegex = regexp.MustCompile(`(?i)^reset me$`) var inspectRegex = regexp.MustCompile(`(?i)^inspect (?P\S+)$`) var clearRegex = regexp.MustCompile(`(?i)^clear (?P\S+)$`) var countRegex = regexp.MustCompile(`(?i)^count (?P\S+)\s?(?P\S+)?$`) var incrementRegex = regexp.MustCompile(`(?i)^(?P[^.\t\n\f\r ]+\s?\.)?(?P\S+)\s?\+\+$`) var decrementRegex = regexp.MustCompile(`(?i)^(?P[^.\t\n\f\r ]+\s?\.)?(?P\S+)\s?--$`) var addToRegex = regexp.MustCompile(`(?i)^(?P[^.\t\n\f\r ]+\s?\.)?(?P\S+)\s+\+=\s+(?P\d+)$`) var removeFromRegex = regexp.MustCompile(`(?i)^(?P[^.\t\n\f\r ]+\s?\.)?(?P\S+)\s+-=\s+(?P\d+)$`) func (p *CounterPlugin) mkAliasCmd(r bot.Request) bool { what := r.Values["what"] to := r.Values["to"] if what == "" || to == "" { p.b.Send(r.Conn, bot.Message, fmt.Sprintf("You must provide all fields for an alias: %s", mkAliasRegex)) return true } if _, err := MkAlias(p.db, what, to); err != nil { log.Error().Err(err).Msg("Could not mkalias") p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "We're gonna need too much db space to make an alias for your mom.") return true } p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("Created alias %s -> %s", what, to)) return true } func (p *CounterPlugin) rmAliasCmd(r bot.Request) bool { what := r.Values["what"] if what == "" { p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "You must specify an alias to remove.") return true } if err := RmAlias(p.db, what); err != nil { log.Error().Err(err).Msg("could not RmAlias") p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "`sudo rm your mom` => Nope, she's staying with me.") return true } p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "`sudo rm your mom`") return true } func (p *CounterPlugin) leaderboardCmd(r bot.Request) bool { var cmd func() ([]Item, error) itNameTxt := "" what := r.Values["what"] if what == "" { cmd = func() ([]Item, error) { return LeaderAll(p.db) } } else { itNameTxt = fmt.Sprintf(" for %s", what) cmd = func() ([]Item, error) { return Leader(p.db, what) } } its, err := cmd() if err != nil { log.Error().Err(err).Msg("Error with leaderboard") return false } else if len(its) == 0 { p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "There are not enough entries for a leaderboard.") return true } out := fmt.Sprintf("Leaderboard%s:\n", itNameTxt) for _, it := range its { out += fmt.Sprintf("%s with %d %s\n", it.Nick, it.Count, it.Item, ) } p.b.Send(r.Conn, bot.Message, r.Msg.Channel, out) return true } func (p *CounterPlugin) resetCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := p.resolveUser(r, "") channel := r.Msg.Channel items, err := GetItems(p.db, nick, id) if err != nil { log.Error(). Err(err). Str("nick", nick). Msg("Error getting items to reset") p.b.Send(r.Conn, bot.Message, channel, "Something is technically wrong with your counters.") return true } log.Debug().Msgf("Items: %+v", items) for _, item := range items { item.Delete() } p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s, you are as new, my son.", nick)) return true } func (p *CounterPlugin) inspectCmd(r bot.Request) bool { who := r.Values["who"] nick, id := "", "" if who == "me" { who = r.Msg.User.Name nick, id = p.resolveUser(r, "") } else { nick, id = p.resolveUser(r, who) } channel := r.Msg.Channel c := r.Conn log.Debug(). Str("nick", nick). Str("id", id). Msg("Getting counter") // pull all the items associated with "subject" items, err := GetItems(p.db, nick, id) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Msg("Error retrieving items") p.b.Send(c, bot.Message, channel, "Something went wrong finding that counter;") return true } resp := fmt.Sprintf("%s has the following counters:", nick) count := 0 for _, it := range items { count += 1 if count > 1 { resp += "," } resp += fmt.Sprintf(" %s: %d", it.Item, it.Count) if count > 20 { resp += ", and a few others" break } } resp += "." if count == 0 { p.b.Send(c, bot.Message, channel, fmt.Sprintf("%s has no counters.", nick)) return true } p.b.Send(c, bot.Message, channel, resp) return true } func (p *CounterPlugin) clearCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := p.resolveUser(r, "") itemName := strings.ToLower(r.Values["what"]) channel := r.Msg.Channel c := r.Conn it, err := GetUserItem(p.db, nick, id, itemName) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error getting item to remove") p.b.Send(c, bot.Message, channel, "Something went wrong removing that counter;") return true } err = it.Delete() if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error removing item") p.b.Send(c, bot.Message, channel, "Something went wrong removing that counter;") return true } p.b.Send(c, bot.Action, channel, fmt.Sprintf("chops a few %s out of his brain", itemName)) return true } func (p *CounterPlugin) countCmd(r bot.Request) bool { itemName := strings.ToLower(r.Values["what"]) nick, id := r.Msg.User.Name, r.Msg.User.ID if r.Values["what"] == "" { itemName = r.Values["who"] } else { nick, id = p.resolveUser(r, r.Values["who"]) } var item Item item, err := GetUserItem(p.db, nick, id, itemName) switch { case err == sql.ErrNoRows: p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("I don't think %s has any %s.", nick, itemName)) return true case err != nil: log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error retrieving item count") return true } p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("%s has %d %s.", nick, item.Count, itemName)) return true } func (p *CounterPlugin) incrementCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := r.Msg.User.Name, r.Msg.User.ID if r.Values["who"] != "" { nick, id = p.resolveUser(r, r.Values["who"]) } itemName := r.Values["thing"] channel := r.Msg.Channel // ++ those fuckers item, err := GetUserItem(p.db, nick, id, itemName) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("error finding item") // Item ain't there, I guess return false } log.Debug().Msgf("About to update item: %#v", item) p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s has %d %s.", nick, item.Count+1, item.Item)) err = item.UpdateDelta(&r, 1) if err != nil { log.Error().Err(err).Msgf("Could not UpdateDelta") } return true } func (p *CounterPlugin) decrementCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := r.Msg.User.Name, r.Msg.User.ID if r.Values["who"] != "" { nick, id = p.resolveUser(r, r.Values["who"]) } itemName := r.Values["thing"] channel := r.Msg.Channel // -- those fuckers item, err := GetUserItem(p.db, nick, id, itemName) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error finding item") // Item ain't there, I guess return false } p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s has %d %s.", nick, item.Count-1, item.Item)) item.UpdateDelta(&r, -1) return true } func (p *CounterPlugin) addToCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := p.resolveUser(r, r.Values["who"]) itemName := r.Values["thing"] channel := r.Msg.Channel // += those fuckers item, err := GetUserItem(p.db, nick, id, itemName) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error finding item") // Item ain't there, I guess return false } n, _ := strconv.ParseInt(r.Values["amount"], 10, 64) log.Debug().Msgf("About to update item by %d: %#v", n, item) p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s has %d %s.", nick, item.Count+n, item.Item)) item.UpdateDelta(&r, n) return true } func (p *CounterPlugin) removeFromCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := p.resolveUser(r, r.Values["who"]) itemName := r.Values["thing"] channel := r.Msg.Channel // -= those fuckers item, err := GetUserItem(p.db, nick, id, itemName) if err != nil { log.Error(). Err(err). Str("nick", nick). Str("id", id). Str("itemName", itemName). Msg("Error finding item") // Item ain't there, I guess return false } n, _ := strconv.ParseInt(r.Values["amount"], 10, 64) log.Debug().Msgf("About to update item by -%d: %#v", n, item) p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s has %d %s.", nick, item.Count-n, item.Item)) item.UpdateDelta(&r, -n) return true } // Help responds to help requests. Every plugin must implement a help function. func (p *CounterPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool { p.b.Send(c, bot.Message, message.Channel, "You can set counters incrementally by using "+ "`++` and `--`. You can see all of your counters using "+ "`inspect`, erase them with `clear`, and view single counters with "+ "`count`.") p.b.Send(c, bot.Message, message.Channel, "You can create aliases with `!mkalias `") p.b.Send(c, bot.Message, message.Channel, "You can remove aliases with `!rmalias `") return true } func (p *CounterPlugin) teaMatchCmd(r bot.Request) bool { p.modify.Lock() defer p.modify.Unlock() nick, id := r.Msg.User.Name, r.Msg.User.ID channel := r.Msg.Channel submatches := teaRegex.FindStringSubmatch(r.Msg.Body) if len(submatches) <= 1 { return false } itemName := strings.ToLower(submatches[1]) // We will specifically allow :tea: to keep compatability item, err := GetUserItem(p.db, nick, id, itemName) if err != nil || (item.Count == 0 && item.Item != ":tea:") { log.Error(). Err(err). Str("itemName", itemName). Msg("Error finding item") // Item ain't there, I guess return false } log.Debug().Msgf("About to update item: %#v", item) var delta int64 = 1 if item.Count < 0 { delta = -1 } p.b.Send(r.Conn, bot.Message, channel, fmt.Sprintf("%s... %s has %d %s", strings.Join(everyDayImShuffling([]string{"bleep", "bloop", "blop"}), "-"), nick, item.Count+delta, itemName)) item.UpdateDelta(&r, delta) return true } func everyDayImShuffling(vals []string) []string { ret := make([]string, len(vals)) perm := rand.Perm(len(vals)) for i, randIndex := range perm { ret[i] = vals[randIndex] } return ret } type updateFunc func(bot.Request, Update) var updateFuncs = []updateFunc{} func RegisterUpdate(f updateFunc) { log.Debug().Msgf("registering update func") updateFuncs = append(updateFuncs, f) } func sendUpdate(r *bot.Request, who, what string, amount int64) { log.Debug(). Msgf("Updating %s for %s with %d", who, what, amount) if r == nil { return } log.Debug().Msgf("sending updates to %d places", len(updateFuncs)) for _, f := range updateFuncs { f(*r, Update{who, what, amount}) } } func (p *CounterPlugin) resolveUser(r bot.Request, nick string) (string, string) { id := "" if nick != "" { nick = strings.TrimSuffix(nick, ".") u, err := r.Conn.Profile(nick) if err == nil && u.ID != "" { id = u.ID } } else if r.Msg.User != nil { nick, id = r.Msg.User.Name, r.Msg.User.ID } nick = strings.ToLower(nick) log.Debug().Msgf("resolveUser(%s, %s) => (%s, %s)", r.Msg.User.ID, nick, nick, id) return nick, id }