mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2025-02-07 02:52:20 +00:00
Breaking: Add configuration interface and switch to more generic config format (#7)
This commit is contained in:
parent
a1e9c35430
commit
b59676492e
45 changed files with 3776 additions and 405 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
config
|
||||
config.hcl
|
||||
config.yaml
|
||||
editor/bundle.*
|
||||
.env
|
||||
storage.json.gz
|
||||
twitch-bot
|
||||
|
|
|
@ -3,16 +3,19 @@
|
|||
---
|
||||
|
||||
run:
|
||||
skip-dirs:
|
||||
- config
|
||||
skip-files:
|
||||
- assets.go
|
||||
- bindata.go
|
||||
# timeout for analysis, e.g. 30s, 5m, default is 1m
|
||||
timeout: 5m
|
||||
# Force readonly modules usage for checking
|
||||
modules-download-mode: readonly
|
||||
|
||||
output:
|
||||
format: tab
|
||||
|
||||
linters-settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- 'fmt\.Errorf' # Should use github.com/pkg/errors
|
||||
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 60
|
||||
|
@ -21,6 +24,11 @@ linters-settings:
|
|||
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
||||
min-complexity: 15
|
||||
|
||||
gomnd:
|
||||
settings:
|
||||
mnd:
|
||||
ignored-functions: 'strconv.(?:Format|Parse)\B+'
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
|
@ -30,6 +38,7 @@ linters:
|
|||
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
|
||||
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false]
|
||||
- exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false]
|
||||
- forbidigo # Forbids identifiers [fast: true, auto-fix: false]
|
||||
- funlen # Tool for detection of long functions [fast: true, auto-fix: false]
|
||||
- gocognit # Computes and checks the cognitive complexity of functions [fast: true, auto-fix: false]
|
||||
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
|
||||
|
@ -37,6 +46,7 @@ linters:
|
|||
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
|
||||
- godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false]
|
||||
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
|
||||
- gofumpt # Gofumpt checks whether code was gofumpt-ed. [fast: true, auto-fix: true]
|
||||
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
|
||||
- gomnd # An analyzer to detect magic numbers. [fast: true, auto-fix: false]
|
||||
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]
|
||||
|
|
|
@ -6,7 +6,12 @@ WORKDIR /go/src/github.com/Luzifer/twitch-bot
|
|||
ENV CGO_ENABLED=0
|
||||
|
||||
RUN set -ex \
|
||||
&& apk add --update git \
|
||||
&& apk add --update \
|
||||
bash \
|
||||
curl \
|
||||
git \
|
||||
make \
|
||||
&& make frontend \
|
||||
&& go install \
|
||||
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
|
||||
-mod=readonly
|
||||
|
|
23
Makefile
23
Makefile
|
@ -3,15 +3,36 @@ default: lint test
|
|||
lint:
|
||||
golangci-lint run --timeout=5m
|
||||
|
||||
publish:
|
||||
publish: frontend
|
||||
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
||||
bash golang.sh
|
||||
|
||||
test:
|
||||
go test -cover -v ./...
|
||||
|
||||
# --- Editor frontend
|
||||
|
||||
frontend: editor/bundle.css
|
||||
frontend: editor/bundle.js
|
||||
|
||||
editor/bundle.js:
|
||||
bash ci/bundle.sh $@ \
|
||||
npm/axios@0.21.4/dist/axios.min.js \
|
||||
npm/vue@2 \
|
||||
npm/bootstrap-vue@2/dist/bootstrap-vue.min.js \
|
||||
npm/moment@2
|
||||
|
||||
editor/bundle.css:
|
||||
bash ci/bundle.sh $@ \
|
||||
npm/bootstrap@4/dist/css/bootstrap.min.css \
|
||||
npm/bootstrap-vue@2/dist/bootstrap-vue.min.css \
|
||||
npm/bootswatch@4/dist/darkly/bootstrap.min.css
|
||||
|
||||
# --- Wiki Updates
|
||||
|
||||
actor_docs:
|
||||
go run . actor-docs >wiki/Actors.md
|
||||
|
||||
pull_wiki:
|
||||
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/internal/actors/ban"
|
||||
"github.com/Luzifer/twitch-bot/internal/actors/delay"
|
||||
|
@ -43,12 +44,17 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
|||
PathPrefix(fmt.Sprintf("/%s/", route.Module)).
|
||||
Subrouter()
|
||||
|
||||
var hdl http.Handler = route.HandlerFunc
|
||||
if route.RequiresEditorsAuth {
|
||||
hdl = botEditorAuthMiddleware(hdl)
|
||||
}
|
||||
|
||||
if route.IsPrefix {
|
||||
r.PathPrefix(route.Path).
|
||||
HandlerFunc(route.HandlerFunc).
|
||||
Handler(hdl).
|
||||
Methods(route.Method)
|
||||
} else {
|
||||
r.HandleFunc(route.Path, route.HandlerFunc).
|
||||
r.Handle(route.Path, hdl).
|
||||
Methods(route.Method)
|
||||
}
|
||||
|
||||
|
@ -61,14 +67,15 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
|||
|
||||
func getRegistrationArguments() plugins.RegistrationArguments {
|
||||
return plugins.RegistrationArguments{
|
||||
FormatMessage: formatMessage,
|
||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||
RegisterActor: registerAction,
|
||||
RegisterAPIRoute: registerRoute,
|
||||
RegisterCron: cronService.AddFunc,
|
||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||
RegisterTemplateFunction: tplFuncs.Register,
|
||||
SendMessage: sendMessage,
|
||||
FormatMessage: formatMessage,
|
||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||
RegisterActor: registerAction,
|
||||
RegisterActorDocumentation: registerActorDocumentation,
|
||||
RegisterAPIRoute: registerRoute,
|
||||
RegisterCron: cronService.AddFunc,
|
||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||
RegisterTemplateFunction: tplFuncs.Register,
|
||||
SendMessage: sendMessage,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,43 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorCounter{} })
|
||||
registerAction("counter", func() plugins.Actor { return &ActorCounter{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Update counter values",
|
||||
Name: "Modify Counter",
|
||||
Type: "counter",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Name of the counter to update",
|
||||
Key: "counter",
|
||||
Name: "Counter",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "1",
|
||||
Description: "Value to add to the counter",
|
||||
Key: "counter_step",
|
||||
Name: "Counter Step",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeInt64,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Value to set the counter to",
|
||||
Key: "counter_set",
|
||||
Name: "Counter Set",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||
Description: "Returns the (formatted) value as a plain string",
|
||||
|
@ -68,29 +104,21 @@ func init() {
|
|||
})
|
||||
}
|
||||
|
||||
type ActorCounter struct {
|
||||
CounterSet *string `json:"counter_set" yaml:"counter_set"`
|
||||
CounterStep *int64 `json:"counter_step" yaml:"counter_step"`
|
||||
Counter *string `json:"counter" yaml:"counter"`
|
||||
}
|
||||
type ActorCounter struct{}
|
||||
|
||||
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Counter == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
counterName, err := formatMessage(*a.Counter, m, r, eventData)
|
||||
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing response")
|
||||
}
|
||||
|
||||
if a.CounterSet != nil {
|
||||
parseValue, err := formatMessage(*a.CounterSet, m, r, eventData)
|
||||
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" {
|
||||
parseValue, err := formatMessage(counterSet, m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "execute counter value template")
|
||||
}
|
||||
|
||||
counterValue, err := strconv.ParseInt(parseValue, 10, 64) //nolint:gomnd // Those numbers are static enough
|
||||
counterValue, err := strconv.ParseInt(parseValue, 10, 64)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "parse counter value")
|
||||
}
|
||||
|
@ -102,8 +130,8 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
|
|||
}
|
||||
|
||||
var counterStep int64 = 1
|
||||
if a.CounterStep != nil {
|
||||
counterStep = *a.CounterStep
|
||||
if s := attrs.MustInt64("counter_step", ptrIntZero); s != 0 {
|
||||
counterStep = s
|
||||
}
|
||||
|
||||
return false, errors.Wrap(
|
||||
|
@ -115,6 +143,14 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
|
|||
func (a ActorCounter) IsAsync() bool { return false }
|
||||
func (a ActorCounter) Name() string { return "counter" }
|
||||
|
||||
func (a ActorCounter) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if cn, err := attrs.String("counter"); err != nil || cn == "" {
|
||||
return errors.New("counter name must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
|
||||
template := r.FormValue("template")
|
||||
if template == "" {
|
||||
|
@ -132,7 +168,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
|
|||
value int64
|
||||
)
|
||||
|
||||
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil { //nolint:gomnd // Those numbers are static enough
|
||||
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil {
|
||||
http.Error(w, errors.Wrap(err, "parsing value").Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -14,27 +14,51 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorScript{} })
|
||||
registerAction("script", func() plugins.Actor { return &ActorScript{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Execute external script / command",
|
||||
Name: "Execute Script / Command",
|
||||
Type: "script",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Command to execute",
|
||||
Key: "command",
|
||||
Name: "Command",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeStringSlice,
|
||||
},
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Do not activate cooldown for route when command exits non-zero",
|
||||
Key: "skip_cooldown_on_error",
|
||||
Name: "Skip Cooldown on Error",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeBool,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ActorScript struct {
|
||||
Command []string `json:"command" yaml:"command"`
|
||||
SkipCooldownOnError bool `json:"skip_cooldown_on_error" yaml:"skip_cooldown_on_error"`
|
||||
}
|
||||
type ActorScript struct{}
|
||||
|
||||
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if len(a.Command) == 0 {
|
||||
return false, nil
|
||||
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
command, err := attrs.StringSlice("command")
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting command")
|
||||
}
|
||||
|
||||
var command []string
|
||||
for _, arg := range a.Command {
|
||||
tmp, err := formatMessage(arg, m, r, eventData)
|
||||
for i := range command {
|
||||
tmp, err := formatMessage(command[i], m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "execute command argument template")
|
||||
}
|
||||
|
||||
command = append(command, tmp)
|
||||
command[i] = tmp
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout)
|
||||
|
@ -67,7 +91,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
|||
cmd.Stdout = stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return a.SkipCooldownOnError, errors.Wrap(err, "running command")
|
||||
return attrs.MustBool("skip_cooldown_on_error", ptrBoolFalse), errors.Wrap(err, "running command")
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
|
@ -86,7 +110,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
|||
}
|
||||
|
||||
for _, action := range actions {
|
||||
apc, err := triggerActions(c, m, r, action, eventData)
|
||||
apc, err := triggerAction(c, m, r, action, eventData)
|
||||
if err != nil {
|
||||
return preventCooldown, errors.Wrap(err, "execute returned action")
|
||||
}
|
||||
|
@ -98,3 +122,11 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
|||
|
||||
func (a ActorScript) IsAsync() bool { return false }
|
||||
func (a ActorScript) Name() string { return "script" }
|
||||
|
||||
func (a ActorScript) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if cmd, err := attrs.StringSlice("command"); err != nil || len(cmd) == 0 {
|
||||
return errors.New("command must be slice of strings with length > 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -11,7 +11,43 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorSetVariable{} })
|
||||
registerAction("setvariable", func() plugins.Actor { return &ActorSetVariable{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Modify variable contents",
|
||||
Name: "Modify Variable",
|
||||
Type: "setvariable",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Name of the variable to update",
|
||||
Key: "variable",
|
||||
Name: "Variable",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Clear variable content and unset the variable",
|
||||
Key: "clear",
|
||||
Name: "Clear",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeBool,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Value to set the variable to",
|
||||
Key: "set",
|
||||
Name: "Set Content",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||
Description: "Returns the value as a plain string",
|
||||
|
@ -53,30 +89,22 @@ func init() {
|
|||
})
|
||||
}
|
||||
|
||||
type ActorSetVariable struct {
|
||||
Variable string `json:"variable" yaml:"variable"`
|
||||
Clear bool `json:"clear" yaml:"clear"`
|
||||
Set string `json:"set" yaml:"set"`
|
||||
}
|
||||
type ActorSetVariable struct{}
|
||||
|
||||
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Variable == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
varName, err := formatMessage(a.Variable, m, r, eventData)
|
||||
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing variable name")
|
||||
}
|
||||
|
||||
if a.Clear {
|
||||
if attrs.MustBool("clear", ptrBoolFalse) {
|
||||
return false, errors.Wrap(
|
||||
store.RemoveVariable(varName),
|
||||
"removing variable",
|
||||
)
|
||||
}
|
||||
|
||||
value, err := formatMessage(a.Set, m, r, eventData)
|
||||
value, err := formatMessage(attrs.MustString("set", ptrStringEmpty), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing value")
|
||||
}
|
||||
|
@ -90,6 +118,14 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
|
|||
func (a ActorSetVariable) IsAsync() bool { return false }
|
||||
func (a ActorSetVariable) Name() string { return "setvariable" }
|
||||
|
||||
func (a ActorSetVariable) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("variable"); err != nil || v == "" {
|
||||
return errors.New("variable name must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text-plain")
|
||||
fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"]))
|
||||
|
|
78
actions.go
78
actions.go
|
@ -10,52 +10,58 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
availableActions []plugins.ActorCreationFunc
|
||||
availableActions = map[string]plugins.ActorCreationFunc{}
|
||||
availableActionsLock = new(sync.RWMutex)
|
||||
)
|
||||
|
||||
// Compile-time assertion
|
||||
var _ plugins.ActorRegistrationFunc = registerAction
|
||||
|
||||
func registerAction(af plugins.ActorCreationFunc) {
|
||||
availableActionsLock.Lock()
|
||||
defer availableActionsLock.Unlock()
|
||||
|
||||
availableActions = append(availableActions, af)
|
||||
}
|
||||
|
||||
func triggerActions(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
func getActorByName(name string) (plugins.Actor, error) {
|
||||
availableActionsLock.RLock()
|
||||
defer availableActionsLock.RUnlock()
|
||||
|
||||
for _, acf := range availableActions {
|
||||
var (
|
||||
a = acf()
|
||||
logger = log.WithField("actor", a.Name())
|
||||
)
|
||||
|
||||
if err := ra.Unmarshal(a); err != nil {
|
||||
logger.WithError(err).Trace("Unable to unmarshal config")
|
||||
continue
|
||||
}
|
||||
|
||||
if a.IsAsync() {
|
||||
go func() {
|
||||
if _, err := a.Execute(c, m, rule, eventData); err != nil {
|
||||
logger.WithError(err).Error("Error in async actor")
|
||||
}
|
||||
}()
|
||||
continue
|
||||
}
|
||||
|
||||
apc, err := a.Execute(c, m, rule, eventData)
|
||||
preventCooldown = preventCooldown || apc
|
||||
if err != nil {
|
||||
return preventCooldown, errors.Wrap(err, "execute action")
|
||||
}
|
||||
acf, ok := availableActions[name]
|
||||
if !ok {
|
||||
return nil, errors.Errorf("undefined actor %q called", name)
|
||||
}
|
||||
|
||||
return preventCooldown, nil
|
||||
return acf(), nil
|
||||
}
|
||||
|
||||
func registerAction(name string, acf plugins.ActorCreationFunc) {
|
||||
availableActionsLock.Lock()
|
||||
defer availableActionsLock.Unlock()
|
||||
|
||||
if _, ok := availableActions[name]; ok {
|
||||
log.WithField("name", name).Fatal("Duplicate registration of actor")
|
||||
}
|
||||
|
||||
availableActions[name] = acf
|
||||
}
|
||||
|
||||
func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
availableActionsLock.RLock()
|
||||
defer availableActionsLock.RUnlock()
|
||||
|
||||
a, err := getActorByName(ra.Type)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting actor")
|
||||
}
|
||||
|
||||
logger := log.WithField("actor", a.Name())
|
||||
|
||||
if a.IsAsync() {
|
||||
go func() {
|
||||
if _, err := a.Execute(c, m, rule, eventData, ra.Attributes); err != nil {
|
||||
logger.WithError(err).Error("Error in async actor")
|
||||
}
|
||||
}()
|
||||
return preventCooldown, nil
|
||||
}
|
||||
|
||||
apc, err := a.Execute(c, m, rule, eventData, ra.Attributes)
|
||||
return apc, errors.Wrap(err, "execute action")
|
||||
}
|
||||
|
||||
func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugins.FieldCollection) {
|
||||
|
@ -63,7 +69,7 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugi
|
|||
var preventCooldown bool
|
||||
|
||||
for _, a := range r.Actions {
|
||||
apc, err := triggerActions(c, m, r, a, eventData)
|
||||
apc, err := triggerAction(c, m, r, a, eventData)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to trigger action")
|
||||
break // Break execution when one action fails
|
||||
|
|
27
actorDocs.go
Normal file
27
actorDocs.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"text/template"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
//go:embed actorDocs.tpl
|
||||
var actorDocsTemplate string
|
||||
|
||||
func generateActorDocs() ([]byte, error) {
|
||||
tpl, err := template.New("actorDocs").Parse(actorDocsTemplate)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parsing actorDocs template")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tpl.Execute(buf, struct{ Actors []plugins.ActionDocumentation }{availableActorDocs}); err != nil {
|
||||
return nil, errors.Wrap(err, "rendering actorDocs template")
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
42
actorDocs.tpl
Normal file
42
actorDocs.tpl
Normal file
|
@ -0,0 +1,42 @@
|
|||
# Available Actions
|
||||
|
||||
{{ range .Actors }}
|
||||
## {{ .Name }}
|
||||
|
||||
{{ .Description }}
|
||||
|
||||
```yaml
|
||||
- type: {{ .Type }}
|
||||
{{- if gt (len .Fields) 0 }}
|
||||
attributes:
|
||||
{{- range .Fields }}
|
||||
# {{ .Description }}
|
||||
# Optional: {{ .Optional }}
|
||||
{{- if eq .Type "bool" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ eq .Default "true" }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "duration" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ if eq .Default "" }}0s{{ else }}{{ .Default }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "int64" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ if eq .Default "" }}0{{ else }}{{ .Default }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "string" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: "{{ .Default }}"
|
||||
{{- end }}
|
||||
{{- if eq .Type "stringslice" }}
|
||||
# Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string) {{ end }}
|
||||
{{ .Key }}: []
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
# Does not have configuration attributes
|
||||
{{- end }}
|
||||
```
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}<!-- vim: set ft=markdown: -->{{ end }}
|
|
@ -14,21 +14,20 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
type autoMessage struct {
|
||||
UUID string `hash:"-" yaml:"uuid"`
|
||||
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
|
||||
|
||||
Channel string `yaml:"channel"`
|
||||
Message string `yaml:"message"`
|
||||
UseAction bool `yaml:"use_action"`
|
||||
Channel string `json:"channel,omitempty" yaml:"channel,omitempty"`
|
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
||||
UseAction bool `json:"use_action,omitempty" yaml:"use_action,omitempty"`
|
||||
|
||||
DisableOnTemplate *string `yaml:"disable_on_template"`
|
||||
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
|
||||
|
||||
Cron string `yaml:"cron"`
|
||||
MessageInterval int64 `yaml:"message_interval"`
|
||||
OnlyOnLive bool `yaml:"only_on_live"`
|
||||
TimeInterval time.Duration `yaml:"time_interval"`
|
||||
Cron string `json:"cron,omitempty" yaml:"cron,omitempty"`
|
||||
MessageInterval int64 `json:"message_interval,omitempty" yaml:"message_interval,omitempty"`
|
||||
OnlyOnLive bool `json:"only_on_live,omitempty" yaml:"only_on_live,omitempty"`
|
||||
|
||||
disabled bool
|
||||
lastMessageSent time.Time
|
||||
|
@ -54,10 +53,6 @@ func (a *autoMessage) CanSend() bool {
|
|||
// Not enough chatted lines
|
||||
return false
|
||||
|
||||
case a.TimeInterval > 0 && a.lastMessageSent.Add(a.TimeInterval).After(time.Now()):
|
||||
// Simple timer is not yet expired
|
||||
return false
|
||||
|
||||
case a.Cron != "":
|
||||
sched, _ := cronParser.Parse(a.Cron)
|
||||
nextExecute := sched.Next(a.lastMessageSent)
|
||||
|
@ -126,7 +121,7 @@ func (a *autoMessage) IsValid() bool {
|
|||
}
|
||||
}
|
||||
|
||||
if a.MessageInterval == 0 && a.TimeInterval == 0 && a.Cron == "" {
|
||||
if a.MessageInterval == 0 && a.Cron == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -163,7 +158,7 @@ func (a *autoMessage) Send(c *irc.Client) error {
|
|||
}
|
||||
|
||||
func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
|
||||
if a.DisableOnTemplate == nil {
|
||||
if a.DisableOnTemplate == nil || *a.DisableOnTemplate == "" {
|
||||
// No match criteria set, does not speak against matching
|
||||
return true
|
||||
}
|
||||
|
|
40
botEditor.go
Normal file
40
botEditor.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/str"
|
||||
"github.com/Luzifer/twitch-bot/twitch"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func botEditorAuthMiddleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
http.Error(w, "no auth-token provided", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
tc := twitch.New(cfg.TwitchClient, token)
|
||||
|
||||
user, err := tc.GetAuthorizedUsername()
|
||||
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)
|
||||
})
|
||||
}
|
14
ci/bundle.sh
Normal file
14
ci/bundle.sh
Normal file
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
set -euxo pipefail
|
||||
|
||||
outfile=${1:-}
|
||||
[[ -n $outfile ]] || {
|
||||
echo "Missing parameters: $0 <outfile> [libraries]"
|
||||
exit 1
|
||||
}
|
||||
shift
|
||||
|
||||
libs=("$@")
|
||||
|
||||
IFS=$','
|
||||
exec curl -sSfLo "${outfile}" "https://cdn.jsdelivr.net/combine/${libs[*]}"
|
196
config.go
196
config.go
|
@ -1,32 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/go-irc/irc"
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type configFile struct {
|
||||
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||
Channels []string `yaml:"channels"`
|
||||
HTTPListen string `yaml:"http_listen"`
|
||||
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||
PermitTimeout time.Duration `yaml:"permit_timeout"`
|
||||
RawLog string `yaml:"raw_log"`
|
||||
Rules []*plugins.Rule `yaml:"rules"`
|
||||
Variables map[string]interface{} `yaml:"variables"`
|
||||
const expectedMinConfigVersion = 2
|
||||
|
||||
rawLogWriter io.WriteCloser
|
||||
var (
|
||||
//go:embed default_config.yaml
|
||||
defaultConfigurationYAML []byte
|
||||
|
||||
hashstructUUIDNamespace = uuid.Must(uuid.FromString("3a0ccc46-d3ba-46b5-ac07-27528c933174"))
|
||||
|
||||
configReloadHooks = map[string]func(){}
|
||||
configReloadHooksLock sync.RWMutex
|
||||
)
|
||||
|
||||
func registerConfigReloadHook(hook func()) func() {
|
||||
configReloadHooksLock.Lock()
|
||||
defer configReloadHooksLock.Unlock()
|
||||
|
||||
id := uuid.Must(uuid.NewV4()).String()
|
||||
configReloadHooks[id] = hook
|
||||
|
||||
return func() {
|
||||
configReloadHooksLock.Lock()
|
||||
defer configReloadHooksLock.Unlock()
|
||||
|
||||
delete(configReloadHooks, id)
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
configFileVersioner struct {
|
||||
ConfigVersion int64 `yaml:"config_version"`
|
||||
}
|
||||
|
||||
configFile struct {
|
||||
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||
BotEditors []string `yaml:"bot_editors"`
|
||||
Channels []string `yaml:"channels"`
|
||||
HTTPListen string `yaml:"http_listen"`
|
||||
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||
PermitTimeout time.Duration `yaml:"permit_timeout"`
|
||||
RawLog string `yaml:"raw_log"`
|
||||
Rules []*plugins.Rule `yaml:"rules"`
|
||||
Variables map[string]interface{} `yaml:"variables"`
|
||||
|
||||
rawLogWriter io.WriteCloser
|
||||
|
||||
configFileVersioner `yaml:",inline"`
|
||||
}
|
||||
)
|
||||
|
||||
func newConfigFile() *configFile {
|
||||
return &configFile{
|
||||
PermitTimeout: time.Minute,
|
||||
|
@ -35,19 +75,20 @@ func newConfigFile() *configFile {
|
|||
|
||||
func loadConfig(filename string) error {
|
||||
var (
|
||||
err error
|
||||
tmpConfig *configFile
|
||||
configVersion = &configFileVersioner{}
|
||||
err error
|
||||
tmpConfig = newConfigFile()
|
||||
)
|
||||
|
||||
switch path.Ext(filename) {
|
||||
case ".yaml", ".yml":
|
||||
tmpConfig, err = parseConfigFromYAML(filename)
|
||||
|
||||
default:
|
||||
return errors.Errorf("Unknown config format %q", path.Ext(filename))
|
||||
if err = parseConfigFromYAML(filename, configVersion, false); err != nil {
|
||||
return errors.Wrap(err, "parsing config version")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if configVersion.ConfigVersion < expectedMinConfigVersion {
|
||||
return errors.Errorf("config version too old: %d < %d - Please have a look at the documentation!", configVersion.ConfigVersion, expectedMinConfigVersion)
|
||||
}
|
||||
|
||||
if err = parseConfigFromYAML(filename, tmpConfig, true); err != nil {
|
||||
return errors.Wrap(err, "parsing config")
|
||||
}
|
||||
|
||||
|
@ -59,11 +100,16 @@ func loadConfig(filename string) error {
|
|||
log.Warn("Loaded config with empty ruleset")
|
||||
}
|
||||
|
||||
if err = tmpConfig.validateRuleActions(); err != nil {
|
||||
return errors.Wrap(err, "validating rule actions")
|
||||
}
|
||||
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
|
||||
tmpConfig.updateAutoMessagesFromConfig(config)
|
||||
tmpConfig.fixDurations()
|
||||
tmpConfig.fixMissingUUIDs()
|
||||
|
||||
switch {
|
||||
case config != nil && config.RawLog == tmpConfig.RawLog:
|
||||
|
@ -96,28 +142,81 @@ func loadConfig(filename string) error {
|
|||
"channels": len(config.Channels),
|
||||
}).Info("Config file (re)loaded")
|
||||
|
||||
// Notify listener config has changed
|
||||
configReloadHooksLock.RLock()
|
||||
defer configReloadHooksLock.RUnlock()
|
||||
for _, fn := range configReloadHooks {
|
||||
fn()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfigFromYAML(filename string) (*configFile, error) {
|
||||
func parseConfigFromYAML(filename string, obj interface{}, strict bool) error {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "open config file")
|
||||
return errors.Wrap(err, "open config file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
decoder := yaml.NewDecoder(f)
|
||||
decoder.SetStrict(strict)
|
||||
|
||||
return errors.Wrap(decoder.Decode(obj), "decode config file")
|
||||
}
|
||||
|
||||
func patchConfig(filename string, patcher func(*configFile) error) error {
|
||||
var (
|
||||
decoder = yaml.NewDecoder(f)
|
||||
tmpConfig = newConfigFile()
|
||||
cfgFile = newConfigFile()
|
||||
err error
|
||||
)
|
||||
|
||||
decoder.SetStrict(true)
|
||||
|
||||
if err = decoder.Decode(&tmpConfig); err != nil {
|
||||
return nil, errors.Wrap(err, "decode config file")
|
||||
if err = parseConfigFromYAML(filename, cfgFile, true); err != nil {
|
||||
return errors.Wrap(err, "loading current config")
|
||||
}
|
||||
|
||||
return tmpConfig, nil
|
||||
cfgFile.fixMissingUUIDs()
|
||||
|
||||
if err = patcher(cfgFile); err != nil {
|
||||
return errors.Wrap(err, "patching config")
|
||||
}
|
||||
|
||||
return errors.Wrap(
|
||||
writeConfigToYAML(filename, cfgFile),
|
||||
"replacing config",
|
||||
)
|
||||
}
|
||||
|
||||
func writeConfigToYAML(filename string, obj interface{}) error {
|
||||
tmpFile, err := ioutil.TempFile(path.Dir(filename), "twitch-bot-*.yaml")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "opening tempfile")
|
||||
}
|
||||
tmpFileName := tmpFile.Name()
|
||||
|
||||
fmt.Fprintf(tmpFile, "# Automatically updated by Config-Editor frontend, last update: %s\n", time.Now().Format(time.RFC3339))
|
||||
|
||||
if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil {
|
||||
tmpFile.Close()
|
||||
return errors.Wrap(err, "encoding config")
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
return errors.Wrap(
|
||||
os.Rename(tmpFileName, filename),
|
||||
"moving config to location",
|
||||
)
|
||||
}
|
||||
|
||||
func writeDefaultConfigFile(filename string) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "creating config file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(defaultConfigurationYAML)
|
||||
return errors.Wrap(err, "writing default config")
|
||||
}
|
||||
|
||||
func (c *configFile) CloseRawMessageWriter() error {
|
||||
|
@ -155,11 +254,6 @@ func (c *configFile) fixDurations() {
|
|||
for _, r := range c.Rules {
|
||||
r.Cooldown = c.fixedDurationPtr(r.Cooldown)
|
||||
}
|
||||
|
||||
// Fix auto-messages
|
||||
for _, a := range c.AutoMessages {
|
||||
a.TimeInterval = c.fixedDuration(a.TimeInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (configFile) fixedDuration(d time.Duration) time.Duration {
|
||||
|
@ -177,6 +271,22 @@ func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
|
|||
return &fd
|
||||
}
|
||||
|
||||
func (c *configFile) fixMissingUUIDs() {
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].UUID != "" {
|
||||
continue
|
||||
}
|
||||
c.AutoMessages[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.AutoMessages[i].ID()).String()
|
||||
}
|
||||
|
||||
for i := range c.Rules {
|
||||
if c.Rules[i].UUID != "" {
|
||||
continue
|
||||
}
|
||||
c.Rules[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.Rules[i].MatcherID()).String()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
||||
for idx, nam := range c.AutoMessages {
|
||||
// By default assume last message to be sent now
|
||||
|
@ -208,3 +318,23 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c configFile) validateRuleActions() error {
|
||||
for _, r := range c.Rules {
|
||||
logger := log.WithField("rule", r.MatcherID())
|
||||
for idx, a := range r.Actions {
|
||||
actor, err := getActorByName(a.Type)
|
||||
if err != nil {
|
||||
logger.WithField("index", idx).WithError(err).Error("Cannot get actor by type")
|
||||
return errors.Wrap(err, "getting actor by type")
|
||||
}
|
||||
|
||||
if err = actor.Validate(a.Attributes); err != nil {
|
||||
logger.WithField("index", idx).WithError(err).Error("Actor reported invalid config")
|
||||
return errors.Wrap(err, "validating action attributes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
597
configEditor.go
Normal file
597
configEditor.go
Normal file
|
@ -0,0 +1,597 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/Luzifer/twitch-bot/twitch"
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const websocketPingInterval = 30 * time.Second
|
||||
|
||||
var (
|
||||
availableActorDocs = []plugins.ActionDocumentation{}
|
||||
availableActorDocsLock sync.RWMutex
|
||||
|
||||
//go:embed editor/*
|
||||
configEditorFrontend embed.FS
|
||||
|
||||
upgrader = websocket.Upgrader{}
|
||||
)
|
||||
|
||||
func registerActorDocumentation(doc plugins.ActionDocumentation) {
|
||||
availableActorDocsLock.Lock()
|
||||
defer availableActorDocsLock.Unlock()
|
||||
|
||||
availableActorDocs = append(availableActorDocs, doc)
|
||||
sort.Slice(availableActorDocs, func(i, j int) bool {
|
||||
return availableActorDocs[i].Name < availableActorDocs[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
type (
|
||||
configEditorGeneralConfig struct {
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerEditorAutoMessageRoutes()
|
||||
registerEditorFrontend()
|
||||
registerEditorGeneralConfigRoutes()
|
||||
registerEditorGlobalMethods()
|
||||
registerEditorRulesRoutes()
|
||||
}
|
||||
|
||||
//nolint:funlen // This is a logic unit and shall not be split up
|
||||
func registerEditorAutoMessageRoutes() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the current set of configured auto-messages in JSON format",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(config.AutoMessages); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get current auto-messages",
|
||||
Path: "/auto-messages",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Adds a new Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
msg := &autoMessage{}
|
||||
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
msg.UUID = uuid.Must(uuid.NewV4()).String()
|
||||
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
c.AutoMessages = append(c.AutoMessages, msg)
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
},
|
||||
Method: http.MethodPost,
|
||||
Module: "config-editor",
|
||||
Name: "Add Auto-Message",
|
||||
Path: "/auto-messages",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
},
|
||||
{
|
||||
Description: "Deletes the given Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
var (
|
||||
id = mux.Vars(r)["uuid"]
|
||||
tmp []*autoMessage
|
||||
)
|
||||
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].ID() == id {
|
||||
continue
|
||||
}
|
||||
tmp = append(tmp, c.AutoMessages[i])
|
||||
}
|
||||
|
||||
c.AutoMessages = tmp
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodDelete,
|
||||
Module: "config-editor",
|
||||
Name: "Delete Auto-Message",
|
||||
Path: "/auto-messages/{uuid}",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "UUID of the auto-message to delete",
|
||||
Name: "uuid",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Description: "Updates the given Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
msg := &autoMessage{}
|
||||
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
id := mux.Vars(r)["uuid"]
|
||||
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].ID() == id {
|
||||
c.AutoMessages[i] = msg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
Module: "config-editor",
|
||||
Name: "Update Auto-Message",
|
||||
Path: "/auto-messages/{uuid}",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "UUID of the auto-message to update",
|
||||
Name: "uuid",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
if err := registerRoute(rd); err != nil {
|
||||
log.WithError(err).Fatal("Unable to register config editor route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerEditorFrontend() {
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := configEditorFrontend.Open("editor/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "opening index.html").Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
io.Copy(w, f)
|
||||
})
|
||||
|
||||
router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(struct {
|
||||
IRCBadges []string
|
||||
KnownEvents []*string
|
||||
TwitchClientID string
|
||||
}{
|
||||
IRCBadges: twitch.KnownBadges,
|
||||
KnownEvents: knownEvents,
|
||||
TwitchClientID: cfg.TwitchClient,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
router.PathPrefix("/editor").Handler(http.FileServer(http.FS(configEditorFrontend)))
|
||||
}
|
||||
|
||||
func registerEditorGeneralConfigRoutes() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the current general config",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
||||
BotEditors: config.BotEditors,
|
||||
Channels: config.Channels,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get general config",
|
||||
Path: "/general",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Updates the general config",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload configEditorGeneralConfig
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range payload.BotEditors {
|
||||
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i])
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
payload.BotEditors[i] = usr.ID
|
||||
}
|
||||
|
||||
if err := patchConfig(cfg.Config, func(cfg *configFile) error {
|
||||
cfg.Channels = payload.Channels
|
||||
cfg.BotEditors = payload.BotEditors
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
Module: "config-editor",
|
||||
Name: "Update general config",
|
||||
Path: "/general",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
},
|
||||
} {
|
||||
if err := registerRoute(rd); err != nil {
|
||||
log.WithError(err).Fatal("Unable to register config editor route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:funlen,gocognit,gocyclo // This is a logic unit and shall not be split up
|
||||
func registerEditorGlobalMethods() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the documentation for available actions",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
availableActorDocsLock.Lock()
|
||||
defer availableActorDocsLock.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(availableActorDocs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get available actions",
|
||||
Path: "/actions",
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Returns information about a Twitch user to properly display bot editors",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
usr, err := twitchClient.GetUserInformation(r.FormValue("user"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||