mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +00:00
parent
3cd9567907
commit
a58b72c268
32 changed files with 2758 additions and 138 deletions
|
@ -14,7 +14,7 @@ RUN set -ex \
|
||||||
make \
|
make \
|
||||||
nodejs-lts-hydrogen \
|
nodejs-lts-hydrogen \
|
||||||
npm \
|
npm \
|
||||||
&& make frontend \
|
&& make frontend_prod \
|
||||||
&& go install \
|
&& go install \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-mod=readonly \
|
-mod=readonly \
|
||||||
|
|
5
Makefile
5
Makefile
|
@ -3,7 +3,7 @@ default: lint frontend_lint test
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
publish: frontend
|
publish: frontend_prod
|
||||||
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
||||||
bash golang.sh
|
bash golang.sh
|
||||||
|
|
||||||
|
@ -12,6 +12,9 @@ test:
|
||||||
|
|
||||||
# --- Editor frontend
|
# --- Editor frontend
|
||||||
|
|
||||||
|
frontend_prod: export NODE_ENV=production
|
||||||
|
frontend_prod: frontend
|
||||||
|
|
||||||
frontend: node_modules
|
frontend: node_modules
|
||||||
node ci/build.mjs
|
node ci/build.mjs
|
||||||
|
|
||||||
|
|
4
auth.go
4
auth.go
|
@ -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)
|
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) {
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
24
botEditor.go
24
botEditor.go
|
@ -5,7 +5,6 @@ import (
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/str"
|
|
||||||
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
"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()
|
_, user, err := tc.GetAuthorizedUser()
|
||||||
return user, tc, errors.Wrap(err, "getting authorized user")
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
68
config.go
68
config.go
|
@ -2,16 +2,21 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-irc/irc"
|
"github.com/go-irc/irc"
|
||||||
"github.com/gofrs/uuid/v3"
|
"github.com/gofrs/uuid/v3"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/str"
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
@ -96,6 +101,9 @@ func loadConfig(filename string) error {
|
||||||
tmpConfig.updateAutoMessagesFromConfig(config)
|
tmpConfig.updateAutoMessagesFromConfig(config)
|
||||||
tmpConfig.fixDurations()
|
tmpConfig.fixDurations()
|
||||||
tmpConfig.fixMissingUUIDs()
|
tmpConfig.fixMissingUUIDs()
|
||||||
|
if err = tmpConfig.fixTokenHashStorage(); err != nil {
|
||||||
|
return errors.Wrap(err, "applying token hash fixes")
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case config != nil && config.RawLog == tmpConfig.RawLog:
|
case config != nil && config.RawLog == tmpConfig.RawLog:
|
||||||
|
@ -130,7 +138,7 @@ func loadConfig(filename string) error {
|
||||||
}).Info("Config file (re)loaded")
|
}).Info("Config file (re)loaded")
|
||||||
|
|
||||||
// Notify listener config has changed
|
// Notify listener config has changed
|
||||||
frontendReloadHooks.Ping()
|
frontendNotifyHooks.Ping(frontendNotifyTypeReload)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -159,6 +167,9 @@ func patchConfig(filename, authorName, authorEmail, summary string, patcher func
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgFile.fixMissingUUIDs()
|
cfgFile.fixMissingUUIDs()
|
||||||
|
if err = cfgFile.fixTokenHashStorage(); err != nil {
|
||||||
|
return errors.Wrap(err, "applying token hash fixes")
|
||||||
|
}
|
||||||
|
|
||||||
err = patcher(cfgFile)
|
err = patcher(cfgFile)
|
||||||
switch {
|
switch {
|
||||||
|
@ -229,6 +240,41 @@ func writeDefaultConfigFile(filename string) error {
|
||||||
return errors.Wrap(err, "writing default config")
|
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 {
|
func (c *configFile) CloseRawMessageWriter() error {
|
||||||
if c == nil || c.rawLogWriter == nil {
|
if c == nil || c.rawLogWriter == nil {
|
||||||
return 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) {
|
func (c *configFile) runLoadChecks() (err error) {
|
||||||
if len(c.Channels) == 0 {
|
if len(c.Channels) == 0 {
|
||||||
log.Warn("Loaded config with empty channel list")
|
log.Warn("Loaded config with empty channel list")
|
||||||
|
|
|
@ -15,7 +15,10 @@ import (
|
||||||
"github.com/Luzifer/twitch-bot/v3/plugins"
|
"github.com/Luzifer/twitch-bot/v3/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
const websocketPingInterval = 30 * time.Second
|
const (
|
||||||
|
moduleConfigEditor = "config-editor"
|
||||||
|
websocketPingInterval = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
availableActorDocs = []plugins.ActionDocumentation{}
|
availableActorDocs = []plugins.ActionDocumentation{}
|
||||||
|
|
|
@ -18,7 +18,7 @@ func registerEditorAutoMessageRoutes() {
|
||||||
Description: "Returns the current set of configured auto-messages in JSON format",
|
Description: "Returns the current set of configured auto-messages in JSON format",
|
||||||
HandlerFunc: configEditorHandleAutoMessagesGet,
|
HandlerFunc: configEditorHandleAutoMessagesGet,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get current auto-messages",
|
Name: "Get current auto-messages",
|
||||||
Path: "/auto-messages",
|
Path: "/auto-messages",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -28,7 +28,7 @@ func registerEditorAutoMessageRoutes() {
|
||||||
Description: "Adds a new Auto-Message",
|
Description: "Adds a new Auto-Message",
|
||||||
HandlerFunc: configEditorHandleAutoMessageAdd,
|
HandlerFunc: configEditorHandleAutoMessageAdd,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Add Auto-Message",
|
Name: "Add Auto-Message",
|
||||||
Path: "/auto-messages",
|
Path: "/auto-messages",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -38,7 +38,7 @@ func registerEditorAutoMessageRoutes() {
|
||||||
Description: "Deletes the given Auto-Message",
|
Description: "Deletes the given Auto-Message",
|
||||||
HandlerFunc: configEditorHandleAutoMessageDelete,
|
HandlerFunc: configEditorHandleAutoMessageDelete,
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Delete Auto-Message",
|
Name: "Delete Auto-Message",
|
||||||
Path: "/auto-messages/{uuid}",
|
Path: "/auto-messages/{uuid}",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -56,7 +56,7 @@ func registerEditorAutoMessageRoutes() {
|
||||||
Description: "Updates the given Auto-Message",
|
Description: "Updates the given Auto-Message",
|
||||||
HandlerFunc: configEditorHandleAutoMessageUpdate,
|
HandlerFunc: configEditorHandleAutoMessageUpdate,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Update Auto-Message",
|
Name: "Update Auto-Message",
|
||||||
Path: "/auto-messages/{uuid}",
|
Path: "/auto-messages/{uuid}",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
|
|
@ -30,7 +30,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "Add new authorization token",
|
Description: "Add new authorization token",
|
||||||
HandlerFunc: configEditorHandleGeneralAddAuthToken,
|
HandlerFunc: configEditorHandleGeneralAddAuthToken,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Add authorization token",
|
Name: "Add authorization token",
|
||||||
Path: "/auth-tokens",
|
Path: "/auth-tokens",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -40,7 +40,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "Delete authorization token",
|
Description: "Delete authorization token",
|
||||||
HandlerFunc: configEditorHandleGeneralDeleteAuthToken,
|
HandlerFunc: configEditorHandleGeneralDeleteAuthToken,
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Delete authorization token",
|
Name: "Delete authorization token",
|
||||||
Path: "/auth-tokens/{handle}",
|
Path: "/auth-tokens/{handle}",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -58,7 +58,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "List authorization tokens",
|
Description: "List authorization tokens",
|
||||||
HandlerFunc: configEditorHandleGeneralListAuthTokens,
|
HandlerFunc: configEditorHandleGeneralListAuthTokens,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "List authorization tokens",
|
Name: "List authorization tokens",
|
||||||
Path: "/auth-tokens",
|
Path: "/auth-tokens",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -68,7 +68,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "Returns the current general config",
|
Description: "Returns the current general config",
|
||||||
HandlerFunc: configEditorHandleGeneralGet,
|
HandlerFunc: configEditorHandleGeneralGet,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get general config",
|
Name: "Get general config",
|
||||||
Path: "/general",
|
Path: "/general",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -78,7 +78,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "Updates the general config",
|
Description: "Updates the general config",
|
||||||
HandlerFunc: configEditorHandleGeneralUpdate,
|
HandlerFunc: configEditorHandleGeneralUpdate,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Update general config",
|
Name: "Update general config",
|
||||||
Path: "/general",
|
Path: "/general",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -88,7 +88,7 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
Description: "Get Bot-Auth URLs for updating bot token and channel scopes",
|
Description: "Get Bot-Auth URLs for updating bot token and channel scopes",
|
||||||
HandlerFunc: configEditorHandleGeneralAuthURLs,
|
HandlerFunc: configEditorHandleGeneralAuthURLs,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get Bot-Auth-URLs",
|
Name: "Get Bot-Auth-URLs",
|
||||||
Path: "/auth-urls",
|
Path: "/auth-urls",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
|
|
@ -11,7 +11,9 @@ import (
|
||||||
"github.com/Luzifer/twitch-bot/v3/plugins"
|
"github.com/Luzifer/twitch-bot/v3/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
var frontendReloadHooks = newHooker()
|
const frontendNotifyTypeReload = "configReload"
|
||||||
|
|
||||||
|
var frontendNotifyHooks = newHooker()
|
||||||
|
|
||||||
//nolint:funlen // Just contains a collection of objects
|
//nolint:funlen // Just contains a collection of objects
|
||||||
func registerEditorGlobalMethods() {
|
func registerEditorGlobalMethods() {
|
||||||
|
@ -20,7 +22,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Returns the documentation for available actions",
|
Description: "Returns the documentation for available actions",
|
||||||
HandlerFunc: configEditorGlobalGetActions,
|
HandlerFunc: configEditorGlobalGetActions,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get available actions",
|
Name: "Get available actions",
|
||||||
Path: "/actions",
|
Path: "/actions",
|
||||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
@ -29,7 +31,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Returns all available modules for auth",
|
Description: "Returns all available modules for auth",
|
||||||
HandlerFunc: configEditorGlobalGetModules,
|
HandlerFunc: configEditorGlobalGetModules,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get available modules",
|
Name: "Get available modules",
|
||||||
Path: "/modules",
|
Path: "/modules",
|
||||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
@ -38,7 +40,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Returns information about a Twitch user to properly display bot editors",
|
Description: "Returns information about a Twitch user to properly display bot editors",
|
||||||
HandlerFunc: configEditorGlobalGetUser,
|
HandlerFunc: configEditorGlobalGetUser,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get user information",
|
Name: "Get user information",
|
||||||
Path: "/user",
|
Path: "/user",
|
||||||
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
@ -56,7 +58,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Subscribe for configuration changes",
|
Description: "Subscribe for configuration changes",
|
||||||
HandlerFunc: configEditorGlobalSubscribe,
|
HandlerFunc: configEditorGlobalSubscribe,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Websocket: Subscribe config changes",
|
Name: "Websocket: Subscribe config changes",
|
||||||
Path: "/notify-config",
|
Path: "/notify-config",
|
||||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
@ -65,7 +67,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Validate a cron expression and return the next executions",
|
Description: "Validate a cron expression and return the next executions",
|
||||||
HandlerFunc: configEditorGlobalValidateCron,
|
HandlerFunc: configEditorGlobalValidateCron,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Validate cron expression",
|
Name: "Validate cron expression",
|
||||||
Path: "/validate-cron",
|
Path: "/validate-cron",
|
||||||
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
@ -88,7 +90,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Validate a regular expression against the RE2 regex parser",
|
Description: "Validate a regular expression against the RE2 regex parser",
|
||||||
HandlerFunc: configEditorGlobalValidateRegex,
|
HandlerFunc: configEditorGlobalValidateRegex,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Validate regular expression",
|
Name: "Validate regular expression",
|
||||||
Path: "/validate-regex",
|
Path: "/validate-regex",
|
||||||
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
@ -105,7 +107,7 @@ func registerEditorGlobalMethods() {
|
||||||
Description: "Validate a template expression against the built in template function library",
|
Description: "Validate a template expression against the built in template function library",
|
||||||
HandlerFunc: configEditorGlobalValidateTemplate,
|
HandlerFunc: configEditorGlobalValidateTemplate,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Validate template expression",
|
Name: "Validate template expression",
|
||||||
Path: "/validate-template",
|
Path: "/validate-template",
|
||||||
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
QueryParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
@ -161,9 +163,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
configReloadNotify = make(chan struct{}, 1)
|
frontendNotify = make(chan string, 1)
|
||||||
pingTimer = time.NewTicker(websocketPingInterval)
|
pingTimer = time.NewTicker(websocketPingInterval)
|
||||||
unsubscribe = frontendReloadHooks.Register(func() { configReloadNotify <- struct{}{} })
|
unsubscribe = frontendNotifyHooks.Register(func(payload any) { frontendNotify <- payload.(string) })
|
||||||
)
|
)
|
||||||
defer unsubscribe()
|
defer unsubscribe()
|
||||||
|
|
||||||
|
@ -173,9 +175,9 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-configReloadNotify:
|
case msgType := <-frontendNotify:
|
||||||
if err := conn.WriteJSON(socketMsg{
|
if err := conn.WriteJSON(socketMsg{
|
||||||
MsgType: "configReload",
|
MsgType: msgType,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.WithError(err).Debug("Unable to send websocket notification")
|
log.WithError(err).Debug("Unable to send websocket notification")
|
||||||
return
|
return
|
||||||
|
|
|
@ -18,7 +18,7 @@ func registerEditorRulesRoutes() {
|
||||||
Description: "Returns the current set of configured rules in JSON format",
|
Description: "Returns the current set of configured rules in JSON format",
|
||||||
HandlerFunc: configEditorRulesGet,
|
HandlerFunc: configEditorRulesGet,
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Get current rules",
|
Name: "Get current rules",
|
||||||
Path: "/rules",
|
Path: "/rules",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -28,7 +28,7 @@ func registerEditorRulesRoutes() {
|
||||||
Description: "Adds a new Rule",
|
Description: "Adds a new Rule",
|
||||||
HandlerFunc: configEditorRulesAdd,
|
HandlerFunc: configEditorRulesAdd,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Add Rule",
|
Name: "Add Rule",
|
||||||
Path: "/rules",
|
Path: "/rules",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -38,7 +38,7 @@ func registerEditorRulesRoutes() {
|
||||||
Description: "Deletes the given Rule",
|
Description: "Deletes the given Rule",
|
||||||
HandlerFunc: configEditorRulesDelete,
|
HandlerFunc: configEditorRulesDelete,
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Delete Rule",
|
Name: "Delete Rule",
|
||||||
Path: "/rules/{uuid}",
|
Path: "/rules/{uuid}",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
@ -56,7 +56,7 @@ func registerEditorRulesRoutes() {
|
||||||
Description: "Updates the given Rule",
|
Description: "Updates the given Rule",
|
||||||
HandlerFunc: configEditorRulesUpdate,
|
HandlerFunc: configEditorRulesUpdate,
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Module: "config-editor",
|
Module: moduleConfigEditor,
|
||||||
Name: "Update Rule",
|
Name: "Update Rule",
|
||||||
Path: "/rules/{uuid}",
|
Path: "/rules/{uuid}",
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
|
|
35
config_test.go
Normal file
35
config_test.go
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
10
hooker.go
10
hooker.go
|
@ -8,23 +8,23 @@ import (
|
||||||
|
|
||||||
type (
|
type (
|
||||||
hooker struct {
|
hooker struct {
|
||||||
hooks map[string]func()
|
hooks map[string]func(any)
|
||||||
lock sync.RWMutex
|
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()
|
h.lock.RLock()
|
||||||
defer h.lock.RUnlock()
|
defer h.lock.RUnlock()
|
||||||
|
|
||||||
for _, hf := range h.hooks {
|
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()
|
h.lock.Lock()
|
||||||
defer h.lock.Unlock()
|
defer h.lock.Unlock()
|
||||||
|
|
||||||
|
|
300
internal/apimodules/raffle/api.go
Normal file
300
internal/apimodules/raffle/api.go
Normal 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
|
||||||
|
}
|
637
internal/apimodules/raffle/database.go
Normal file
637
internal/apimodules/raffle/database.go
Normal 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 (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",
|
||||||
|
)
|
||||||
|
}
|
144
internal/apimodules/raffle/irc.go
Normal file
144
internal/apimodules/raffle/irc.go
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
package raffle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rawMessageHandler(m *irc.Message) error {
|
||||||
|
if m.Command != "PRIVMSG" {
|
||||||
|
// We only care for messages containing the raffle keyword
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := plugins.DeriveChannel(m, nil)
|
||||||
|
if channel == "" {
|
||||||
|
// The frick? Messages should have a channel but whatever
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := plugins.DeriveUser(m, nil)
|
||||||
|
if user == "" {
|
||||||
|
// The frick? Messages should have a user but whatever
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := dbc.RegisterSpeakUp(channel, user, m.Trailing()); err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"channel": channel,
|
||||||
|
"user": user,
|
||||||
|
}).WithError(err).Error("registering speak-up")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return handleRaffleEntry(m, channel, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // Dividing would need to carry over everyhing and make it more complex
|
||||||
|
func handleRaffleEntry(m *irc.Message, channel, user string) error {
|
||||||
|
flds := strings.Fields(m.Trailing())
|
||||||
|
if len(flds) == 0 {
|
||||||
|
// A message also should have: a message!
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
badges = twitch.ParseBadgeLevels(m)
|
||||||
|
doesFollow bool
|
||||||
|
keyword = flds[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
r, err := dbc.GetByChannelAndKeyword(channel, keyword)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errRaffleNotFound) {
|
||||||
|
// We don't need to care, that was no raffle input
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "fetching raffle")
|
||||||
|
}
|
||||||
|
|
||||||
|
raffleChan, err := tcGetter(r.Channel)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting twitch client for raffle")
|
||||||
|
}
|
||||||
|
|
||||||
|
since, err := raffleChan.GetFollowDate(user, strings.TrimLeft(channel, "#"))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
doesFollow = since.Before(time.Now().Add(-r.MinFollowAge))
|
||||||
|
|
||||||
|
case errors.Is(err, twitch.ErrUserDoesNotFollow):
|
||||||
|
doesFollow = false
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errors.Wrap(err, "checking follow for user")
|
||||||
|
}
|
||||||
|
|
||||||
|
re := raffleEntry{
|
||||||
|
RaffleID: r.ID,
|
||||||
|
UserID: string(m.Tags["user-id"]),
|
||||||
|
UserLogin: user,
|
||||||
|
UserDisplayName: string(m.Tags["display-name"]),
|
||||||
|
EnteredAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if re.UserDisplayName == "" {
|
||||||
|
re.UserDisplayName = re.UserLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{
|
||||||
|
"user_id": string(m.Tags["user-id"]),
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.AllowVIP && badges.Has(twitch.BadgeVIP):
|
||||||
|
re.EnteredAs = twitch.BadgeVIP
|
||||||
|
re.Multiplier = r.MultiVIP
|
||||||
|
|
||||||
|
case r.AllowSubscriber && badges.Has(twitch.BadgeSubscriber):
|
||||||
|
re.EnteredAs = twitch.BadgeSubscriber
|
||||||
|
re.Multiplier = r.MultiSubscriber
|
||||||
|
|
||||||
|
case r.AllowFollower && doesFollow:
|
||||||
|
re.EnteredAs = "follower"
|
||||||
|
re.Multiplier = r.MultiFollower
|
||||||
|
|
||||||
|
case r.AllowEveryone:
|
||||||
|
re.EnteredAs = "everyone"
|
||||||
|
re.Multiplier = 1
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Well. No luck, no entry.
|
||||||
|
return errors.Wrap(
|
||||||
|
r.SendEvent(raffleMessageEventEntryFailed, raffleEventFields),
|
||||||
|
"sending entry-failed chat message",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have everything we need to create an entry
|
||||||
|
if err = dbc.Enter(re); err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"raffle": r.ID,
|
||||||
|
"user_id": re.UserID,
|
||||||
|
"user": re.UserLogin,
|
||||||
|
}).WithError(err).Error("creating raffle entry")
|
||||||
|
return errors.Wrap(
|
||||||
|
r.SendEvent(raffleMessageEventEntryFailed, raffleEventFields),
|
||||||
|
"sending entry-failed chat message",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
r.SendEvent(raffleMessageEventEntry, raffleEventFields),
|
||||||
|
"sending entry chat message",
|
||||||
|
)
|
||||||
|
}
|
62
internal/apimodules/raffle/pick.go
Normal file
62
internal/apimodules/raffle/pick.go
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
package raffle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
mathRand "math/rand"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
cryptRandSrc struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoCandidatesLeft = errors.New("no candidates left")
|
||||||
|
|
||||||
|
func pickWinnerFromRaffle(r raffle) (winner raffleEntry, err error) {
|
||||||
|
var maxScore float64
|
||||||
|
for _, re := range r.Entries {
|
||||||
|
if re.WasPicked {
|
||||||
|
// We skip previously picked winners and pretend they
|
||||||
|
// don't exist
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
maxScore += re.Multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxScore == 0 {
|
||||||
|
return winner, errNoCandidatesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
winnerPoint := mathRand.New(cryptRandSrc{}).Float64() * maxScore //#nosec:G404 - RNG is using a secure source
|
||||||
|
|
||||||
|
for i := range r.Entries {
|
||||||
|
re := r.Entries[i]
|
||||||
|
|
||||||
|
if re.WasPicked {
|
||||||
|
// We skip previously picked winners and pretend they
|
||||||
|
// don't exist
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
winnerPoint -= re.Multiplier
|
||||||
|
if winnerPoint < 0 {
|
||||||
|
winner = re
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cryptRandSrc) Int63() int64 {
|
||||||
|
var b [8]byte
|
||||||
|
rand.Read(b[:])
|
||||||
|
// mask off sign bit to ensure positive number
|
||||||
|
return int64(binary.LittleEndian.Uint64(b[:]) & (1<<63 - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're using a non-seedable source
|
||||||
|
func (cryptRandSrc) Seed(int64) {}
|
83
internal/apimodules/raffle/pick_test.go
Normal file
83
internal/apimodules/raffle/pick_test.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
package raffle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testGenerateRaffe() raffle {
|
||||||
|
r := raffle{
|
||||||
|
MultiFollower: 1.1,
|
||||||
|
MultiSubscriber: 1.2,
|
||||||
|
MultiVIP: 1.5,
|
||||||
|
|
||||||
|
Entries: make([]raffleEntry, 0, 837),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now lets generate 132 non-followers taking part
|
||||||
|
for i := 0; i < 132; i++ {
|
||||||
|
r.Entries = append(r.Entries, raffleEntry{ID: uint64(i), Multiplier: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now lets generate 500 followers taking part
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
r.Entries = append(r.Entries, raffleEntry{ID: 10000 + uint64(i), Multiplier: r.MultiFollower})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now lets generate 200 subscribers taking part
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
r.Entries = append(r.Entries, raffleEntry{ID: 20000 + uint64(i), Multiplier: r.MultiSubscriber})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now lets generate 5 VIPs taking part
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
r.Entries = append(r.Entries, raffleEntry{ID: 30000 + uint64(i), Multiplier: r.MultiVIP})
|
||||||
|
}
|
||||||
|
|
||||||
|
// They didn't join in order so lets shuffle them
|
||||||
|
rand.Shuffle(len(r.Entries), func(i, j int) { r.Entries[i], r.Entries[j] = r.Entries[j], r.Entries[i] })
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkPickWinnerFromRaffle(b *testing.B) {
|
||||||
|
tData := testGenerateRaffe()
|
||||||
|
b.Run("pick", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
pickWinnerFromRaffle(tData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWinnerFromRaffle(t *testing.T) {
|
||||||
|
var (
|
||||||
|
winners []uint64
|
||||||
|
tData = testGenerateRaffe()
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
w, err := pickWinnerFromRaffle(tData)
|
||||||
|
require.NoError(t, err, "picking winner")
|
||||||
|
winners = append(winners, w.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("winners: %v", winners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWinnerFromRaffleSpecial(t *testing.T) {
|
||||||
|
r := raffle{}
|
||||||
|
_, err := pickWinnerFromRaffle(r)
|
||||||
|
assert.ErrorIs(t, errNoCandidatesLeft, err, "picking from 0 paricipants")
|
||||||
|
|
||||||
|
r.Entries = append(r.Entries, raffleEntry{ID: 1, Multiplier: 1.0})
|
||||||
|
winner, err := pickWinnerFromRaffle(r)
|
||||||
|
assert.NoError(t, err, "picking from set of 1")
|
||||||
|
assert.Equal(t, uint64(1), winner.ID, "expect the right winner")
|
||||||
|
|
||||||
|
r.Entries[0].WasPicked = true
|
||||||
|
_, err = pickWinnerFromRaffle(r)
|
||||||
|
assert.ErrorIs(t, errNoCandidatesLeft, err, "picking from 1 paricipant, which already won")
|
||||||
|
}
|
70
internal/apimodules/raffle/raffle.go
Normal file
70
internal/apimodules/raffle/raffle.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
// Package raffle contains the backend and API implementation as well
|
||||||
|
// as the chat listeners for chat-raffles
|
||||||
|
package raffle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/pkg/database"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
const actorName = "raffle"
|
||||||
|
|
||||||
|
var (
|
||||||
|
db database.Connector
|
||||||
|
dbc *dbClient
|
||||||
|
formatMessage plugins.MsgFormatter
|
||||||
|
frontendNotify func(string)
|
||||||
|
send plugins.SendMessageFunc
|
||||||
|
tcGetter func(string) (*twitch.Client, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
func Register(args plugins.RegistrationArguments) (err error) {
|
||||||
|
db = args.GetDatabaseConnector()
|
||||||
|
if err := db.DB().AutoMigrate(&raffle{}, &raffleEntry{}); err != nil {
|
||||||
|
return errors.Wrap(err, "applying schema migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbc = newDBClient(db)
|
||||||
|
if err = dbc.RefreshActiveRaffles(); err != nil {
|
||||||
|
return errors.Wrap(err, "refreshing active raffle cache")
|
||||||
|
}
|
||||||
|
if err = dbc.RefreshSpeakUp(); err != nil {
|
||||||
|
return errors.Wrap(err, "refreshing active speak-ups")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMessage = args.FormatMessage
|
||||||
|
frontendNotify = args.FrontendNotify
|
||||||
|
send = args.SendMessage
|
||||||
|
tcGetter = args.GetTwitchClientForChannel
|
||||||
|
|
||||||
|
if err = registerAPI(args); err != nil {
|
||||||
|
return errors.Wrap(err, "registering API")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := args.RegisterCron("@every 10s", func() {
|
||||||
|
for name, fn := range map[string]func() error{
|
||||||
|
"close": dbc.AutoCloseExpired,
|
||||||
|
"start": dbc.AutoStart,
|
||||||
|
"send_reminders": dbc.AutoSendReminders,
|
||||||
|
} {
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"actor": actorName,
|
||||||
|
"cron": name,
|
||||||
|
}).WithError(err).Error("executing cron action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return errors.Wrap(err, "registering cron")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := args.RegisterRawMessageHandler(rawMessageHandler); err != nil {
|
||||||
|
return errors.Wrap(err, "registering raw message handler")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
2
main.go
2
main.go
|
@ -144,7 +144,7 @@ func main() {
|
||||||
FallbackToken: cfg.TwitchToken,
|
FallbackToken: cfg.TwitchToken,
|
||||||
TokenUpdateHook: func() {
|
TokenUpdateHook: func() {
|
||||||
// make frontend reload its state as of token change
|
// make frontend reload its state as of token change
|
||||||
frontendReloadHooks.Ping()
|
frontendNotifyHooks.Ping(frontendNotifyTypeReload)
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if !errors.Is(err, access.ErrChannelNotAuthorized) {
|
if !errors.Is(err, access.ErrChannelNotAuthorized) {
|
||||||
|
|
67
package-lock.json
generated
67
package-lock.json
generated
|
@ -4,11 +4,10 @@
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-bot",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.3.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.3.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.3.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/vue-fontawesome": "^2.0.10",
|
"@fortawesome/vue-fontawesome": "^2.0.10",
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.3.4",
|
||||||
"bootstrap": "^4.6.2",
|
"bootstrap": "^4.6.2",
|
||||||
|
@ -896,45 +895,45 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
|
||||||
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==",
|
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
|
||||||
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
|
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/free-brands-svg-icons": {
|
"node_modules/@fortawesome/free-brands-svg-icons": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
|
||||||
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
|
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
|
||||||
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
|
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
|
@ -4940,32 +4939,32 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@fortawesome/fontawesome-common-types": {
|
"@fortawesome/fontawesome-common-types": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
|
||||||
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg=="
|
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
|
||||||
},
|
},
|
||||||
"@fortawesome/fontawesome-svg-core": {
|
"@fortawesome/fontawesome-svg-core": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
|
||||||
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
|
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@fortawesome/free-brands-svg-icons": {
|
"@fortawesome/free-brands-svg-icons": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
|
||||||
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
|
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@fortawesome/free-solid-svg-icons": {
|
"@fortawesome/free-solid-svg-icons": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
|
||||||
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
|
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@fortawesome/fontawesome-common-types": "6.3.0"
|
"@fortawesome/fontawesome-common-types": "6.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@fortawesome/vue-fontawesome": {
|
"@fortawesome/vue-fontawesome": {
|
||||||
|
|
|
@ -8,9 +8,9 @@
|
||||||
"vue-template-compiler": "^2.7.14"
|
"vue-template-compiler": "^2.7.14"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.3.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.3.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.3.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/vue-fontawesome": "^2.0.10",
|
"@fortawesome/vue-fontawesome": "^2.0.10",
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.3.4",
|
||||||
"bootstrap": "^4.6.2",
|
"bootstrap": "^4.6.2",
|
||||||
|
|
|
@ -62,6 +62,7 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := gorm.Open(innerDB, &gorm.Config{
|
db, err := gorm.Open(innerDB, &gorm.Config{
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
Logger: logger.New(newLogrusLogWriterWithLevel(logrus.TraceLevel, driverName), logger.Config{}),
|
Logger: logger.New(newLogrusLogWriterWithLevel(logrus.TraceLevel, driverName), logger.Config{}),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -59,6 +59,8 @@ type (
|
||||||
CreateEvent EventHandlerFunc
|
CreateEvent EventHandlerFunc
|
||||||
// FormatMessage is a method to convert templates into strings using internally known variables / configs
|
// FormatMessage is a method to convert templates into strings using internally known variables / configs
|
||||||
FormatMessage MsgFormatter
|
FormatMessage MsgFormatter
|
||||||
|
// FrontendNotify is a way to send a notification to the frontend
|
||||||
|
FrontendNotify func(string)
|
||||||
// GetDatabaseConnector returns an active database.Connector to access the backend storage database
|
// GetDatabaseConnector returns an active database.Connector to access the backend storage database
|
||||||
GetDatabaseConnector func() database.Connector
|
GetDatabaseConnector func() database.Connector
|
||||||
// GetLogger returns a sirupsen log.Entry pre-configured with the module name
|
// GetLogger returns a sirupsen log.Entry pre-configured with the module name
|
||||||
|
|
|
@ -38,6 +38,7 @@ import (
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/customevent"
|
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/customevent"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/msgformat"
|
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/msgformat"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/overlays"
|
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/overlays"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/raffle"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
|
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/template/api"
|
"github.com/Luzifer/twitch-bot/v3/internal/template/api"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/template/numeric"
|
"github.com/Luzifer/twitch-bot/v3/internal/template/numeric"
|
||||||
|
@ -91,6 +92,7 @@ var (
|
||||||
customevent.Register,
|
customevent.Register,
|
||||||
msgformat.Register,
|
msgformat.Register,
|
||||||
overlays.Register,
|
overlays.Register,
|
||||||
|
raffle.Register,
|
||||||
}
|
}
|
||||||
knownModules []string
|
knownModules []string
|
||||||
)
|
)
|
||||||
|
@ -117,7 +119,7 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||||
var hdl http.Handler = route.HandlerFunc
|
var hdl http.Handler = route.HandlerFunc
|
||||||
switch {
|
switch {
|
||||||
case route.RequiresEditorsAuth:
|
case route.RequiresEditorsAuth:
|
||||||
hdl = botEditorAuthMiddleware(hdl)
|
hdl = writeAuthMiddleware(hdl, moduleConfigEditor)
|
||||||
case route.RequiresWriteAuth:
|
case route.RequiresWriteAuth:
|
||||||
hdl = writeAuthMiddleware(hdl, route.Module)
|
hdl = writeAuthMiddleware(hdl, route.Module)
|
||||||
}
|
}
|
||||||
|
@ -145,6 +147,7 @@ func getRegistrationArguments() plugins.RegistrationArguments {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
FormatMessage: formatMessage,
|
FormatMessage: formatMessage,
|
||||||
|
FrontendNotify: func(mt string) { frontendNotifyHooks.Ping(mt) },
|
||||||
GetDatabaseConnector: func() database.Connector { return db },
|
GetDatabaseConnector: func() database.Connector { return db },
|
||||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||||
|
|
14
src/app.vue
14
src/app.vue
|
@ -52,6 +52,16 @@
|
||||||
/>
|
/>
|
||||||
Rules
|
Rules
|
||||||
</b-nav-item>
|
</b-nav-item>
|
||||||
|
<b-nav-item
|
||||||
|
:to="{ name: 'raffle' }"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
fixed-width
|
||||||
|
class="mr-1"
|
||||||
|
:icon="['fas', 'dice']"
|
||||||
|
/>
|
||||||
|
Raffle
|
||||||
|
</b-nav-item>
|
||||||
</b-navbar-nav>
|
</b-navbar-nav>
|
||||||
|
|
||||||
<b-navbar-nav class="ml-auto">
|
<b-navbar-nav class="ml-auto">
|
||||||
|
@ -295,8 +305,8 @@ export default {
|
||||||
console.debug(`[notify] Socket message received type=${msg.msg_type}`)
|
console.debug(`[notify] Socket message received type=${msg.msg_type}`)
|
||||||
this.configNotifyBackoff = 100 // We've received a message, reset backoff
|
this.configNotifyBackoff = 100 // We've received a message, reset backoff
|
||||||
|
|
||||||
if (msg.msg_type === constants.NOTIFY_CONFIG_RELOAD) {
|
if (msg.msg_type !== 'ping') {
|
||||||
this.$bus.$emit(constants.NOTIFY_CONFIG_RELOAD)
|
this.$bus.$emit(msg.msg_type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.configNotifySocket.onclose = evt => {
|
this.configNotifySocket.onclose = evt => {
|
||||||
|
|
|
@ -260,7 +260,6 @@ import * as constants from './const.js'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import TemplateEditor from './tplEditor.vue'
|
import TemplateEditor from './tplEditor.vue'
|
||||||
import Vue from 'vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { TemplateEditor },
|
components: { TemplateEditor },
|
||||||
|
@ -375,7 +374,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
editAutoMessage(msg) {
|
editAutoMessage(msg) {
|
||||||
Vue.set(this.models, 'autoMessage', {
|
this.$set(this.models, 'autoMessage', {
|
||||||
...msg,
|
...msg,
|
||||||
sendMode: msg.cron ? 'cron' : 'lines',
|
sendMode: msg.cron ? 'cron' : 'lines',
|
||||||
})
|
})
|
||||||
|
@ -395,7 +394,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
newAutoMessage() {
|
newAutoMessage() {
|
||||||
Vue.set(this.models, 'autoMessage', {})
|
this.$set(this.models, 'autoMessage', {})
|
||||||
this.templateValid = {}
|
this.templateValid = {}
|
||||||
this.showAutoMessageEditModal = true
|
this.showAutoMessageEditModal = true
|
||||||
},
|
},
|
||||||
|
@ -429,7 +428,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTemplateValid(id, valid) {
|
updateTemplateValid(id, valid) {
|
||||||
Vue.set(this.templateValid, id, valid)
|
this.$set(this.templateValid, id, valid)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -412,7 +412,6 @@
|
||||||
import * as constants from './const.js'
|
import * as constants from './const.js'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import Vue from 'vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -645,7 +644,7 @@ export default {
|
||||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||||
return axios.get(`config-editor/user?user=${user}`, this.$root.axiosOptions)
|
return axios.get(`config-editor/user?user=${user}`, this.$root.axiosOptions)
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
Vue.set(this.userProfiles, user, resp.data)
|
this.$set(this.userProfiles, user, resp.data)
|
||||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
|
||||||
})
|
})
|
||||||
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
||||||
|
@ -682,7 +681,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
newAPIToken() {
|
newAPIToken() {
|
||||||
Vue.set(this.models, 'apiToken', {
|
this.$set(this.models, 'apiToken', {
|
||||||
modules: [],
|
modules: [],
|
||||||
name: '',
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
40
src/main.js
40
src/main.js
|
@ -19,16 +19,18 @@ Vue.use(VueRouter)
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { fab } from '@fortawesome/free-brands-svg-icons'
|
import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||||
|
|
||||||
library.add(fab, fas)
|
library.add(fab, fas)
|
||||||
Vue.component('FontAwesomeIcon', FontAwesomeIcon)
|
Vue.component('FontAwesomeIcon', FontAwesomeIcon)
|
||||||
|
Vue.component('FontAwesomeLayers', FontAwesomeLayers)
|
||||||
|
|
||||||
// App
|
// App
|
||||||
import App from './app.vue'
|
import App from './app.vue'
|
||||||
import Router from './router.js'
|
import Router from './router.js'
|
||||||
|
|
||||||
Vue.config.devtools = process.env.NODE_ENV === 'dev'
|
Vue.config.devtools = process.env.NODE_ENV === 'dev'
|
||||||
|
Vue.config.silent = process.env.NODE_ENV !== 'dev'
|
||||||
|
|
||||||
Vue.prototype.$bus = new Vue()
|
Vue.prototype.$bus = new Vue()
|
||||||
|
|
||||||
|
@ -46,6 +48,14 @@ new Vue({
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
authToken: null,
|
authToken: null,
|
||||||
|
commonToastOpts: {
|
||||||
|
appendToast: true,
|
||||||
|
autoHideDelay: 3000,
|
||||||
|
bodyClass: 'd-none',
|
||||||
|
solid: true,
|
||||||
|
toaster: 'b-toaster-bottom-right',
|
||||||
|
},
|
||||||
|
|
||||||
vars: {},
|
vars: {},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -58,6 +68,34 @@ new Vue({
|
||||||
this.vars = resp.data
|
this.vars = resp.data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toastError(message, options = {}) {
|
||||||
|
this.$bvToast.toast('...', {
|
||||||
|
...this.commonToastOpts,
|
||||||
|
...options,
|
||||||
|
noAutoHide: true,
|
||||||
|
title: message,
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
toastInfo(message, options = {}) {
|
||||||
|
this.$bvToast.toast('...', {
|
||||||
|
...this.commonToastOpts,
|
||||||
|
...options,
|
||||||
|
title: message,
|
||||||
|
variant: 'info',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
toastSuccess(message, options = {}) {
|
||||||
|
this.$bvToast.toast('...', {
|
||||||
|
...this.commonToastOpts,
|
||||||
|
...options,
|
||||||
|
title: message,
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
1146
src/raffle.vue
Normal file
1146
src/raffle.vue
Normal file
File diff suppressed because it is too large
Load diff
|
@ -4,6 +4,7 @@ import VueRouter from 'vue-router'
|
||||||
|
|
||||||
import Automessages from './automessages.vue'
|
import Automessages from './automessages.vue'
|
||||||
import GeneralConfig from './generalConfig.vue'
|
import GeneralConfig from './generalConfig.vue'
|
||||||
|
import Raffle from './raffle.vue'
|
||||||
import Rules from './rules.vue'
|
import Rules from './rules.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
@ -17,6 +18,11 @@ const routes = [
|
||||||
name: 'edit-automessages',
|
name: 'edit-automessages',
|
||||||
path: '/automessages',
|
path: '/automessages',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: Raffle,
|
||||||
|
name: 'raffle',
|
||||||
|
path: '/raffle',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: Rules,
|
component: Rules,
|
||||||
name: 'edit-rules',
|
name: 'edit-rules',
|
||||||
|
|
|
@ -677,7 +677,6 @@ import * as constants from './const.js'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import TemplateEditor from './tplEditor.vue'
|
import TemplateEditor from './tplEditor.vue'
|
||||||
import Vue from 'vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { TemplateEditor },
|
components: { TemplateEditor },
|
||||||
|
@ -804,7 +803,7 @@ export default {
|
||||||
|
|
||||||
addAction() {
|
addAction() {
|
||||||
if (!this.models.rule.actions) {
|
if (!this.models.rule.actions) {
|
||||||
Vue.set(this.models.rule, 'actions', [])
|
this.$set(this.models.rule, 'actions', [])
|
||||||
}
|
}
|
||||||
|
|
||||||
this.models.rule.actions.push({ attributes: {}, type: this.models.addAction })
|
this.models.rule.actions.push({ attributes: {}, type: this.models.addAction })
|
||||||
|
@ -846,7 +845,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
editRule(msg) {
|
editRule(msg) {
|
||||||
Vue.set(this.models, 'rule', {
|
this.$set(this.models, 'rule', {
|
||||||
...msg,
|
...msg,
|
||||||
actions: msg.actions?.map(action => ({ ...action, attributes: action.attributes || {} })) || [],
|
actions: msg.actions?.map(action => ({ ...action, attributes: action.attributes || {} })) || [],
|
||||||
channel_cooldown: this.fixDurationRepresentationToString(msg.channel_cooldown),
|
channel_cooldown: this.fixDurationRepresentationToString(msg.channel_cooldown),
|
||||||
|
@ -975,11 +974,11 @@ export default {
|
||||||
tmp[idx] = tmp[idx + direction]
|
tmp[idx] = tmp[idx + direction]
|
||||||
tmp[idx + direction] = eltmp
|
tmp[idx + direction] = eltmp
|
||||||
|
|
||||||
Vue.set(this.models.rule, 'actions', tmp)
|
this.$set(this.models.rule, 'actions', tmp)
|
||||||
},
|
},
|
||||||
|
|
||||||
newRule() {
|
newRule() {
|
||||||
Vue.set(this.models, 'rule', { match_message__validation: true })
|
this.$set(this.models, 'rule', { match_message__validation: true })
|
||||||
this.templateValid = {}
|
this.templateValid = {}
|
||||||
this.showRuleEditModal = true
|
this.showRuleEditModal = true
|
||||||
},
|
},
|
||||||
|
@ -1100,7 +1099,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTemplateValid(id, valid) {
|
updateTemplateValid(id, valid) {
|
||||||
Vue.set(this.templateValid, id, valid)
|
this.$set(this.templateValid, id, valid)
|
||||||
},
|
},
|
||||||
|
|
||||||
validateActionArgument(idx, key) {
|
validateActionArgument(idx, key) {
|
||||||
|
@ -1172,17 +1171,17 @@ export default {
|
||||||
|
|
||||||
validateExceptionRegex() {
|
validateExceptionRegex() {
|
||||||
return this.validateRegex(this.models.addException, false)
|
return this.validateRegex(this.models.addException, false)
|
||||||
.then(res => Vue.set(this.models, 'addException__validation', res))
|
.then(res => this.$set(this.models, 'addException__validation', res))
|
||||||
},
|
},
|
||||||
|
|
||||||
validateMatcherRegex() {
|
validateMatcherRegex() {
|
||||||
if (this.models.rule.match_message === '') {
|
if (this.models.rule.match_message === '') {
|
||||||
Vue.set(this.models.rule, 'match_message__validation', true)
|
this.$set(this.models.rule, 'match_message__validation', true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.validateRegex(this.models.rule.match_message, true)
|
return this.validateRegex(this.models.rule.match_message, true)
|
||||||
.then(res => Vue.set(this.models.rule, 'match_message__validation', res))
|
.then(res => this.$set(this.models.rule, 'match_message__validation', res))
|
||||||
},
|
},
|
||||||
|
|
||||||
validateRegex(regex, allowEmpty = true) {
|
validateRegex(regex, allowEmpty = true) {
|
||||||
|
|
71
writeAuth.go
71
writeAuth.go
|
@ -1,26 +1,45 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gofrs/uuid/v3"
|
"github.com/gofrs/uuid/v3"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/str"
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OWASP recommendations - 2023-07-07
|
||||||
|
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||||
|
argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
|
||||||
|
argonHashLen = 16
|
||||||
|
argonMemory = 46 * 1024
|
||||||
|
argonSaltLength = 8
|
||||||
|
argonThreads = 1
|
||||||
|
argonTime = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
func fillAuthToken(token *configAuthToken) error {
|
func fillAuthToken(token *configAuthToken) error {
|
||||||
token.Token = uuid.Must(uuid.NewV4()).String()
|
token.Token = uuid.Must(uuid.NewV4()).String()
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(token.Token), bcrypt.DefaultCost)
|
salt := make([]byte, argonSaltLength)
|
||||||
if err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return errors.Wrap(err, "hashing token")
|
return errors.Wrap(err, "reading salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
token.Hash = hex.EncodeToString(hash)
|
token.Hash = fmt.Sprintf(
|
||||||
|
argonFmt,
|
||||||
|
argon2.Version,
|
||||||
|
argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)),
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -33,24 +52,27 @@ func writeAuthMiddleware(h http.Handler, module string) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateAuthToken(token, module); err != nil {
|
for _, fn := range []func() error{
|
||||||
http.Error(w, "auth not successful", http.StatusForbidden)
|
// First try to validate against internal token management
|
||||||
return
|
func() error { return validateAuthToken(token, module) },
|
||||||
|
// If not successful validate against Twitch and check for bot-editors
|
||||||
|
func() error { return validateTwitchBotEditorAuthToken(token) },
|
||||||
|
} {
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "auth not successful", http.StatusForbidden)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAuthToken(token string, modules ...string) error {
|
func validateAuthToken(token string, modules ...string) error {
|
||||||
for _, auth := range config.AuthTokens {
|
for _, auth := range config.AuthTokens {
|
||||||
rawHash, err := hex.DecodeString(auth.Hash)
|
if auth.validate(token) != nil {
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("Invalid token hash found")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword(rawHash, []byte(token)) != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,3 +87,18 @@ func validateAuthToken(token string, modules ...string) error {
|
||||||
|
|
||||||
return errors.New("no matching token")
|
return errors.New("no matching token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTwitchBotEditorAuthToken(token string) error {
|
||||||
|
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
|
||||||
|
|
||||||
|
id, user, err := tc.GetAuthorizedUser()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting authorized user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
|
||||||
|
return errors.New("user is not an bot-edtior")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue