Breaking: Add configuration interface and switch to more generic config format (#7)

This commit is contained in:
Knut Ahlers 2021-09-22 15:36:45 +02:00 committed by GitHub
parent a1e9c35430
commit b59676492e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 3776 additions and 405 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
config
config.hcl
config.yaml
editor/bundle.*
.env
storage.json.gz
twitch-bot

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"]))

View file

@ -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
View 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
View 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 }}

View file

@ -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
View 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
View 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
View file

@ -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
View 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
}