[#16] Implement Raffle module (#47)

This commit is contained in:
Knut Ahlers 2023-07-14 16:15:58 +02:00 committed by GitHub
parent 3cd9567907
commit a58b72c268
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 2758 additions and 138 deletions

View file

@ -14,7 +14,7 @@ RUN set -ex \
make \
nodejs-lts-hydrogen \
npm \
&& make frontend \
&& make frontend_prod \
&& go install \
-trimpath \
-mod=readonly \

View file

@ -3,7 +3,7 @@ default: lint frontend_lint test
lint:
golangci-lint run
publish: frontend
publish: frontend_prod
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
bash golang.sh
@ -12,6 +12,9 @@ test:
# --- Editor frontend
frontend_prod: export NODE_ENV=production
frontend_prod: frontend
frontend: node_modules
node ci/build.mjs

View file

@ -99,7 +99,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Authorization as %q complete, you can now close this window.", botUser), http.StatusOK)
frontendReloadHooks.Ping() // Tell frontend to update its config
frontendNotifyHooks.Ping(frontendNotifyTypeReload) // Tell frontend to update its config
}
func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
@ -150,5 +150,5 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Scopes for %q updated, you can now close this window.", grantUser), http.StatusOK)
frontendReloadHooks.Ping() // Tell frontend to update its config
frontendNotifyHooks.Ping(frontendNotifyTypeReload) // Tell frontend to update its config
}

View file

@ -5,7 +5,6 @@ import (
"github.com/pkg/errors"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)
@ -20,26 +19,3 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
_, user, err := tc.GetAuthorizedUser()
return user, tc, errors.Wrap(err, "getting authorized user")
}
func botEditorAuthMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, tc, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusForbidden)
return
}
id, err := tc.GetIDForUsername(user)
if err != nil {
http.Error(w, errors.Wrap(err, "getting ID for authorized user").Error(), http.StatusForbidden)
return
}
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
http.Error(w, "user is not authorized", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}

View file

@ -2,16 +2,21 @@ package main
import (
_ "embed"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
"path"
"strings"
"time"
"github.com/go-irc/irc"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/str"
@ -96,6 +101,9 @@ func loadConfig(filename string) error {
tmpConfig.updateAutoMessagesFromConfig(config)
tmpConfig.fixDurations()
tmpConfig.fixMissingUUIDs()
if err = tmpConfig.fixTokenHashStorage(); err != nil {
return errors.Wrap(err, "applying token hash fixes")
}
switch {
case config != nil && config.RawLog == tmpConfig.RawLog:
@ -130,7 +138,7 @@ func loadConfig(filename string) error {
}).Info("Config file (re)loaded")
// Notify listener config has changed
frontendReloadHooks.Ping()
frontendNotifyHooks.Ping(frontendNotifyTypeReload)
return nil
}
@ -159,6 +167,9 @@ func patchConfig(filename, authorName, authorEmail, summary string, patcher func
}
cfgFile.fixMissingUUIDs()
if err = cfgFile.fixTokenHashStorage(); err != nil {
return errors.Wrap(err, "applying token hash fixes")
}
err = patcher(cfgFile)
switch {
@ -229,6 +240,41 @@ func writeDefaultConfigFile(filename string) error {
return errors.Wrap(err, "writing default config")
}
func (c configAuthToken) validate(token string) error {
switch {
case strings.HasPrefix(c.Hash, "$2a$"):
return errors.Wrap(
bcrypt.CompareHashAndPassword([]byte(c.Hash), []byte(token)),
"validating bcrypt",
)
case strings.HasPrefix(c.Hash, "$argon2id$"):
var (
flds = strings.Split(c.Hash, "$")
t, m uint32
p uint8
)
if _, err := fmt.Sscanf(flds[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return errors.Wrap(err, "scanning argon2id hash params")
}
salt, err := base64.RawStdEncoding.DecodeString(flds[4])
if err != nil {
return errors.Wrap(err, "decoding salt")
}
if flds[5] == base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token), salt, t, m, p, argonHashLen)) {
return nil
}
return errors.New("hash does not match")
default:
return errors.New("unknown hash format found")
}
}
func (c *configFile) CloseRawMessageWriter() error {
if c == nil || c.rawLogWriter == nil {
return nil
@ -299,6 +345,26 @@ func (c *configFile) fixMissingUUIDs() {
}
}
func (c *configFile) fixTokenHashStorage() (err error) {
for key := range c.AuthTokens {
auth := c.AuthTokens[key]
if strings.HasPrefix(auth.Hash, "$") {
continue
}
rawHash, err := hex.DecodeString(auth.Hash)
if err != nil {
return errors.Wrap(err, "reading hash")
}
auth.Hash = string(rawHash)
c.AuthTokens[key] = auth
}
return nil
}
func (c *configFile) runLoadChecks() (err error) {
if len(c.Channels) == 0 {
log.Warn("Loaded config with empty channel list")

View file

@ -15,7 +15,10 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const websocketPingInterval = 30 * time.Second
const (
moduleConfigEditor = "config-editor"
websocketPingInterval = 30 * time.Second
)
var (
availableActorDocs = []plugins.ActionDocumentation{}

View file

@ -18,7 +18,7 @@ func registerEditorAutoMessageRoutes() {
Description: "Returns the current set of configured auto-messages in JSON format",
HandlerFunc: configEditorHandleAutoMessagesGet,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get current auto-messages",
Path: "/auto-messages",
RequiresEditorsAuth: true,
@ -28,7 +28,7 @@ func registerEditorAutoMessageRoutes() {
Description: "Adds a new Auto-Message",
HandlerFunc: configEditorHandleAutoMessageAdd,
Method: http.MethodPost,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Add Auto-Message",
Path: "/auto-messages",
RequiresEditorsAuth: true,
@ -38,7 +38,7 @@ func registerEditorAutoMessageRoutes() {
Description: "Deletes the given Auto-Message",
HandlerFunc: configEditorHandleAutoMessageDelete,
Method: http.MethodDelete,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Delete Auto-Message",
Path: "/auto-messages/{uuid}",
RequiresEditorsAuth: true,
@ -56,7 +56,7 @@ func registerEditorAutoMessageRoutes() {
Description: "Updates the given Auto-Message",
HandlerFunc: configEditorHandleAutoMessageUpdate,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Update Auto-Message",
Path: "/auto-messages/{uuid}",
RequiresEditorsAuth: true,

View file

@ -30,7 +30,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "Add new authorization token",
HandlerFunc: configEditorHandleGeneralAddAuthToken,
Method: http.MethodPost,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Add authorization token",
Path: "/auth-tokens",
RequiresEditorsAuth: true,
@ -40,7 +40,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "Delete authorization token",
HandlerFunc: configEditorHandleGeneralDeleteAuthToken,
Method: http.MethodDelete,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Delete authorization token",
Path: "/auth-tokens/{handle}",
RequiresEditorsAuth: true,
@ -58,7 +58,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "List authorization tokens",
HandlerFunc: configEditorHandleGeneralListAuthTokens,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "List authorization tokens",
Path: "/auth-tokens",
RequiresEditorsAuth: true,
@ -68,7 +68,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "Returns the current general config",
HandlerFunc: configEditorHandleGeneralGet,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get general config",
Path: "/general",
RequiresEditorsAuth: true,
@ -78,7 +78,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "Updates the general config",
HandlerFunc: configEditorHandleGeneralUpdate,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Update general config",
Path: "/general",
RequiresEditorsAuth: true,
@ -88,7 +88,7 @@ func registerEditorGeneralConfigRoutes() {
Description: "Get Bot-Auth URLs for updating bot token and channel scopes",
HandlerFunc: configEditorHandleGeneralAuthURLs,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get Bot-Auth-URLs",
Path: "/auth-urls",
RequiresEditorsAuth: true,

View file

@ -11,7 +11,9 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins"
)
var frontendReloadHooks = newHooker()
const frontendNotifyTypeReload = "configReload"
var frontendNotifyHooks = newHooker()
//nolint:funlen // Just contains a collection of objects
func registerEditorGlobalMethods() {
@ -20,7 +22,7 @@ func registerEditorGlobalMethods() {
Description: "Returns the documentation for available actions",
HandlerFunc: configEditorGlobalGetActions,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get available actions",
Path: "/actions",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
@ -29,7 +31,7 @@ func registerEditorGlobalMethods() {
Description: "Returns all available modules for auth",
HandlerFunc: configEditorGlobalGetModules,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get available modules",
Path: "/modules",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
@ -38,7 +40,7 @@ func registerEditorGlobalMethods() {
Description: "Returns information about a Twitch user to properly display bot editors",
HandlerFunc: configEditorGlobalGetUser,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get user information",
Path: "/user",
QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -56,7 +58,7 @@ func registerEditorGlobalMethods() {
Description: "Subscribe for configuration changes",
HandlerFunc: configEditorGlobalSubscribe,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Websocket: Subscribe config changes",
Path: "/notify-config",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
@ -65,7 +67,7 @@ func registerEditorGlobalMethods() {
Description: "Validate a cron expression and return the next executions",
HandlerFunc: configEditorGlobalValidateCron,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Validate cron expression",
Path: "/validate-cron",
QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -88,7 +90,7 @@ func registerEditorGlobalMethods() {
Description: "Validate a regular expression against the RE2 regex parser",
HandlerFunc: configEditorGlobalValidateRegex,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Validate regular expression",
Path: "/validate-regex",
QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -105,7 +107,7 @@ func registerEditorGlobalMethods() {
Description: "Validate a template expression against the built in template function library",
HandlerFunc: configEditorGlobalValidateTemplate,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Validate template expression",
Path: "/validate-template",
QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -161,9 +163,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
defer conn.Close()
var (
configReloadNotify = make(chan struct{}, 1)
pingTimer = time.NewTicker(websocketPingInterval)
unsubscribe = frontendReloadHooks.Register(func() { configReloadNotify <- struct{}{} })
frontendNotify = make(chan string, 1)
pingTimer = time.NewTicker(websocketPingInterval)
unsubscribe = frontendNotifyHooks.Register(func(payload any) { frontendNotify <- payload.(string) })
)
defer unsubscribe()
@ -173,9 +175,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
for {
select {
case <-configReloadNotify:
case msgType := <-frontendNotify:
if err := conn.WriteJSON(socketMsg{
MsgType: "configReload",
MsgType: msgType,
}); err != nil {
log.WithError(err).Debug("Unable to send websocket notification")
return

View file

@ -18,7 +18,7 @@ func registerEditorRulesRoutes() {
Description: "Returns the current set of configured rules in JSON format",
HandlerFunc: configEditorRulesGet,
Method: http.MethodGet,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Get current rules",
Path: "/rules",
RequiresEditorsAuth: true,
@ -28,7 +28,7 @@ func registerEditorRulesRoutes() {
Description: "Adds a new Rule",
HandlerFunc: configEditorRulesAdd,
Method: http.MethodPost,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Add Rule",
Path: "/rules",
RequiresEditorsAuth: true,
@ -38,7 +38,7 @@ func registerEditorRulesRoutes() {
Description: "Deletes the given Rule",
HandlerFunc: configEditorRulesDelete,
Method: http.MethodDelete,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Delete Rule",
Path: "/rules/{uuid}",
RequiresEditorsAuth: true,
@ -56,7 +56,7 @@ func registerEditorRulesRoutes() {
Description: "Updates the given Rule",
HandlerFunc: configEditorRulesUpdate,
Method: http.MethodPut,
Module: "config-editor",
Module: moduleConfigEditor,
Name: "Update Rule",
Path: "/rules/{uuid}",
RequiresEditorsAuth: true,

35
config_test.go Normal file
View file

@ -0,0 +1,35 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAuthTokenValidate(t *testing.T) {
assert.NoError(
t,
configAuthToken{Hash: "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$vvmJ4o4I75omtCuCUcFupw"}.
validate("21a84dfe-155f-427d-add2-2e63dab7502f"),
"valid argon2id token",
)
assert.Error(
t,
configAuthToken{Hash: "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$vvmJ4o4I75omtCuCUcFupw"}.
validate("a8188a1f-d7ff-4628-b0f3-0efa0ef364d8"),
"invalid argon2id token",
)
assert.NoError(
t,
configAuthToken{Hash: "$2a$10$xa3tzQheujq3nj/vJdzKIOzaPvirI6FFangBNDFXI8BME4rBMhNoG"}.
validate("21a84dfe-155f-427d-add2-2e63dab7502f"),
"valid bcrypt token",
)
assert.Error(
t,
configAuthToken{Hash: "$2a$10$xa3tzQheujq3nj/vJdzKIOzaPvirI6FFangBNDFXI8BME4rBMhNoG"}.
validate("a8188a1f-d7ff-4628-b0f3-0efa0ef364d8"),
"invalid bcrypt token",
)
}

View file

@ -8,23 +8,23 @@ import (
type (
hooker struct {
hooks map[string]func()
hooks map[string]func(any)
lock sync.RWMutex
}
)
func newHooker() *hooker { return &hooker{hooks: map[string]func(){}} }
func newHooker() *hooker { return &hooker{hooks: map[string]func(any){}} }
func (h *hooker) Ping() {
func (h *hooker) Ping(payload any) {
h.lock.RLock()
defer h.lock.RUnlock()
for _, hf := range h.hooks {
hf()
hf(payload)
}
}
func (h *hooker) Register(hook func()) func() {
func (h *hooker) Register(hook func(any)) func() {
h.lock.Lock()
defer h.lock.Unlock()

View file

@ -0,0 +1,300 @@
package raffle
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gofrs/uuid"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
{
Description: "Lists all raffles known to the bot",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, _ map[string]uint64) (any, error) {
ras, err := dbc.List()
return ras, errors.Wrap(err, "fetching raffles from database")
}, nil),
Method: http.MethodGet,
Module: actorName,
Name: "List Raffles",
Path: "/",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Creates a new raffle based on the data in the body",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, _ map[string]uint64) (any, error) {
var ra raffle
if err := json.NewDecoder(r.Body).Decode(&ra); err != nil {
return nil, errors.Wrap(err, "parsing raffle from body")
}
return nil, errors.Wrap(dbc.Create(ra), "creating raffle")
}, nil),
Method: http.MethodPost,
Module: actorName,
Name: "Create Raffle",
Path: "/",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
},
{
Description: "Deletes raffle by given ID including all entries",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.Delete(ids["id"]), "fetching raffle from database")
}, []string{"id"}),
Method: http.MethodDelete,
Module: actorName,
Name: "Delete Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to fetch",
Name: "id",
},
},
},
{
Description: "Gets raffle by given ID including all entries",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
ra, err := dbc.Get(ids["id"])
return ra, errors.Wrap(err, "fetching raffle from database")
}, []string{"id"}),
Method: http.MethodGet,
Module: actorName,
Name: "Get Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to fetch",
Name: "id",
},
},
},
{
Description: "Updates the given raffle (needs to include the whole object, not just changed fields)",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
var ra raffle
if err := json.NewDecoder(r.Body).Decode(&ra); err != nil {
return nil, errors.Wrap(err, "parsing raffle from body")
}
if ra.ID != ids["id"] {
return nil, errors.New("raffle ID does not match")
}
return nil, errors.Wrap(dbc.Update(ra), "updating raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Update Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to update",
Name: "id",
},
},
},
{
Description: "Duplicates the raffle given by its ID",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.Clone(ids["id"]), "cloning raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Clone Raffle",
Path: "/{id}/clone",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to clone",
Name: "id",
},
},
},
{
Description: "Closes the raffle given by its ID",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.Close(ids["id"]), "closing raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Close Raffle",
Path: "/{id}/close",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to close",
Name: "id",
},
},
},
{
Description: "Picks a winner for the given raffle (this does NOT close the raffle, use only on closed raffle!)",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.PickWinner(ids["id"]), "picking winner")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Pick Raffle Winner",
Path: "/{id}/pick",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to pick a winner for",
Name: "id",
},
},
},
{
Description: "Re-opens a raffle for additional entries, only Status and CloseAt are modified",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
dur, err := strconv.ParseInt(r.URL.Query().Get("duration"), 10, 64)
if err != nil {
return nil, errors.Wrap(err, "parsing duration")
}
return nil, errors.Wrap(dbc.Reopen(ids["id"], time.Duration(dur)*time.Second), "reopening raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Reopen Raffle",
Path: "/{id}/reopen",
QueryParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "Number of seconds to leave the raffle open",
Name: "duration",
Required: true,
Type: "integer",
},
},
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to pick a winner for",
Name: "id",
},
},
},
{
Description: "Starts a raffle making it available for entries",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.Start(ids["id"]), "starting raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Name: "Start Raffle",
Path: "/{id}/start",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to start",
Name: "id",
},
},
},
{
Description: "Dismisses a previously picked winner and picks a new one",
HandlerFunc: handleWrap(func(w http.ResponseWriter, r *http.Request, ids map[string]uint64) (any, error) {
return nil, errors.Wrap(dbc.RedrawWinner(ids["id"], ids["winner"]), "re-picking winner")
}, []string{"id", "winner"}),
Method: http.MethodPut,
Module: actorName,
Name: "Re-Pick Raffle Winner",
Path: "/{id}/repick/{winner}",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "ID of the raffle to re-pick the winner for",
Name: "id",
},
{
Description: "ID of the winner to replace",
Name: "winner",
},
},
},
}
func handleWrap(f func(http.ResponseWriter, *http.Request, map[string]uint64) (any, error), parseIDs []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
ids = map[string]uint64{}
reqUUID = uuid.Must(uuid.NewV4()).String()
logger = logrus.WithFields(logrus.Fields{
"path": r.URL.Path,
"req": reqUUID,
})
)
for _, k := range parseIDs {
id, err := strconv.ParseUint(mux.Vars(r)[k], 10, 64)
if err != nil {
http.Error(w, fmt.Sprintf("invalid ID field %q", k), http.StatusBadRequest)
return
}
ids[k] = id
}
resp, err := f(w, r, ids)
if err != nil {
logger.WithError(err).Error("handling request")
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
if resp == nil {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "applicatioh/json")
if err = json.NewEncoder(w).Encode(resp); err != nil {
logger.WithError(err).Error("serializing response")
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
}
}
func registerAPI(args plugins.RegistrationArguments) (err error) {
for i, r := range apiRoutes {
if err = args.RegisterAPIRoute(r); err != nil {
return errors.Wrapf(err, "registering route %d", i)
}
}
return nil
}

View file

@ -0,0 +1,637 @@
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:"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