mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-15 03:22:43 +00:00
Knut Ahlers
6a0c48488b
which didn't work as `string` is a `LONGTEXT` field which cannot fully be indexed while MariaDB does not have those issues. Signed-off-by: Knut Ahlers <knut@ahlers.me>
637 lines
17 KiB
Go
637 lines
17 KiB
Go
package raffle
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-irc/irc"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/Luzifer/twitch-bot/v3/pkg/database"
|
|
"github.com/Luzifer/twitch-bot/v3/plugins"
|
|
)
|
|
|
|
const (
|
|
frontendNotifyEventRaffleChange = "raffleChanged"
|
|
frontendNotifyEventRaffleEntryChange = "raffleEntryChanged"
|
|
)
|
|
|
|
type (
|
|
dbClient struct {
|
|
activeRaffles map[string]uint64
|
|
speakUp map[string]*speakUpWait
|
|
db database.Connector
|
|
lock sync.RWMutex
|
|
}
|
|
|
|
raffle struct {
|
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
|
|
|
Channel string `json:"channel"`
|
|
Keyword string `json:"keyword"`
|
|
Title string `json:"title"`
|
|
Status raffleStatus `gorm:"default:planned" json:"status"`
|
|
|
|
AllowEveryone bool `json:"allowEveryone"`
|
|
AllowFollower bool `json:"allowFollower"`
|
|
AllowSubscriber bool `json:"allowSubscriber"`
|
|
AllowVIP bool `gorm:"column:allow_vip" json:"allowVIP"`
|
|
MinFollowAge time.Duration `json:"minFollowAge"`
|
|
|
|
MultiFollower float64 `json:"multiFollower"`
|
|
MultiSubscriber float64 `json:"multiSubscriber"`
|
|
MultiVIP float64 `gorm:"column:multi_vip" json:"multiVIP"`
|
|
|
|
AutoStartAt *time.Time `json:"autoStartAt"`
|
|
CloseAfter time.Duration `json:"closeAfter"`
|
|
CloseAt *time.Time `json:"closeAt"`
|
|
WaitForResponse time.Duration `json:"waitForResponse"`
|
|
|
|
TextClose string `json:"textClose"`
|
|
TextClosePost bool `json:"textClosePost"`
|
|
TextEntry string `json:"textEntry"`
|
|
TextEntryPost bool `json:"textEntryPost"`
|
|
TextEntryFail string `json:"textEntryFail"`
|
|
TextEntryFailPost bool `json:"textEntryFailPost"`
|
|
TextReminder string `json:"textReminder"`
|
|
TextReminderInterval time.Duration `json:"textReminderInterval"`
|
|
TextReminderNextSend time.Time `json:"-"`
|
|
TextReminderPost bool `json:"textReminderPost"`
|
|
TextWin string `json:"textWin"`
|
|
TextWinPost bool `json:"textWinPost"`
|
|
|
|
Entries []raffleEntry `gorm:"foreignKey:RaffleID" json:"entries,omitempty"`
|
|
}
|
|
|
|
raffleEntry struct {
|
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
|
RaffleID uint64 `gorm:"uniqueIndex:user_per_raffle" json:"-"`
|
|
|
|
UserID string `gorm:"size:128;uniqueIndex:user_per_raffle" json:"userID"`
|
|
UserLogin string `json:"userLogin"`
|
|
UserDisplayName string `json:"userDisplayName"`
|
|
|
|
EnteredAt time.Time `json:"enteredAt"`
|
|
EnteredAs string `json:"enteredAs"`
|
|
Multiplier float64 `json:"multiplier"`
|
|
|
|
WasPicked bool `json:"wasPicked"`
|
|
WasRedrawn bool `json:"wasRedrawn"`
|
|
|
|
DrawResponse string `json:"drawResponse,omitempty"`
|
|
SpeakUpUntil *time.Time `json:"speakUpUntil,omitempty"`
|
|
}
|
|
|
|
raffleMessageEvent uint8
|
|
raffleStatus string
|
|
|
|
speakUpWait struct {
|
|
RaffleEntryID uint64
|
|
Until time.Time
|
|
}
|
|
)
|
|
|
|
const (
|
|
raffleMessageEventEntryFailed raffleMessageEvent = iota
|
|
raffleMessageEventEntry
|
|
raffleMessageEventReminder
|
|
raffleMessageEventWin
|
|
raffleMessageEventClose
|
|
)
|
|
|
|
const (
|
|
raffleStatusPlanned raffleStatus = "planned"
|
|
raffleStatusActive raffleStatus = "active"
|
|
raffleStatusEnded raffleStatus = "ended"
|
|
)
|
|
|
|
var errRaffleNotFound = errors.New("raffle not found")
|
|
|
|
func newDBClient(db database.Connector) *dbClient {
|
|
return &dbClient{
|
|
activeRaffles: make(map[string]uint64),
|
|
speakUp: make(map[string]*speakUpWait),
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// AutoCloseExpired collects all active raffles which have overdue
|
|
// close_at dates and closes them
|
|
func (d *dbClient) AutoCloseExpired() (err error) {
|
|
var rr []raffle
|
|
|
|
if err = d.db.DB().
|
|
Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()).
|
|
Find(&rr).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "fetching raffles to close")
|
|
}
|
|
|
|
for _, r := range rr {
|
|
if err = d.Close(r.ID); err != nil {
|
|
return errors.Wrapf(err, "closing raffle %d", r.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AutoSendReminders collects all active raffles which have enabled
|
|
// reminders which are overdue and posts the reminder for them
|
|
func (d *dbClient) AutoSendReminders() (err error) {
|
|
var rr []raffle
|
|
|
|
if err = d.db.DB().
|
|
Where("status = ? AND text_reminder_post = ? AND text_reminder_next_send < ?", raffleStatusActive, true, time.Now().UTC()).
|
|
Find(&rr).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "fetching raffles to send reminders")
|
|
}
|
|
|
|
for _, r := range rr {
|
|
if err = r.SendEvent(raffleMessageEventReminder, nil); err != nil {
|
|
return errors.Wrapf(err, "sending reminder for raffle %d", r.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AutoStart collects planned and overdue raffles and starts them
|
|
func (d *dbClient) AutoStart() (err error) {
|
|
var rr []raffle
|
|
|
|
if err = d.db.DB().
|
|
Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()).
|
|
Find(&rr).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "fetching raffles to start")
|
|
}
|
|
|
|
for _, r := range rr {
|
|
if err = d.Start(r.ID); err != nil {
|
|
return errors.Wrapf(err, "starting raffle %d", r.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Clone duplicates a raffle into a new draft resetting some
|
|
// parameters into their default state
|
|
func (d *dbClient) Clone(raffleID uint64) error {
|
|
raffle, err := d.Get(raffleID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting raffle")
|
|
}
|
|
|
|
raffle.CloseAt = nil
|
|
raffle.Entries = nil
|
|
raffle.ID = 0
|
|
raffle.Status = raffleStatusPlanned
|
|
raffle.Title = strings.Join([]string{"Copy of", raffle.Title}, " ")
|
|
|
|
if err = d.Create(raffle); err != nil {
|
|
return errors.Wrap(err, "creating copy")
|
|
}
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
return nil
|
|
}
|
|
|
|
// Close marks the raffle as closed and removes it from the active
|
|
// raffle cache
|
|
func (d *dbClient) Close(raffleID uint64) error {
|
|
r, err := d.Get(raffleID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting raffle")
|
|
}
|
|
|
|
if err = d.db.DB().Model(&raffle{}).
|
|
Where("id = ?", raffleID).
|
|
Update("status", raffleStatusEnded).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "setting status closed")
|
|
}
|
|
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
delete(d.activeRaffles, strings.Join([]string{r.Channel, r.Keyword}, "::"))
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
|
|
return errors.Wrap(
|
|
r.SendEvent(raffleMessageEventClose, nil),
|
|
"sending close-message",
|
|
)
|
|
}
|
|
|
|
// Create creates a new raffle. The record will be written to
|
|
// the database without modification and therefore need to be filled
|
|
// before calling this function
|
|
func (d *dbClient) Create(r raffle) error {
|
|
if err := d.db.DB().Create(&r).Error; err != nil {
|
|
return errors.Wrap(err, "creating database record")
|
|
}
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
return nil
|
|
}
|
|
|
|
// Delete removes all entries for the given raffle and afterwards
|
|
// deletes the raffle itself
|
|
func (d *dbClient) Delete(raffleID uint64) (err error) {
|
|
if err = d.db.DB().
|
|
Where("raffle_id = ?", raffleID).
|
|
Delete(&raffleEntry{}).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "deleting raffle entries")
|
|
}
|
|
|
|
if err = d.db.DB().
|
|
Where("id = ?", raffleID).
|
|
Delete(&raffle{}).Error; err != nil {
|
|
return errors.Wrap(err, "creating database record")
|
|
}
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
return nil
|
|
}
|
|
|
|
// Enter creates a new raffle entry. The entry will be written to
|
|
// the database without modification and therefore need to be filled
|
|
// before calling this function
|
|
func (d *dbClient) Enter(re raffleEntry) error {
|
|
if err := d.db.DB().Create(&re).Error; err != nil {
|
|
return errors.Wrap(err, "creating database record")
|
|
}
|
|
|
|
frontendNotify(frontendNotifyEventRaffleEntryChange)
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a raffle from the database
|
|
func (d *dbClient) Get(raffleID uint64) (out raffle, err error) {
|
|
return out, errors.Wrap(
|
|
d.db.DB().
|
|
Where("raffles.id = ?", raffleID).
|
|
Preload("Entries").
|
|
First(&out).
|
|
Error,
|
|
"getting raffle from database",
|
|
)
|
|
}
|
|
|
|
// GetByChannelAndKeyword resolves an active raffle through channel
|
|
// and keyword given in the raffle and returns it through the Get
|
|
// function. If the combination is not known errRaffleNotFound is
|
|
// returned.
|
|
func (d *dbClient) GetByChannelAndKeyword(channel, keyword string) (raffle, error) {
|
|
d.lock.RLock()
|
|
id := d.activeRaffles[strings.Join([]string{channel, keyword}, "::")]
|
|
d.lock.RUnlock()
|
|
|
|
if id == 0 {
|
|
return raffle{}, errRaffleNotFound
|
|
}
|
|
|
|
return d.Get(id)
|
|
}
|
|
|
|
// List returns a list of all known raffles
|
|
func (d *dbClient) List() (raffles []raffle, _ error) {
|
|
return raffles, errors.Wrap(
|
|
d.db.DB().Model(&raffle{}).
|
|
Order("id DESC").
|
|
Find(&raffles).
|
|
Error,
|
|
"updating column",
|
|
)
|
|
}
|
|
|
|
// PatchNextReminderSend updates the time another reminder shall be
|
|
// sent for the given raffle ID. No other fields are modified
|
|
func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error {
|
|
return errors.Wrap(
|
|
d.db.DB().Model(&raffle{}).
|
|
Where("id = ?", raffleID).
|
|
Update("text_reminder_next_send", next).
|
|
Error,
|
|
"updating column",
|
|
)
|
|
}
|
|
|
|
// PickWinner fetches the given raffle and picks a random winner
|
|
// based on entries and their multiplier
|
|
func (d *dbClient) PickWinner(raffleID uint64) error {
|
|
r, err := d.Get(raffleID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting raffle")
|
|
}
|
|
|
|
winner, err := pickWinnerFromRaffle(r)
|
|
if err != nil {
|
|
return errors.Wrap(err, "picking winner")
|
|
}
|
|
|
|
speakUpUntil := time.Now().UTC().Add(r.WaitForResponse)
|
|
if err = d.db.DB().Model(&raffleEntry{}).
|
|
Where("id = ?", winner.ID).
|
|
Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "updating winner")
|
|
}
|
|
|
|
d.lock.Lock()
|
|
d.speakUp[strings.Join([]string{r.Channel, winner.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: winner.ID, Until: speakUpUntil}
|
|
d.lock.Unlock()
|
|
|
|
fields := plugins.FieldCollectionFromData(map[string]any{
|
|
"user_id": winner.UserID,
|
|
"user": winner.UserLogin,
|
|
"winner": winner,
|
|
})
|
|
|
|
frontendNotify(frontendNotifyEventRaffleEntryChange)
|
|
|
|
return errors.Wrap(
|
|
r.SendEvent(raffleMessageEventWin, fields),
|
|
"sending win-message",
|
|
)
|
|
}
|
|
|
|
// RedrawWinner marks the previous winner as redrawn (and therefore
|
|
// crossed out as winner in the interface) and picks a new one
|
|
func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error {
|
|
if err := d.db.DB().Model(&raffleEntry{}).
|
|
Where("id = ?", winnerID).
|
|
Update("was_redrawn", true).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "updating previous winner")
|
|
}
|
|
|
|
return d.PickWinner(raffleID)
|
|
}
|
|
|
|
// RefreshActiveRaffles loads all active raffles and populates the
|
|
// activeRaffles cache
|
|
func (d *dbClient) RefreshActiveRaffles() error {
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
|
|
var (
|
|
actives []raffle
|
|
tmp = map[string]uint64{}
|
|
)
|
|
|
|
if err := d.db.DB().
|
|
Where("status = ?", raffleStatusActive).
|
|
Find(&actives).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "fetching active raffles")
|
|
}
|
|
|
|
for _, r := range actives {
|
|
tmp[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID
|
|
}
|
|
|
|
d.activeRaffles = tmp
|
|
return nil
|
|
}
|
|
|
|
// RefreshSpeakUp seeks all still active speak-up entries and
|
|
// populates the speak-up cache with them
|
|
func (d *dbClient) RefreshSpeakUp() error {
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
|
|
var (
|
|
res []raffleEntry
|
|
tmp = map[string]*speakUpWait{}
|
|
)
|
|
|
|
if err := d.db.DB().Debug().
|
|
Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()).
|
|
Find(&res).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "querying active entries")
|
|
}
|
|
|
|
for _, e := range res {
|
|
var r raffle
|
|
if err := d.db.DB().
|
|
Where("id = ?", e.RaffleID).
|
|
First(&r).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "fetching raffle for entry")
|
|
}
|
|
tmp[strings.Join([]string{r.Channel, e.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: e.ID, Until: *e.SpeakUpUntil}
|
|
}
|
|
|
|
d.speakUp = tmp
|
|
return nil
|
|
}
|
|
|
|
// RegisterSpeakUp sets the speak-up message if there was a
|
|
// speakUpWait for that user and channel
|
|
func (d *dbClient) RegisterSpeakUp(channel, user, message string) error {
|
|
d.lock.RLock()
|
|
w := d.speakUp[strings.Join([]string{channel, user}, ":")]
|
|
d.lock.RUnlock()
|
|
|
|
if w == nil || w.Until.Before(time.Now()) {
|
|
// No speak-up-request for that user or expired
|
|
return nil
|
|
}
|
|
|
|
if err := d.db.DB().
|
|
Model(&raffleEntry{}).
|
|
Where("id = ?", w.RaffleEntryID).
|
|
Updates(map[string]any{
|
|
"DrawResponse": message,
|
|
"SpeakUpUntil": nil,
|
|
}).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "registering speak-up")
|
|
}
|
|
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
delete(d.speakUp, strings.Join([]string{channel, user}, ":"))
|
|
|
|
frontendNotify(frontendNotifyEventRaffleEntryChange)
|
|
return nil
|
|
}
|
|
|
|
// Reopen updates the CloseAt attribute and status to active to
|
|
// prolong the raffle
|
|
func (d *dbClient) Reopen(raffleID uint64, duration time.Duration) error {
|
|
r, err := d.Get(raffleID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting specified raffle")
|
|
}
|
|
|
|
if err = d.db.DB().
|
|
Model(&raffle{}).
|
|
Where("id = ?", raffleID).
|
|
Updates(map[string]any{
|
|
"CloseAt": time.Now().UTC().Add(duration),
|
|
"status": raffleStatusActive,
|
|
}).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "updating raffle")
|
|
}
|
|
|
|
// Store ID to active-raffle cache
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
return nil
|
|
}
|
|
|
|
// Start fetches the given raffle, updates its CloseAt attribute
|
|
// in case it is not already set, sets the raffle to active, updates
|
|
// the raffle in the database and notes its channel/keyword combo
|
|
// into the activeRaffles cache for use with irc handling
|
|
func (d *dbClient) Start(raffleID uint64) error {
|
|
r, err := d.Get(raffleID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting specified raffle")
|
|
}
|
|
|
|
if r.CloseAt == nil {
|
|
end := time.Now().UTC().Add(r.CloseAfter)
|
|
r.CloseAt = &end
|
|
}
|
|
|
|
r.Status = raffleStatusActive
|
|
if err = d.Update(r); err != nil {
|
|
return errors.Wrap(err, "updating raffle")
|
|
}
|
|
|
|
// Store ID to active-raffle cache
|
|
d.lock.Lock()
|
|
defer d.lock.Unlock()
|
|
d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
|
|
return errors.Wrap(
|
|
r.SendEvent(raffleMessageEventReminder, nil),
|
|
"sending first reminder",
|
|
)
|
|
}
|
|
|
|
// Update stores the given raffle to the database. The ID within the
|
|
// raffle object must be set in order to update it. The object must
|
|
// be completely filled.
|
|
func (d *dbClient) Update(r raffle) error {
|
|
old, err := d.Get(r.ID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting previous version")
|
|
}
|
|
|
|
// These information must not be changed after raffle has been started
|
|
if old.Status != raffleStatusPlanned {
|
|
r.Channel = old.Channel
|
|
r.Keyword = old.Keyword
|
|
r.Status = old.Status
|
|
|
|
r.AllowEveryone = old.AllowEveryone
|
|
r.AllowFollower = old.AllowFollower
|
|
r.AllowSubscriber = old.AllowSubscriber
|
|
r.AllowVIP = old.AllowVIP
|
|
r.MinFollowAge = old.MinFollowAge
|
|
|
|
r.MultiFollower = old.MultiFollower
|
|
r.MultiSubscriber = old.MultiSubscriber
|
|
r.MultiVIP = old.MultiVIP
|
|
|
|
r.AutoStartAt = old.AutoStartAt
|
|
}
|
|
|
|
// This info must be preserved
|
|
r.Entries = nil
|
|
r.TextReminderNextSend = old.TextReminderNextSend
|
|
|
|
if err := d.db.DB().
|
|
Model(&raffle{}).
|
|
Where("id = ?", r.ID).
|
|
Updates(&r).
|
|
Error; err != nil {
|
|
return errors.Wrap(err, "updating raffle")
|
|
}
|
|
|
|
frontendNotify(frontendNotifyEventRaffleChange)
|
|
return nil
|
|
}
|
|
|
|
// SendEvent processes the text template and sends the message if
|
|
// enabled given through the event
|
|
func (r raffle) SendEvent(evt raffleMessageEvent, fields *plugins.FieldCollection) (err error) {
|
|
if fields == nil {
|
|
fields = plugins.NewFieldCollection()
|
|
}
|
|
|
|
fields.Set("raffle", r) // Make raffle available to templating
|
|
|
|
var sendTextTpl string
|
|
|
|
switch evt {
|
|
case raffleMessageEventClose:
|
|
if !r.TextClosePost {
|
|
return nil
|
|
}
|
|
sendTextTpl = r.TextClose
|
|
|
|
case raffleMessageEventEntryFailed:
|
|
if !r.TextEntryFailPost {
|
|
return nil
|
|
}
|
|
sendTextTpl = r.TextEntryFail
|
|
|
|
case raffleMessageEventEntry:
|
|
if !r.TextEntryPost {
|
|
return nil
|
|
}
|
|
sendTextTpl = r.TextEntry
|
|
|
|
case raffleMessageEventReminder:
|
|
if !r.TextReminderPost {
|
|
return nil
|
|
}
|
|
sendTextTpl = r.TextReminder
|
|
if err = dbc.PatchNextReminderSend(r.ID, time.Now().UTC().Add(r.TextReminderInterval)); err != nil {
|
|
return errors.Wrap(err, "updating next reminder for raffle")
|
|
}
|
|
|
|
case raffleMessageEventWin:
|
|
if !r.TextWinPost {
|
|
return nil
|
|
}
|
|
sendTextTpl = r.TextWin
|
|
|
|
default:
|
|
// How?
|
|
return errors.New("unexpected event")
|
|
}
|
|
|
|
msg, err := formatMessage(sendTextTpl, nil, nil, fields)
|
|
if err != nil {
|
|
return errors.Wrap(err, "formatting message to send")
|
|
}
|
|
|
|
return errors.Wrap(
|
|
send(&irc.Message{
|
|
Command: "PRIVMSG",
|
|
Params: []string{
|
|
"#" + strings.TrimLeft(r.Channel, "#"),
|
|
msg,
|
|
},
|
|
}),
|
|
"sending message",
|
|
)
|
|
}
|