diff --git a/.gitignore b/.gitignore index eaf6410..00b603f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ config config.hcl config.yaml +editor/bundle.* .env storage.json.gz twitch-bot diff --git a/.golangci.yml b/.golangci.yml index 30378a0..162bbf7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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] diff --git a/Dockerfile b/Dockerfile index dfc4e5d..f9ba349 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 20e1819..cf76e26 100644 --- a/Makefile +++ b/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 diff --git a/action_core.go b/action_core.go index 17c7cd8..21da8f7 100644 --- a/action_core.go +++ b/action_core.go @@ -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, } } diff --git a/action_counter.go b/action_counter.go index 75f5a39..5f194b0 100644 --- a/action_counter.go +++ b/action_counter.go @@ -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 } diff --git a/action_script.go b/action_script.go index 2122e3e..3f3eba4 100644 --- a/action_script.go +++ b/action_script.go @@ -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 +} diff --git a/action_setvar.go b/action_setvar.go index 1c559c7..a2f851c 100644 --- a/action_setvar.go +++ b/action_setvar.go @@ -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"])) diff --git a/actions.go b/actions.go index 3c8f268..f5410cc 100644 --- a/actions.go +++ b/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 diff --git a/actorDocs.go b/actorDocs.go new file mode 100644 index 0000000..d5f0550 --- /dev/null +++ b/actorDocs.go @@ -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 +} diff --git a/actorDocs.tpl b/actorDocs.tpl new file mode 100644 index 0000000..c5e8d0c --- /dev/null +++ b/actorDocs.tpl @@ -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 }}{{ end }} diff --git a/automessage.go b/automessage.go index 7634c9b..62c1b67 100644 --- a/automessage.go +++ b/automessage.go @@ -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 } diff --git a/botEditor.go b/botEditor.go new file mode 100644 index 0000000..2aa7498 --- /dev/null +++ b/botEditor.go @@ -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) + }) +} diff --git a/ci/bundle.sh b/ci/bundle.sh new file mode 100644 index 0000000..54aeafc --- /dev/null +++ b/ci/bundle.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euxo pipefail + +outfile=${1:-} +[[ -n $outfile ]] || { + echo "Missing parameters: $0 [libraries]" + exit 1 +} +shift + +libs=("$@") + +IFS=$',' +exec curl -sSfLo "${outfile}" "https://cdn.jsdelivr.net/combine/${libs[*]}" diff --git a/config.go b/config.go index 00afdde..e3f0ae2 100644 --- a/config.go +++ b/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 +} diff --git a/configEditor.go b/configEditor.go new file mode 100644 index 0000000..052675c --- /dev/null +++ b/configEditor.go @@ -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 + } + + if err := json.NewEncoder(w).Encode(usr); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }, + Method: http.MethodGet, + Module: "config-editor", + Name: "Get user information", + Path: "/user", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "The user to query the information for", + Name: "user", + Required: true, + Type: "string", + }, + }, + RequiresEditorsAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeJSON, + }, + { + Description: "Subscribe for configuration changes", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer conn.Close() + + var ( + configReloadNotify = make(chan struct{}, 1) + pingTimer = time.NewTicker(websocketPingInterval) + unsubscribe = registerConfigReloadHook(func() { configReloadNotify <- struct{}{} }) + ) + defer unsubscribe() + + type socketMsg struct { + MsgType string `json:"msg_type"` + } + + for { + select { + case <-configReloadNotify: + if err := conn.WriteJSON(socketMsg{ + MsgType: "configReload", + }); err != nil { + log.WithError(err).Debug("Unable to send websocket notification") + return + } + + case <-pingTimer.C: + if err := conn.WriteJSON(socketMsg{ + MsgType: "ping", + }); err != nil { + log.WithError(err).Debug("Unable to send websocket ping") + return + } + + } + } + }, + Method: http.MethodGet, + Module: "config-editor", + Name: "Websocket: Subscribe config changes", + Path: "/notify-config", + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + }, + { + Description: "Validate a cron expression and return the next executions", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + sched, err := cronParser.Parse(r.FormValue("cron")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var ( + lt = time.Now() + out []time.Time + ) + + if id := r.FormValue("uuid"); id != "" { + for _, a := range config.AutoMessages { + if a.ID() != id { + continue + } + lt = a.lastMessageSent + break + } + } + + for i := 0; i < 3; i++ { + lt = sched.Next(lt) + out = append(out, lt) + } + + if err := json.NewEncoder(w).Encode(out); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }, + Method: http.MethodPut, + Module: "config-editor", + Name: "Validate cron expression", + Path: "/validate-cron", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "The cron expression to test", + Name: "cron", + Required: true, + Type: "string", + }, + { + Description: "Check cron with last execution of auto-message", + Name: "uuid", + Required: false, + Type: "string", + }, + }, + ResponseType: plugins.HTTPRouteResponseTypeJSON, + }, + { + Description: "Validate a regular expression against the RE2 regex parser", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + if _, err := regexp.Compile(r.FormValue("regexp")); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) + }, + Method: http.MethodPut, + Module: "config-editor", + Name: "Validate regular expression", + Path: "/validate-regex", + QueryParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "The regular expression to test", + Name: "regexp", + Required: true, + Type: "string", + }, + }, + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + }, + } { + if err := registerRoute(rd); err != nil { + log.WithError(err).Fatal("Unable to register config editor route") + } + } +} + +//nolint:funlen // This is a logic unit and shall not be split up +func registerEditorRulesRoutes() { + for _, rd := range []plugins.HTTPRouteRegistrationArgs{ + { + Description: "Returns the current set of configured rules in JSON format", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(config.Rules); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }, + Method: http.MethodGet, + Module: "config-editor", + Name: "Get current rules", + Path: "/rules", + RequiresEditorsAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeJSON, + }, + { + Description: "Adds a new Rule", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + msg := &plugins.Rule{} + 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.Rules = append(c.Rules, 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 Rule", + Path: "/rules", + RequiresEditorsAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + }, + { + Description: "Deletes the given Rule", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + if err := patchConfig(cfg.Config, func(c *configFile) error { + var ( + id = mux.Vars(r)["uuid"] + tmp []*plugins.Rule + ) + + for i := range c.Rules { + if c.Rules[i].MatcherID() == id { + continue + } + tmp = append(tmp, c.Rules[i]) + } + + c.Rules = 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 Rule", + Path: "/rules/{uuid}", + RequiresEditorsAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "UUID of the rule to delete", + Name: "uuid", + Required: false, + Type: "string", + }, + }, + }, + { + Description: "Updates the given Rule", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + msg := &plugins.Rule{} + 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.Rules { + if c.Rules[i].MatcherID() == id { + c.Rules[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 Rule", + Path: "/rules/{uuid}", + RequiresEditorsAuth: true, + ResponseType: plugins.HTTPRouteResponseTypeTextPlain, + RouteParams: []plugins.HTTPRouteParamDocumentation{ + { + Description: "UUID of the rule to update", + Name: "uuid", + Required: false, + Type: "string", + }, + }, + }, + } { + if err := registerRoute(rd); err != nil { + log.WithError(err).Fatal("Unable to register config editor route") + } + } +} diff --git a/cors.go b/cors.go new file mode 100644 index 0000000..fae1577 --- /dev/null +++ b/cors.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + "strings" +) + +func corsMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow the client to send us credentials + w.Header().Set("Access-Control-Allow-Credentials", "true") + + // We only care about certain headers, so whitelist them + w.Header().Set("Access-Control-Allow-Headers", strings.Join([]string{ + "Accept", + "Authorization", + "Content-Type", + "User-Agent", + }, ", ")) + + // List all accepted methods no matter whether they are accepted by the specified endpoint + w.Header().Set("Access-Control-Allow-Methods", strings.Join([]string{ + http.MethodDelete, + http.MethodGet, + http.MethodPost, + http.MethodPut, + }, ", ")) + + // Public API: Let everyone in + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + + h.ServeHTTP(w, r) + }) +} diff --git a/default_config.yaml b/default_config.yaml new file mode 100644 index 0000000..35ca453 --- /dev/null +++ b/default_config.yaml @@ -0,0 +1,29 @@ +--- + +# This must be the config version you've used below. Current version +# is version 2 so probably keep it at 2 until the bot tells you to +# upgrade. +config_version: 2 + +# List of strings: Either Twitch user-ids or nicknames (best to stick +# with IDs as they can't change while nicknames can be changed every +# 60 days). Those users are able to use the config editor web-interface. +bot_editors: [] + +# List of channels to join. Channels not listed here will not be +# joined and therefore cannot be actioned on. +channels: [] + +# IP/Port to start the web-interface on. Format: IP:Port +# The default is 127.0.0.1:0 - Listen on localhost with random port +http_listen: "127.0.0.1:0" + +# List of auto-messages. See documentation for details or use +# web-interface to configure. +auto_messages: [] + +# List of rules. See documentation for details or use web-interface +# to configure. +rules: [] + +... diff --git a/editor/.eslintrc.js b/editor/.eslintrc.js new file mode 100644 index 0000000..c2d2368 --- /dev/null +++ b/editor/.eslintrc.js @@ -0,0 +1,151 @@ +/* + * Hack to automatically load globally installed eslint modules + * on Archlinux systems placed in /usr/lib/node_modules + * + * Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633 + */ + +const Module = require('module') + +const hacks = [ + 'babel-eslint', + 'eslint-plugin-vue', +] + +const ModuleFindPath = Module._findPath +Module._findPath = (request, paths, isMain) => { + const r = ModuleFindPath(request, paths, isMain) + if (!r && hacks.includes(request)) { + return require.resolve(`/usr/lib/node_modules/${request}`) + } + return r +} + +/* + * ESLint configuration derived as differences from eslint:recommended + * with changes I found useful to ensure code quality and equal formatting + * https://eslint.org/docs/user-guide/configuring + */ + +module.exports = { + env: { + browser: true, + node: true, + }, + + extends: [ + 'plugin:vue/recommended', + 'eslint:recommended', // https://eslint.org/docs/rules/ + ], + + globals: { + process: true, + }, + + parserOptions: { + ecmaVersion: 2020, + parser: 'babel-eslint', + }, + + plugins: [ + // required to lint *.vue files + 'vue', + ], + + reportUnusedDisableDirectives: true, + + root: true, + + rules: { + 'array-bracket-newline': ['error', { multiline: true }], + 'array-bracket-spacing': ['error'], + 'arrow-body-style': ['error', 'as-needed'], + 'arrow-parens': ['error', 'as-needed'], + 'arrow-spacing': ['error', { after: true, before: true }], + 'block-spacing': ['error'], + 'brace-style': ['error', '1tbs'], + 'camelcase': ['error'], + 'comma-dangle': ['error', 'always-multiline'], + 'comma-spacing': ['error'], + 'comma-style': ['error', 'last'], + 'curly': ['error'], + 'default-case-last': ['error'], + 'default-param-last': ['error'], + 'dot-location': ['error', 'property'], + 'dot-notation': ['error'], + 'eol-last': ['error', 'always'], + 'eqeqeq': ['error', 'always', { null: 'ignore' }], + 'func-call-spacing': ['error', 'never'], + 'function-paren-newline': ['error', 'multiline'], + 'generator-star-spacing': ['off'], // allow async-await + 'implicit-arrow-linebreak': ['error'], + 'indent': ['error', 2], + 'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }], + 'keyword-spacing': ['error'], + 'linebreak-style': ['error', 'unix'], + 'lines-between-class-members': ['error'], + 'multiline-comment-style': ['warn'], + 'newline-per-chained-call': ['error'], + 'no-alert': ['error'], + 'no-console': ['off'], + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development + 'no-duplicate-imports': ['error'], + 'no-else-return': ['error'], + 'no-empty-function': ['error'], + 'no-extra-parens': ['error'], + 'no-implicit-coercion': ['error'], + 'no-lonely-if': ['error'], + 'no-multi-spaces': ['error'], + 'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }], + 'no-promise-executor-return': ['error'], + 'no-return-assign': ['error'], + 'no-script-url': ['error'], + 'no-template-curly-in-string': ['error'], + 'no-trailing-spaces': ['error'], + 'no-unneeded-ternary': ['error'], + 'no-unreachable-loop': ['error'], + 'no-unsafe-optional-chaining': ['error'], + 'no-useless-return': ['error'], + 'no-var': ['error'], + 'no-warning-comments': ['error'], + 'no-whitespace-before-property': ['error'], + 'object-curly-newline': ['error', { consistent: true }], + 'object-curly-spacing': ['error', 'always'], + 'object-shorthand': ['error'], + 'padded-blocks': ['error', 'never'], + 'prefer-arrow-callback': ['error'], + 'prefer-const': ['error'], + 'prefer-object-spread': ['error'], + 'prefer-rest-params': ['error'], + 'prefer-template': ['error'], + 'quote-props': ['error', 'consistent-as-needed', { keywords: false }], + 'quotes': ['error', 'single', { allowTemplateLiterals: true }], + 'require-atomic-updates': ['error'], + 'require-await': ['error'], + 'semi': ['error', 'never'], + 'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }], + 'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }], + 'space-before-blocks': ['error', 'always'], + 'space-before-function-paren': ['error', 'never'], + 'space-in-parens': ['error', 'never'], + 'space-infix-ops': ['error'], + 'space-unary-ops': ['error', { nonwords: false, words: true }], + 'spaced-comment': ['warn', 'always'], + 'switch-colon-spacing': ['error'], + 'template-curly-spacing': ['error', 'never'], + 'unicode-bom': ['error', 'never'], + 'vue/new-line-between-multi-line-property': ['error'], + 'vue/no-empty-component-block': ['error'], + 'vue/no-reserved-component-names': ['error'], + 'vue/no-template-target-blank': ['error'], + 'vue/no-unused-properties': ['error'], + 'vue/no-unused-refs': ['error'], + 'vue/no-useless-mustaches': ['error'], + 'vue/order-in-components': ['off'], // Collides with sort-keys + 'vue/require-name-property': ['error'], + 'vue/v-for-delimiter-style': ['error'], + 'vue/v-on-function-call': ['error'], + 'wrap-iife': ['error'], + 'yoda': ['error'], + }, +} diff --git a/editor/app.js b/editor/app.js new file mode 100644 index 0000000..e0e3d4e --- /dev/null +++ b/editor/app.js @@ -0,0 +1,766 @@ +/* global axios, Vue */ + +/* eslint-disable camelcase --- We are working with data from a Go JSON API */ + +const CRON_VALIDATION = /^(?:(?:@every (?:\d+(?:s|m|h))+)|(?:(?:(?:(?:\d+,)+\d+|(?:\d+(?:\/|-)\d+)|\d+|\*|\*\/\d+)(?: |$)){5}))$/ +const NANO = 1000000000 + +Vue.config.devtools = true +new Vue({ + computed: { + authURL() { + const scopes = [] + + const params = new URLSearchParams() + params.set('client_id', this.vars.TwitchClientID) + params.set('redirect_uri', window.location.href.split('#')[0]) + params.set('response_type', 'token') + params.set('scope', scopes.join(' ')) + + return `https://id.twitch.tv/oauth2/authorize?${params.toString()}` + }, + + availableActionsForAdd() { + return this.actions.map(a => ({ text: a.name, value: a.type })) + }, + + availableEvents() { + return [ + { text: 'Clear Event-Matching', value: null }, + ...this.vars.KnownEvents, + ] + }, + + axiosOptions() { + return { + headers: { + authorization: this.authToken, + }, + } + }, + + countRuleConditions() { + let count = 0 + count += this.models.rule.disable ? 1 : 0 + count += this.models.rule.disable_on_offline ? 1 : 0 + count += this.models.rule.disable_on_permit ? 1 : 0 + count += this.models.rule.disable_on ? 1 : 0 + count += this.models.rule.enable_on ? 1 : 0 + count += this.models.rule.disable_on_template ? 1 : 0 + return count + }, + + countRuleCooldowns() { + let count = 0 + count += this.models.rule.cooldown ? 1 : 0 + count += this.models.rule.channel_cooldown ? 1 : 0 + count += this.models.rule.user_cooldown ? 1 : 0 + count += this.models.rule.skip_cooldown_for ? 1 : 0 + return count + }, + + countRuleMatchers() { + let count = 0 + count += this.models.rule.match_channels ? 1 : 0 + count += this.models.rule.match_event ? 1 : 0 + count += this.models.rule.match_message ? 1 : 0 + count += this.models.rule.match_users ? 1 : 0 + return count + }, + + sortedChannels() { + return this.generalConfig?.channels + ?.sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase())) + }, + + sortedEditors() { + return this.generalConfig?.bot_editors + ?.sort((a, b) => { + const an = this.userProfiles[a]?.login || a + const bn = this.userProfiles[b]?.login || b + + return an.localeCompare(bn) + }) + }, + + validateAutoMessage() { + if (!this.models.autoMessage.sendMode) { + return false + } + + if (this.models.autoMessage.sendMode === 'cron' && !this.validateAutoMessageCron) { + return false + } + + if (this.models.autoMessage.sendMode === 'lines' && (!this.models.autoMessage.message_interval || Number(this.models.autoMessage.message_interval) <= 0)) { + return false + } + + if (this.validateAutoMessageMessageLength < this.models.autoMessage.message?.length) { + return false + } + + if (!this.validateAutoMessageChannel) { + return false + } + + return true + }, + + validateAutoMessageChannel() { + return Boolean(this.models.autoMessage.channel?.match(/^[a-zA-Z0-9_]{4,25}$/)) + }, + + validateAutoMessageCron() { + if (this.models.autoMessage.sendMode !== 'cron' && !this.models.autoMessage.cron) { + return true + } + + return Boolean(this.models.autoMessage.cron?.match(CRON_VALIDATION)) + }, + + validateAutoMessageMessageLength() { + return this.models.autoMessage.use_action ? 496 : 500 + }, + + + validateRule() { + if (!this.models.rule.match_message__validation) { + this.validateReason = 'rule.match_message__validation' + return false + } + + if (!this.validateDuration(this.models.rule.cooldown, false)) { + this.validateReason = 'rule.cooldown' + return false + } + + if (!this.validateDuration(this.models.rule.user_cooldown, false)) { + this.validateReason = 'rule.user_cooldown' + return false + } + + if (!this.validateDuration(this.models.rule.channel_cooldown, false)) { + this.validateReason = 'rule.channel_cooldown' + return false + } + + for (const action of this.models.rule.actions || []) { + const def = this.getActionDefinitionByType(action.type) + if (!def) { + this.validateReason = `nodef: ${action.type}` + return false + } + + if (!def.fields) { + // No fields to check + continue + } + + for (const field of def.fields) { + if (!field.optional && !action.attributes[field.key]) { + this.validateReason = `${action.type} -> ${field.key} -> opt` + return false + } + } + } + + return true + }, + }, + + data: { + actions: [], + authToken: null, + autoMessageFields: [ + { + class: 'col-1 text-nowrap', + key: 'channel', + sortable: true, + thClass: 'align-middle', + }, + { + class: 'col-9', + key: 'message', + sortable: true, + thClass: 'align-middle', + }, + { + class: 'col-1 text-nowrap', + key: 'cron', + thClass: 'align-middle', + }, + { + class: 'col-1 text-right', + key: 'actions', + label: '', + thClass: 'align-middle', + }, + ], + + autoMessageSendModes: [ + { text: 'Cron', value: 'cron' }, + { text: 'Number of lines', value: 'lines' }, + ], + + autoMessages: [], + + changePending: false, + configNotifySocket: null, + configNotifySocketConnected: false, + configNotifyBackoff: 100, + editMode: 'general', + error: null, + generalConfig: {}, + models: { + addAction: '', + addChannel: '', + addEditor: '', + autoMessage: {}, + rule: {}, + }, + + rules: [], + rulesFields: [ + { + class: 'col-3', + key: '_match', + label: 'Match', + thClass: 'align-middle', + }, + { + class: 'col-8', + key: '_description', + label: 'Description', + thClass: 'align-middle', + }, + { + class: 'col-1 text-right', + key: '_actions', + label: '', + thClass: 'align-middle', + }, + ], + + showAutoMessageEditModal: false, + showRuleEditModal: false, + userProfiles: {}, + validateReason: null, + vars: {}, + }, + + el: '#app', + + methods: { + actionHasValidationError(idx) { + const action = this.models.rule.actions[idx] + const def = this.getActionDefinitionByType(action.type) + + for (const field of def.fields || []) { + if (!this.validateActionArgument(idx, field.key)) { + return true + } + } + + return false + }, + + addAction() { + const attributes = {} + + for (const field of this.getActionDefinitionByType(this.models.addAction).fields || []) { + let defaultValue = null + + switch (field.type) { + case 'bool': + defaultValue = field.default === 'true' + break + case 'int64': + defaultValue = field.default ? Number(field.default) : 0 + break + case 'string': + defaultValue = field.default + break + case 'stringslice': + defaultValue = [] + break + } + + attributes[field.key] = defaultValue + } + + if (!this.models.rule.actions) { + Vue.set(this.models.rule, 'actions', []) + } + + this.models.rule.actions.push({ attributes, type: this.models.addAction }) + }, + + addChannel() { + this.generalConfig.channels.push(this.models.addChannel.replace(/^#*/, '')) + this.models.addChannel = '' + + this.updateGeneralConfig() + }, + + addEditor() { + this.fetchProfile(this.models.addEditor) + this.generalConfig.bot_editors.push(this.models.addEditor) + this.models.addEditor = '' + + this.updateGeneralConfig() + }, + + deleteAutoMessage(uuid) { + axios.delete(`config-editor/auto-messages/${uuid}`, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + }, + + deleteRule(uuid) { + axios.delete(`config-editor/rules/${uuid}`, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + }, + + editAutoMessage(msg) { + Vue.set(this.models, 'autoMessage', { + ...msg, + sendMode: msg.cron ? 'cron' : 'lines', + }) + this.showAutoMessageEditModal = true + }, + + editRule(msg) { + Vue.set(this.models, 'rule', { + ...msg, + cooldown: this.fixDurationRepresentationToString(msg.cooldown), + channel_cooldown: this.fixDurationRepresentationToString(msg.channel_cooldown), + user_cooldown: this.fixDurationRepresentationToString(msg.user_cooldown), + }) + this.showRuleEditModal = true + this.validateMatcherRegex() + }, + + fetchActions() { + return axios.get('config-editor/actions') + .then(resp => { + this.actions = resp.data + }) + .catch(err => this.handleFetchError(err)) + }, + + fetchAutoMessages() { + return axios.get('config-editor/auto-messages', this.axiosOptions) + .then(resp => { + this.autoMessages = resp.data + }) + .catch(err => this.handleFetchError(err)) + }, + + fetchGeneralConfig() { + return axios.get('config-editor/general', this.axiosOptions) + .catch(err => this.handleFetchError(err)) + .then(resp => { + this.generalConfig = resp.data + + const promises = [] + for (const editor of this.generalConfig.bot_editors) { + promises.push(this.fetchProfile(editor)) + } + + return Promise.all(promises) + }) + }, + + fetchProfile(user) { + return axios.get(`config-editor/user?user=${user}`, this.axiosOptions) + .then(resp => Vue.set(this.userProfiles, user, resp.data)) + .catch(err => this.handleFetchError(err)) + }, + + fetchRules() { + return axios.get('config-editor/rules', this.axiosOptions) + .then(resp => { + this.rules = resp.data + }) + .catch(err => this.handleFetchError(err)) + }, + + fetchVars() { + return axios.get('editor/vars.json') + .then(resp => { + this.vars = resp.data + }) + }, + + fixDurationRepresentationToInt64(value) { + switch (typeof value) { + case 'string': + const match = value.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/) + return ((Number(match[1]) || 0) * 3600 + (Number(match[2]) || 0) * 60 + (Number(match[3]) || 0)) * NANO + + default: + return value + } + }, + + fixDurationRepresentationToString(value) { + switch (typeof value) { + case 'number': + value /= NANO + let repr = '' + + if (value >= 3600) { + const h = Math.floor(value / 3600) + repr += `${h}h` + value -= h * 3600 + } + + if (value >= 60) { + const m = Math.floor(value / 60) + repr += `${m}m` + value -= m * 60 + } + + if (value > 0) { + repr += `${value}s` + } + + return repr + + default: + return value + } + }, + + formatRuleActions(rule) { + const badges = [] + + for (const action of rule.actions || []) { + for (const actionDefinition of this.actions) { + if (actionDefinition.type !== action.type) { + continue + } + + badges.push(actionDefinition.name) + } + } + + return badges + }, + + formatRuleMatch(rule) { + const badges = [] + + if (rule.match_channels) { + badges.push({ key: 'Channels', value: rule.match_channels.join(', ') }) + } + + if (rule.match_event) { + badges.push({ key: 'Event', value: rule.match_event }) + } + + if (rule.match_message) { + badges.push({ key: 'Message', value: rule.match_message }) + } + + if (rule.match_users) { + badges.push({ key: 'Users', value: rule.match_users.join(', ') }) + } + + return badges + }, + + getActionDefinitionByType(type) { + for (const ad of this.actions) { + if (ad.type === type) { + return ad + } + } + + return null + }, + + handleFetchError(err) { + switch (err.response.status) { + case 403: + this.authToken = null + this.error = 'This user is not authorized for the config editor' + break + case 502: + this.error = 'Looks like the bot is currently not reachable. Please check it is running and refresh the interface.' + break + default: + this.error = `Something went wrong: ${err.response.data} (${err.response.status})` + } + }, + + moveAction(idx, direction) { + const tmp = [...this.models.rule.actions] + + const eltmp = tmp[idx] + tmp[idx] = tmp[idx + direction] + tmp[idx + direction] = eltmp + + Vue.set(this.models.rule, 'actions', tmp) + }, + + newAutoMessage() { + Vue.set(this.models, 'autoMessage', {}) + this.showAutoMessageEditModal = true + }, + + newRule() { + Vue.set(this.models, 'rule', { match_message__validation: true }) + this.showRuleEditModal = true + }, + + openConfigNotifySocket() { + if (this.configNotifySocket) { + this.configNotifySocket.close() + this.configNotifySocket = null + } + + const updateBackoffAndReconnect = () => { + this.configNotifyBackoff = Math.min(this.configNotifyBackoff * 1.5, 10000) + window.setTimeout(() => this.openConfigNotifySocket(), this.configNotifyBackoff) + } + + this.configNotifySocket = new WebSocket(`${window.location.href.replace(/^http/, 'ws')}config-editor/notify-config`) + this.configNotifySocket.onopen = () => { + console.debug('[notify] Socket connected') + this.configNotifySocketConnected = true + } + this.configNotifySocket.onmessage = evt => { + const msg = JSON.parse(evt.data) + + console.debug(`[notify] Socket message received type=${msg.msg_type}`) + this.configNotifyBackoff = 100 // We've received a message, reset backoff + + if (msg.msg_type === 'configReload') { + this.reload() + } + } + this.configNotifySocket.onclose = evt => { + console.debug(`[notify] Socket was closed wasClean=${evt.wasClean}`) + this.configNotifySocketConnected = false + updateBackoffAndReconnect() + } + }, + + reload() { + return Promise.all([ + this.fetchAutoMessages(), + this.fetchGeneralConfig(), + this.fetchRules(), + ]).then(() => { + this.changePending = false + }) + }, + + removeAction(idx) { + this.models.rule.actions = this.models.rule.actions.filter((_, i) => i !== idx) + }, + + removeChannel(channel) { + this.generalConfig.channels = this.generalConfig.channels + .filter(ch => ch !== channel) + + this.updateGeneralConfig() + }, + + removeEditor(editor) { + this.generalConfig.bot_editors = this.generalConfig.bot_editors + .filter(ed => ed !== editor) + + this.updateGeneralConfig() + }, + + saveAutoMessage(evt) { + if (!this.validateAutoMessage) { + evt.preventDefault() + } + + const obj = { ...this.models.autoMessage } + + if (this.models.autoMessage.sendMode === 'cron') { + delete obj.message_interval + } else if (this.models.autoMessage.sendMode === 'lines') { + delete obj.cron + } + + if (obj.uuid) { + axios.put(`config-editor/auto-messages/${obj.uuid}`, obj, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + } else { + axios.post(`config-editor/auto-messages`, obj, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + } + }, + + saveRule(evt) { + if (!this.validateRule) { + evt.preventDefault() + } + + const obj = { ...this.models.rule } + + if (obj.cooldown) { + obj.cooldown = this.fixDurationRepresentationToInt64(obj.cooldown) + } + + if (obj.channel_cooldown) { + obj.channel_cooldown = this.fixDurationRepresentationToInt64(obj.channel_cooldown) + } + + if (obj.user_cooldown) { + obj.user_cooldown = this.fixDurationRepresentationToInt64(obj.user_cooldown) + } + + if (obj.uuid) { + axios.put(`config-editor/rules/${obj.uuid}`, obj, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + } else { + axios.post(`config-editor/rules`, obj, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + } + }, + + updateGeneralConfig() { + axios.put('config-editor/general', this.generalConfig, this.axiosOptions) + .then(() => { + this.changePending = true + }) + .catch(err => this.handleFetchError(err)) + }, + + validateActionArgument(idx, key) { + const action = this.models.rule.actions[idx] + const def = this.getActionDefinitionByType(action.type) + + if (!def || !def.fields) { + return false + } + + for (const field of def.fields) { + if (field.key !== key) { + continue + } + + switch (field.type) { + case 'bool': + if (!field.optional && !action.attributes[field.key]) { + return false + } + break + + case 'duration': + if (!this.validateDuration(action.attributes[field.key], !field.optional)) { + return false + } + break + + case 'int64': + if (!field.optional && !action.attributes[field.key]) { + return false + } + + if (action.attributes[field.key] && Number(action.attributes[field.key]) === NaN) { + return false + } + + break + + case 'string': + if (!field.optional && !action.attributes[field.key]) { + return false + } + break + + case 'stringslice': + if (!field.optional && !action.attributes[field.key]) { + return false + } + break + } + break + } + + return true + }, + + validateDuration(duration, required) { + if (!duration && !required) { + return true + } + + if (!duration && required) { + return false + } + + return Boolean(duration.match(/^(?:\d+(?:s|m|h))+$/)) + }, + + validateMatcherRegex() { + if (this.models.rule.match_message === '') { + Vue.set(this.models.rule, 'match_message__validation', true) + return + } + + return axios.put(`config-editor/validate-regex?regexp=${encodeURIComponent(this.models.rule.match_message)}`) + .then(() => { + Vue.set(this.models.rule, 'match_message__validation', true) + }) + .catch(() => { + Vue.set(this.models.rule, 'match_message__validation', false) + }) + }, + + validateTwitchBadge(tag) { + return this.vars.IRCBadges.includes(tag) + }, + }, + + mounted() { + this.fetchVars() + this.fetchActions() + + const params = new URLSearchParams(window.location.hash.substring(1)) + this.authToken = params.get('access_token') || null + + if (this.authToken) { + window.history.replaceState(null, '', window.location.href.split('#')[0]) + this.openConfigNotifySocket() + this.reload() + } + }, + + name: 'ConfigEditor', + + watch: { + 'models.rule.match_message'(to, from) { + if (to === from) { + return + } + + this.validateMatcherRegex() + }, + }, +}) diff --git a/editor/index.html b/editor/index.html new file mode 100644 index 0000000..0663bd5 --- /dev/null +++ b/editor/index.html @@ -0,0 +1,843 @@ + + + Twitch-Bot: Config-Editor + + + + + +
+ + Twitch-Bot + + + + + + + + General + + + + Auto-Messages + {{ autoMessages.length }} + + + + Rules + {{ rules.length }} + + + + + + + + + + + + + + + + + + + + + + + {{ error }} + + + + + + + + + + Your change was submitted and is pending, please wait for config to be updated! + + + + + + + + + + Login with Twitch + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + Send message as action (/me) + + + +
+ + + + + + + + +
+ @every [time] or Cron syntax +
+
+ + + + + + + +
+ + + + Send only when channel is live + + + + +
+ Template expression resulting in true to disable the rule or false to enable it +
+ +
+ +
+ + +
Getting Help
+

+ For information about available template functions and variables to use in the Message see the Templating section of the Wiki. +

+

+ For information about the Cron syntax have a look at the cron.help site. Aditionally you can use @every [time] syntax. The [time] part is in format 1h30m20s. You can leave out every segment but need to specify the unit of every segment. So for example @every 1h or @every 10m would be a valid specification. +

+
+
+ +
+ + + + + + + + + + +
+ + + +
+ Matcher {{ countRuleMatchers }} +
+ + + + + + + + + + + + + + + + + +
+ +
+ Cooldown {{ countRuleCooldowns }} +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ Conditions {{ countRuleConditions }} +
+ +

Disable rule…

+ + + + + completely + + + + + + + when channel is offline + + + + + + + when user has permit + + + + + + + + + + + + + + +
+ Template expression resulting in true to disable the rule or false to enable it +
+ +
+ +
+
+ +
+ + + +
+ + + + + {{ getActionDefinitionByType(action.type).name }} + + + + + + + + + + + + + This action has no attributes. + + + + +
+ + + + + + Add + + + +
+ + + + + + + +
+ + + + diff --git a/events.go b/events.go index 973c7f9..e7cf7f9 100644 --- a/events.go +++ b/events.go @@ -17,4 +17,21 @@ var ( eventTypeTwitchStreamOffline = ptrStr("stream_offline") eventTypeTwitchStreamOnline = ptrStr("stream_online") eventTypeTwitchTitleUpdate = ptrStr("title_update") + + knownEvents = []*string{ + eventTypeJoin, + eventTypeHost, + eventTypePart, + eventTypePermit, + eventTypeRaid, + eventTypeResub, + eventTypeSub, + eventTypeSubgift, + eventTypeWhisper, + + eventTypeTwitchCategoryUpdate, + eventTypeTwitchStreamOffline, + eventTypeTwitchStreamOnline, + eventTypeTwitchTitleUpdate, + } ) diff --git a/go.mod b/go.mod index be98cc9..e9a3b84 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,14 @@ require ( github.com/Luzifer/korvike/functions v0.6.1 github.com/Luzifer/rconfig/v2 v2.3.0 github.com/go-irc/irc v2.1.0+incompatible + github.com/gofrs/uuid/v3 v3.1.2 github.com/gorilla/mux v1.7.4 + github.com/gorilla/websocket v1.4.2 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/pkg/errors v0.9.1 github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.8.1 + github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb gopkg.in/yaml.v2 v2.4.0 ) @@ -32,7 +35,6 @@ require ( github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect diff --git a/go.sum b/go.sum index 83bf57d..9983601 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY= +github.com/gofrs/uuid/v3 v3.1.2/go.mod h1:xPwMqoocQ1L5G6pXX5BcE7N5jlzn2o19oqAKxwZW/kI= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= @@ -108,6 +110,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..cd7b5b7 --- /dev/null +++ b/helpers.go @@ -0,0 +1,7 @@ +package main + +var ( + ptrBoolFalse = func(v bool) *bool { return &v }(false) + ptrIntZero = func(v int64) *int64 { return &v }(0) + ptrStringEmpty = func(v string) *string { return &v }("") +) diff --git a/internal/actors/ban/actor.go b/internal/actors/ban/actor.go index 930bb50..d3cbdfc 100644 --- a/internal/actors/ban/actor.go +++ b/internal/actors/ban/actor.go @@ -1,26 +1,51 @@ package ban import ( - "fmt" + "strings" "github.com/Luzifer/twitch-bot/plugins" "github.com/go-irc/irc" "github.com/pkg/errors" ) +const actorName = "ban" + func Register(args plugins.RegistrationArguments) error { - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Ban user from chat", + Name: "Ban User", + Type: "ban", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Reason why the user was banned", + Key: "reason", + Name: "Reason", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) return nil } -type actor struct { - Ban *string `json:"ban" yaml:"ban"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.Ban == nil { - return false, nil +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + ptrStringEmpty := func(v string) *string { return &v }("") + + cmd := []string{ + "/ban", + plugins.DeriveUser(m, eventData), + } + + if reason := attrs.MustString("reason", ptrStringEmpty); reason != "" { + cmd = append(cmd, reason) } return false, errors.Wrap( @@ -28,12 +53,14 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData Command: "PRIVMSG", Params: []string{ plugins.DeriveChannel(m, eventData), - fmt.Sprintf("/ban %s %s", plugins.DeriveUser(m, eventData), *a.Ban), + strings.Join(cmd, " "), }, }), - "sending timeout", + "sending ban", ) } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "ban" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil } diff --git a/internal/actors/delay/actor.go b/internal/actors/delay/actor.go index bcd19f2..9726681 100644 --- a/internal/actors/delay/actor.go +++ b/internal/actors/delay/actor.go @@ -8,25 +8,57 @@ import ( "github.com/go-irc/irc" ) +const actorName = "delay" + func Register(args plugins.RegistrationArguments) error { - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Delay next action", + Name: "Delay", + Type: "delay", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Static delay to wait", + Key: "delay", + Name: "Delay", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeDuration, + }, + { + Default: "", + Description: "Dynamic jitter to add to the static delay (the added extra delay will be between 0 and this value)", + Key: "jitter", + Name: "Jitter", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeDuration, + }, + }, + }) return nil } -type actor struct { - Delay time.Duration `json:"delay" yaml:"delay"` - DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.Delay == 0 && a.DelayJitter == 0 { +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + var ( + ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0) + delay = attrs.MustDuration("delay", ptrZeroDuration) + jitter = attrs.MustDuration("jitter", ptrZeroDuration) + ) + + if delay == 0 && jitter == 0 { return false, nil } - totalDelay := a.Delay - if a.DelayJitter > 0 { - totalDelay += time.Duration(rand.Int63n(int64(a.DelayJitter))) // #nosec: G404 // It's just time, no need for crypto/rand + totalDelay := delay + if jitter > 0 { + totalDelay += time.Duration(rand.Int63n(int64(jitter))) // #nosec: G404 // It's just time, no need for crypto/rand } time.Sleep(totalDelay) @@ -34,4 +66,6 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "delay" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil } diff --git a/internal/actors/delete/actor.go b/internal/actors/delete/actor.go index 230d6f9..cd17003 100644 --- a/internal/actors/delete/actor.go +++ b/internal/actors/delete/actor.go @@ -8,21 +8,23 @@ import ( "github.com/pkg/errors" ) +const actorName = "delete" + func Register(args plugins.RegistrationArguments) error { - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Delete message which caused the rule to be executed", + Name: "Delete Message", + Type: "delete", + }) return nil } -type actor struct { - DeleteMessage *bool `json:"delete_message" yaml:"delete_message"` -} - -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.DeleteMessage == nil || !*a.DeleteMessage || m == nil { - return false, nil - } +type actor struct{} +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { msgID, ok := m.Tags.GetTag("id") if !ok || msgID == "" { return false, nil @@ -41,4 +43,6 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "delete" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil } diff --git a/internal/actors/modchannel/actor.go b/internal/actors/modchannel/actor.go index 001e861..f929741 100644 --- a/internal/actors/modchannel/actor.go +++ b/internal/actors/modchannel/actor.go @@ -10,6 +10,8 @@ import ( "github.com/pkg/errors" ) +const actorName = "modchannel" + var ( formatMessage plugins.MsgFormatter twitchClient *twitch.Client @@ -19,52 +21,98 @@ func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage twitchClient = args.GetTwitchClient() - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Update stream information", + Name: "Modify Stream", + Type: "modchannel", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Channel to update", + Key: "channel", + Name: "Channel", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Category / Game to set", + Key: "game", + Name: "Game", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Stream title to set", + Key: "title", + Name: "Title", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) return nil } -type actor struct { - Channel string `json:"channel" yaml:"channel"` - UpdateGame *string `json:"update_game" yaml:"update_game"` - UpdateTitle *string `json:"update_title" yaml:"update_title"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.UpdateGame == nil && a.UpdateTitle == nil { +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + var ( + ptrStringEmpty = func(v string) *string { return &v }("") + game = attrs.MustString("game", ptrStringEmpty) + title = attrs.MustString("title", ptrStringEmpty) + ) + + if game == "" && title == "" { return false, nil } - var game, title *string + var updGame, updTitle *string - channel, err := formatMessage(a.Channel, m, r, eventData) + channel, err := formatMessage(attrs.MustString("channel", nil), m, r, eventData) if err != nil { return false, errors.Wrap(err, "parsing channel") } - if a.UpdateGame != nil { - parsedGame, err := formatMessage(*a.UpdateGame, m, r, eventData) + if game != "" { + parsedGame, err := formatMessage(game, m, r, eventData) if err != nil { return false, errors.Wrap(err, "parsing game") } - game = &parsedGame + updGame = &parsedGame } - if a.UpdateTitle != nil { - parsedTitle, err := formatMessage(*a.UpdateTitle, m, r, eventData) + if title != "" { + parsedTitle, err := formatMessage(title, m, r, eventData) if err != nil { return false, errors.Wrap(err, "parsing title") } - title = &parsedTitle + updTitle = &parsedTitle } return false, errors.Wrap( - twitchClient.ModifyChannelInformation(context.Background(), strings.TrimLeft(channel, "#"), game, title), + twitchClient.ModifyChannelInformation(context.Background(), strings.TrimLeft(channel, "#"), updGame, updTitle), "updating channel info", ) } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "modchannel" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("channel"); err != nil || v == "" { + return errors.New("channel must be non-empty string") + } + + return nil +} diff --git a/internal/actors/raw/actor.go b/internal/actors/raw/actor.go index f6dbd44..0d80e20 100644 --- a/internal/actors/raw/actor.go +++ b/internal/actors/raw/actor.go @@ -6,26 +6,40 @@ import ( "github.com/pkg/errors" ) +const actorName = "raw" + var formatMessage plugins.MsgFormatter func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Send raw IRC message", + Name: "Send RAW Message", + Type: "raw", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Raw message to send (must be a valid IRC protocol message)", + Key: "message", + Name: "Message", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) return nil } -type actor struct { - RawMessage *string `json:"raw_message" yaml:"raw_message"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.RawMessage == nil { - return false, nil - } - - rawMsg, err := formatMessage(*a.RawMessage, m, r, eventData) +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData) if err != nil { return false, errors.Wrap(err, "preparing raw message") } @@ -42,4 +56,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "raw" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("message"); err != nil || v == "" { + return errors.New("message must be non-empty string") + } + + return nil +} diff --git a/internal/actors/respond/actor.go b/internal/actors/respond/actor.go index 7b9f799..d019c3f 100644 --- a/internal/actors/respond/actor.go +++ b/internal/actors/respond/actor.go @@ -9,41 +9,84 @@ import ( "github.com/pkg/errors" ) -var formatMessage plugins.MsgFormatter +const actorName = "respond" + +var ( + formatMessage plugins.MsgFormatter + + ptrBoolFalse = func(v bool) *bool { return &v }(false) +) func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Respond to message with a new message", + Name: "Respond to Message", + Type: "respond", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Message text to send", + Key: "message", + Long: true, + Name: "Message", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Fallback message text to send if message cannot be generated", + Key: "fallback", + Name: "Fallback", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "false", + Description: "Send message as a native Twitch-reply to the original message", + Key: "as_reply", + Name: "As Reply", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeBool, + }, + { + Default: "", + Description: "Send message to a different channel than the original message", + Key: "to_channel", + Name: "To Channel", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) return nil } -type actor struct { - Respond *string `json:"respond" yaml:"respond"` - RespondAsReply *bool `json:"respond_as_reply" yaml:"respond_as_reply"` - RespondFallback *string `json:"respond_fallback" yaml:"respond_fallback"` - ToChannel *string `json:"to_channel" yaml:"to_channel"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.Respond == nil { - return false, nil - } - - msg, err := formatMessage(*a.Respond, m, r, eventData) +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData) if err != nil { - if a.RespondFallback == nil { + if attrs.CanString("fallback") { return false, errors.Wrap(err, "preparing response") } - if msg, err = formatMessage(*a.RespondFallback, m, r, eventData); err != nil { + if msg, err = formatMessage(attrs.MustString("fallback", nil), m, r, eventData); err != nil { return false, errors.Wrap(err, "preparing response fallback") } } toChannel := plugins.DeriveChannel(m, eventData) - if a.ToChannel != nil { - toChannel = fmt.Sprintf("#%s", strings.TrimLeft(*a.ToChannel, "#")) + if attrs.CanString("to_channel") && attrs.MustString("to_channel", nil) != "" { + toChannel = fmt.Sprintf("#%s", strings.TrimLeft(attrs.MustString("to_channel", nil), "#")) } ircMessage := &irc.Message{ @@ -54,7 +97,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData }, } - if a.RespondAsReply != nil && *a.RespondAsReply && m != nil { + if attrs.MustBool("as_reply", ptrBoolFalse) { id, ok := m.GetTag("id") if ok { if ircMessage.Tags == nil { @@ -71,4 +114,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "respond" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("message"); err != nil || v == "" { + return errors.New("message must be non-empty string") + } + + return nil +} diff --git a/internal/actors/timeout/actor.go b/internal/actors/timeout/actor.go index 0e8757d..495820c 100644 --- a/internal/actors/timeout/actor.go +++ b/internal/actors/timeout/actor.go @@ -9,27 +9,41 @@ import ( "github.com/pkg/errors" ) +const actorName = "timeout" + func Register(args plugins.RegistrationArguments) error { - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Timeout user from chat", + Name: "Timeout User", + Type: "timeout", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Duration of the timeout", + Key: "duration", + Name: "Duration", + Optional: false, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeDuration, + }, + }, + }) return nil } -type actor struct { - Timeout *time.Duration `json:"timeout" yaml:"timeout"` -} - -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.Timeout == nil { - return false, nil - } +type actor struct{} +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { return false, errors.Wrap( c.WriteMessage(&irc.Message{ Command: "PRIVMSG", Params: []string{ plugins.DeriveChannel(m, eventData), - fmt.Sprintf("/timeout %s %d", plugins.DeriveUser(m, eventData), fixDurationValue(*a.Timeout)/time.Second), + fmt.Sprintf("/timeout %s %d", plugins.DeriveUser(m, eventData), attrs.MustDuration("duration", nil)/time.Second), }, }), "sending timeout", @@ -37,12 +51,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "timeout" } +func (a actor) Name() string { return actorName } -func fixDurationValue(d time.Duration) time.Duration { - if d >= time.Second { - return d +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.Duration("duration"); err != nil || v < time.Second { + return errors.New("duration must be of type duration greater or equal one second") } - return d * time.Second + return nil } diff --git a/internal/actors/whisper/actor.go b/internal/actors/whisper/actor.go index 43ffa08..d4ef1ad 100644 --- a/internal/actors/whisper/actor.go +++ b/internal/actors/whisper/actor.go @@ -8,32 +8,54 @@ import ( "github.com/pkg/errors" ) +const actorName = "whisper" + var formatMessage plugins.MsgFormatter func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage - args.RegisterActor(func() plugins.Actor { return &actor{} }) + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Send a whisper (requires a verified bot!)", + Name: "Send Whisper", + Type: "whisper", + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Message to whisper to the user", + Key: "message", + Name: "Message", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "User to send the message to", + Key: "to", + Name: "To User", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) return nil } -type actor struct { - WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"` - WhisperTo *string `json:"whisper_to" yaml:"whisper_to"` -} +type actor struct{} -func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) { - if a.WhisperTo == nil || a.WhisperMessage == nil { - return false, nil - } - - to, err := formatMessage(*a.WhisperTo, m, r, eventData) +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData) if err != nil { return false, errors.Wrap(err, "preparing whisper receiver") } - msg, err := formatMessage(*a.WhisperMessage, m, r, eventData) + msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData) if err != nil { return false, errors.Wrap(err, "preparing whisper message") } @@ -53,4 +75,16 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } func (a actor) IsAsync() bool { return false } -func (a actor) Name() string { return "whisper" } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("to"); err != nil || v == "" { + return errors.New("to must be non-empty string") + } + + if v, err := attrs.String("message"); err != nil || v == "" { + return errors.New("message must be non-empty string") + } + + return nil +} diff --git a/main.go b/main.go index e35e49e..d19b6b2 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + "net" "net/http" "os" "strings" @@ -86,7 +88,8 @@ func main() { twitchWatch := newTwitchWatcher() cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache - router.HandleFunc("/", handleSwaggerHTML) + router.Use(corsMiddleware) + router.HandleFunc("/openapi.html", handleSwaggerHTML) router.HandleFunc("/openapi.json", handleSwaggerRequest) if err = initCorePlugins(); err != nil { @@ -97,7 +100,27 @@ func main() { log.WithError(err).Fatal("Unable to load plugins") } + if len(rconfig.Args()) == 2 && rconfig.Args()[1] == "actor-docs" { + doc, err := generateActorDocs() + if err != nil { + log.WithError(err).Fatal("Unable to generate actor docs") + } + if _, err = os.Stdout.Write(append(bytes.TrimSpace(doc), '\n')); err != nil { + log.WithError(err).Fatal("Unable to write actor docs to stdout") + } + return + } + if err = loadConfig(cfg.Config); err != nil { + if os.IsNotExist(errors.Cause(err)) { + if err = writeDefaultConfigFile(cfg.Config); err != nil { + log.WithError(err).Fatal("Initial config not found and not able to create example config") + } + + log.WithField("filename", cfg.Config).Warn("No config was found, created example config: Please review that config!") + return + } + log.WithError(err).Fatal("Initial config load failed") } defer func() { config.CloseRawMessageWriter() }() @@ -134,7 +157,13 @@ func main() { if config.HTTPListen != "" { // If listen address is configured start HTTP server - go http.ListenAndServe(config.HTTPListen, router) + listener, err := net.Listen("tcp", config.HTTPListen) + if err != nil { + log.WithError(err).Fatal("Unable to open http_listen port") + } + + go http.Serve(listener, router) + log.WithField("address", listener.Addr().String()).Info("HTTP server started") } ircDisconnected <- struct{}{} diff --git a/plugins/action_docs.go b/plugins/action_docs.go new file mode 100644 index 0000000..bd88bf8 --- /dev/null +++ b/plugins/action_docs.go @@ -0,0 +1,32 @@ +package plugins + +type ( + ActionDocumentation struct { + Description string `json:"description"` + Name string `json:"name"` + Type string `json:"type"` + + Fields []ActionDocumentationField `json:"fields"` + } + + ActionDocumentationField struct { + Default string `json:"default"` + Description string `json:"description"` + Key string `json:"key"` + Long bool `json:"long"` + Name string `json:"name"` + Optional bool `json:"optional"` + SupportTemplate bool `json:"support_template"` + Type ActionDocumentationFieldType `json:"type"` + } + + ActionDocumentationFieldType string +) + +const ( + ActionDocumentationFieldTypeBool ActionDocumentationFieldType = "bool" + ActionDocumentationFieldTypeDuration ActionDocumentationFieldType = "duration" + ActionDocumentationFieldTypeInt64 ActionDocumentationFieldType = "int64" + ActionDocumentationFieldTypeString ActionDocumentationFieldType = "string" + ActionDocumentationFieldTypeStringSlice ActionDocumentationFieldType = "stringslice" +) diff --git a/plugins/fieldcollection.go b/plugins/fieldcollection.go index 643ba8e..853663f 100644 --- a/plugins/fieldcollection.go +++ b/plugins/fieldcollection.go @@ -16,6 +16,26 @@ var ( type FieldCollection map[string]interface{} +func (f FieldCollection) CanBool(name string) bool { + _, err := f.Bool(name) + return err == nil +} + +func (f FieldCollection) CanDuration(name string) bool { + _, err := f.Duration(name) + return err == nil +} + +func (f FieldCollection) CanInt64(name string) bool { + _, err := f.Int64(name) + return err == nil +} + +func (f FieldCollection) CanString(name string) bool { + _, err := f.String(name) + return err == nil +} + func (f FieldCollection) Expect(keys ...string) error { var missing []string @@ -32,6 +52,16 @@ func (f FieldCollection) Expect(keys ...string) error { return nil } +func (f FieldCollection) HasAll(keys ...string) bool { + for _, k := range keys { + if _, ok := f[k]; !ok { + return false + } + } + + return true +} + func (f FieldCollection) MustBool(name string, defVal *bool) bool { v, err := f.Bool(name) if err != nil { diff --git a/plugins/http_api.go b/plugins/http_api.go index 5836a8c..92f761f 100644 --- a/plugins/http_api.go +++ b/plugins/http_api.go @@ -11,17 +11,18 @@ type ( } HTTPRouteRegistrationArgs struct { - Description string - HandlerFunc http.HandlerFunc - IsPrefix bool - Method string - Module string - Name string - Path string - QueryParams []HTTPRouteParamDocumentation - ResponseType HTTPRouteResponseType - RouteParams []HTTPRouteParamDocumentation - SkipDocumentation bool + Description string + HandlerFunc http.HandlerFunc + IsPrefix bool + Method string + Module string + Name string + Path string + QueryParams []HTTPRouteParamDocumentation + RequiresEditorsAuth bool + ResponseType HTTPRouteResponseType + RouteParams []HTTPRouteParamDocumentation + SkipDocumentation bool } HTTPRouteResponseType uint64 diff --git a/plugins/interface.go b/plugins/interface.go index 8cda6ed..694ae35 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -10,7 +10,7 @@ import ( type ( Actor interface { // Execute will be called after the config was read into the Actor - Execute(*irc.Client, *irc.Message, *Rule, FieldCollection) (preventCooldown bool, err error) + Execute(c *irc.Client, m *irc.Message, r *Rule, evtData FieldCollection, attrs FieldCollection) (preventCooldown bool, err error) // IsAsync may return true if the Execute function is to be executed // in a Go routine as of long runtime. Normally it should return false // except in very specific cases @@ -18,11 +18,17 @@ type ( // Name must return an unique name for the actor in order to identify // it in the logs for debugging purposes Name() string + // Validate will be called to validate the loaded configuration. It should + // return an error if required keys are missing from the AttributeStore + // or if keys contain broken configs + Validate(FieldCollection) error } ActorCreationFunc func() Actor - ActorRegistrationFunc func(ActorCreationFunc) + ActorRegistrationFunc func(name string, acf ActorCreationFunc) + + ActorDocumentationRegistrationFunc func(ActionDocumentation) CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error) @@ -45,6 +51,8 @@ type ( GetTwitchClient func() *twitch.Client // RegisterActor is used to register a new IRC rule-actor implementing the Actor interface RegisterActor ActorRegistrationFunc + // RegisterActorDocumentation is used to register an ActorDocumentation for the config editor + RegisterActorDocumentation ActorDocumentationRegistrationFunc // RegisterAPIRoute registers a new HTTP handler function including documentation RegisterAPIRoute HTTPRouteRegistrationFunc // RegisterCron is a method to register cron functions in the global cron instance diff --git a/plugins/rule.go b/plugins/rule.go index 5799887..f04d3da 100644 --- a/plugins/rule.go +++ b/plugins/rule.go @@ -14,37 +14,45 @@ import ( log "github.com/sirupsen/logrus" ) -type Rule struct { - UUID string `hash:"-" yaml:"uuid"` +type ( + Rule struct { + UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` - Actions []*RuleAction `yaml:"actions"` + Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"` - Cooldown *time.Duration `yaml:"cooldown"` - ChannelCooldown *time.Duration `yaml:"channel_cooldown"` - UserCooldown *time.Duration `yaml:"user_cooldown"` - SkipCooldownFor []string `yaml:"skip_cooldown_for"` + Cooldown *time.Duration `json:"cooldown,omitempty" yaml:"cooldown,omitempty"` + ChannelCooldown *time.Duration `json:"channel_cooldown,omitempty" yaml:"channel_cooldown,omitempty"` + UserCooldown *time.Duration `json:"user_cooldown,omitempty" yaml:"user_cooldown,omitempty"` + SkipCooldownFor []string `json:"skip_cooldown_for,omitempty" yaml:"skip_cooldown_for,omitempty"` - MatchChannels []string `yaml:"match_channels"` - MatchEvent *string `yaml:"match_event"` - MatchMessage *string `yaml:"match_message"` - MatchUsers []string `yaml:"match_users" ` + MatchChannels []string `json:"match_channels,omitempty" yaml:"match_channels,omitempty"` + MatchEvent *string `json:"match_event,omitempty" yaml:"match_event,omitempty"` + MatchMessage *string `json:"match_message,omitempty" yaml:"match_message,omitempty"` + MatchUsers []string `json:"match_users,omitempty" yaml:"match_users,omitempty" ` - DisableOnMatchMessages []string `yaml:"disable_on_match_messages"` + DisableOnMatchMessages []string `json:"disable_on_match_messages,omitempty" yaml:"disable_on_match_messages,omitempty"` - Disable *bool `yaml:"disable"` - DisableOnOffline *bool `yaml:"disable_on_offline"` - DisableOnPermit *bool `yaml:"disable_on_permit"` - DisableOnTemplate *string `yaml:"disable_on_template"` - DisableOn []string `yaml:"disable_on"` - EnableOn []string `yaml:"enable_on"` + Disable *bool `json:"disable,omitempty" yaml:"disable,omitempty"` + DisableOnOffline *bool `json:"disable_on_offline,omitempty" yaml:"disable_on_offline,omitempty"` + DisableOnPermit *bool `json:"disable_on_permit,omitempty" yaml:"disable_on_permit,omitempty"` + DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"` + DisableOn []string `json:"disable_on,omitempty" yaml:"disable_on,omitempty"` + EnableOn []string `json:"enable_on,omitempty" yaml:"enable_on,omitempty"` - matchMessage *regexp.Regexp - disableOnMatchMessages []*regexp.Regexp + matchMessage *regexp.Regexp + disableOnMatchMessages []*regexp.Regexp - msgFormatter MsgFormatter - timerStore TimerStore - twitchClient *twitch.Client -} + msgFormatter MsgFormatter + timerStore TimerStore + twitchClient *twitch.Client + } + + RuleAction struct { + Type string `json:"type" yaml:"type,omitempty"` + Attributes FieldCollection `json:"attributes" yaml:"attributes,omitempty"` + } +) func (r Rule) MatcherID() string { if r.UUID != "" { @@ -225,7 +233,7 @@ func (r *Rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, ev } func (r *Rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData FieldCollection) bool { - if r.DisableOnTemplate == nil { + if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" { // No match criteria set, does not speak against matching return true } @@ -246,7 +254,7 @@ func (r *Rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, } func (r *Rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData FieldCollection) bool { - if r.MatchEvent == nil { + if r.MatchEvent == nil || *r.MatchEvent == "" { // No match criteria set, does not speak against matching return true } diff --git a/plugins/ruleAction.go b/plugins/ruleAction.go deleted file mode 100644 index 1a9e52a..0000000 --- a/plugins/ruleAction.go +++ /dev/null @@ -1,38 +0,0 @@ -package plugins - -import ( - "bytes" - "encoding/json" - - "github.com/pkg/errors" -) - -type RuleAction struct { - yamlUnmarshal func(interface{}) error - jsonValue []byte -} - -func (r *RuleAction) UnmarshalJSON(d []byte) error { - r.jsonValue = d - return nil -} - -func (r *RuleAction) UnmarshalYAML(unmarshal func(interface{}) error) error { - r.yamlUnmarshal = unmarshal - return nil -} - -func (r *RuleAction) Unmarshal(v interface{}) error { - switch { - case r.yamlUnmarshal != nil: - return r.yamlUnmarshal(v) - - case r.jsonValue != nil: - jd := json.NewDecoder(bytes.NewReader(r.jsonValue)) - jd.DisallowUnknownFields() - return jd.Decode(v) - - default: - return errors.New("unmarshal on unprimed object") - } -} diff --git a/swagger.go b/swagger.go index fdf9f08..98bf764 100644 --- a/swagger.go +++ b/swagger.go @@ -31,6 +31,9 @@ var ( "inputErrorResponse": spec.TextPlainResponse(nil).WithDescription("Data sent to API is invalid: See error message"), "notFoundResponse": spec.TextPlainResponse(nil).WithDescription("Document was not found or insufficient permissions"), }, + SecuritySchemes: map[string]*spec.SecurityScheme{ + "authenticated": spec.APIKeyAuth("Authorization", spec.InHeader), + }, }, } @@ -87,6 +90,12 @@ func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error { }, } + if route.RequiresEditorsAuth { + op.Security = []map[string]spec.SecurityRequirement{ + {"authenticated": {}}, + } + } + switch route.ResponseType { case plugins.HTTPRouteResponseTypeJSON: op.Responses["200"] = spec.JSONResponse(nil).WithDescription("Successful execution with JSON object response") @@ -119,9 +128,7 @@ func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error { specParam := spec.QueryParam(param.Name, ps). WithDescription(param.Description) - if !param.Required { - specParam = specParam.AsOptional() - } + specParam.Required = param.Required op.Parameters = append( op.Parameters, diff --git a/twitch/badges.go b/twitch/badges.go index b1e20f7..3304362 100644 --- a/twitch/badges.go +++ b/twitch/badges.go @@ -12,8 +12,17 @@ const ( BadgeFounder = "founder" BadgeModerator = "moderator" BadgeSubscriber = "subscriber" + BadgeVIP = "vip" ) +var KnownBadges = []string{ + BadgeBroadcaster, + BadgeFounder, + BadgeModerator, + BadgeSubscriber, + BadgeVIP, +} + type BadgeCollection map[string]*int func ParseBadgeLevels(m *irc.Message) BadgeCollection { diff --git a/twitch/twitch.go b/twitch/twitch.go index 1142117..7660ec0 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strconv" "time" "github.com/Luzifer/go_helpers/v2/backoff" @@ -37,6 +38,13 @@ type ( apiCache *APICache } + + User struct { + DisplayName string `json:"display_name"` + ID string `json:"id"` + Login string `json:"login"` + ProfileImageURL string `json:"profile_image_url"` + } ) func New(clientID, token string) *Client { @@ -52,10 +60,7 @@ func (c Client) APICache() *APICache { return c.apiCache } func (c Client) GetAuthorizedUsername() (string, error) { var payload struct { - Data []struct { - ID string `json:"id"` - Login string `json:"login"` - } `json:"data"` + Data []User `json:"data"` } if err := c.request( @@ -82,11 +87,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) { } var payload struct { - Data []struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - Login string `json:"login"` - } `json:"data"` + Data []User `json:"data"` } if err := c.request( @@ -150,6 +151,46 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) { return payload.Data[0].FollowedAt, nil } +func (c Client) GetUserInformation(user string) (*User, error) { + var ( + out User + param = "login" + payload struct { + Data []User `json:"data"` + } + ) + + cacheKey := []string{"userInformation", user} + if d := c.apiCache.Get(cacheKey); d != nil { + out = d.(User) + return &out, nil + } + + if _, err := strconv.ParseInt(user, 10, 64); err == nil { + param = "id" + } + + if err := c.request( + context.Background(), + http.MethodGet, + fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user), + nil, + &payload, + ); err != nil { + return nil, errors.Wrap(err, "request user info") + } + + if l := len(payload.Data); l != 1 { + return nil, errors.Errorf("unexpected number of records returned: %d", l) + } + + // Follow date will not change that often, cache for a long time + c.apiCache.Set(cacheKey, timeDay, payload.Data[0]) + out = payload.Data[0] + + return &out, nil +} + func (c Client) SearchCategories(ctx context.Context, name string) ([]Category, error) { var out []Category @@ -219,10 +260,7 @@ func (c Client) GetIDForUsername(username string) (string, error) { } var payload struct { - Data []struct { - ID string `json:"id"` - Login string `json:"login"` - } `json:"data"` + Data []User `json:"data"` } if err := c.request( diff --git a/wiki/Actors.md b/wiki/Actors.md new file mode 100644 index 0000000..ab971eb --- /dev/null +++ b/wiki/Actors.md @@ -0,0 +1,189 @@ +# Available Actions + + +## Ban User + +Ban user from chat + +```yaml +- type: ban + attributes: + # Reason why the user was banned + # Optional: true + # Type: string + reason: "" +``` + +## Delay + +Delay next action + +```yaml +- type: delay + attributes: + # Static delay to wait + # Optional: true + # Type: duration + delay: 0s + # Dynamic jitter to add to the static delay (the added extra delay will be between 0 and this value) + # Optional: true + # Type: duration + jitter: 0s +``` + +## Delete Message + +Delete message which caused the rule to be executed + +```yaml +- type: delete + # Does not have configuration attributes +``` + +## Execute Script / Command + +Execute external script / command + +```yaml +- type: script + attributes: + # Command to execute + # Optional: false + # Type: array of strings (Supports Templating in each string) + command: [] + # Do not activate cooldown for route when command exits non-zero + # Optional: true + # Type: bool + skip_cooldown_on_error: false +``` + +## Modify Counter + +Update counter values + +```yaml +- type: counter + attributes: + # Name of the counter to update + # Optional: false + # Type: string (Supports Templating) + counter: "" + # Value to add to the counter + # Optional: true + # Type: int64 + counter_step: 1 + # Value to set the counter to + # Optional: true + # Type: string (Supports Templating) + counter_set: "" +``` + +## Modify Stream + +Update stream information + +```yaml +- type: modchannel + attributes: + # Channel to update + # Optional: false + # Type: string (Supports Templating) + channel: "" + # Category / Game to set + # Optional: true + # Type: string (Supports Templating) + game: "" + # Stream title to set + # Optional: true + # Type: string (Supports Templating) + title: "" +``` + +## Modify Variable + +Modify variable contents + +```yaml +- type: setvariable + attributes: + # Name of the variable to update + # Optional: false + # Type: string (Supports Templating) + variable: "" + # Clear variable content and unset the variable + # Optional: true + # Type: bool + clear: false + # Value to set the variable to + # Optional: true + # Type: string (Supports Templating) + set: "" +``` + +## Respond to Message + +Respond to message with a new message + +```yaml +- type: respond + attributes: + # Message text to send + # Optional: false + # Type: string (Supports Templating) + message: "" + # Fallback message text to send if message cannot be generated + # Optional: true + # Type: string (Supports Templating) + fallback: "" + # Send message as a native Twitch-reply to the original message + # Optional: true + # Type: bool + as_reply: false + # Send message to a different channel than the original message + # Optional: true + # Type: string + to_channel: "" +``` + +## Send RAW Message + +Send raw IRC message + +```yaml +- type: raw + attributes: + # Raw message to send (must be a valid IRC protocol message) + # Optional: false + # Type: string (Supports Templating) + message: "" +``` + +## Send Whisper + +Send a whisper (requires a verified bot!) + +```yaml +- type: whisper + attributes: + # Message to whisper to the user + # Optional: false + # Type: string (Supports Templating) + message: "" + # User to send the message to + # Optional: false + # Type: string (Supports Templating) + to: "" +``` + +## Timeout User + +Timeout user from chat + +```yaml +- type: timeout + attributes: + # Duration of the timeout + # Optional: false + # Type: duration + duration: 0s +``` diff --git a/wiki/Home.md b/wiki/Home.md index 085bd45..853f42c 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -3,7 +3,18 @@ ```yaml --- -# Channels to join (only those can be acted on) +# This must be the config version you've used below. Current version +# is version 2 so probably keep it at 2 until the bot tells you to +# upgrade. +config_version: 2 + +# List of strings: Either Twitch user-ids or nicknames (best to stick +# with IDs as they can't change while nicknames can be changed every +# 60 days). Those users are able to use the config editor web-interface. +bot_editors: [] + +# List of channels to join. Channels not listed here will not be +# joined and therefore cannot be actioned on. channels: - mychannel @@ -23,6 +34,8 @@ variables: myvariable: true anothervariable: "string" +# List of auto-messages. See documentation for details or use +# web-interface to configure. auto_messages: - channel: 'mychannel' # String, channel to send message to message: 'Automated message' # String, message to send @@ -31,66 +44,23 @@ auto_messages: # Even though all of these are optional, at least one MUST be specified for the entry to be valid cron: '*/10 * * * *' # String, optional, cron syntax when to send the message message_interval: 3 # Integer, optional, how many non-bot-messages must be sent in between - time_interval: 900s # Duration, optional, how long to wait before repeating the message only_on_live: true # Boolean, optional, only send the message when channel is live # Disable message using templating, must yield string `true` to disable the automated message disable_on_template: '{{ ne .myvariable true }}' +# List of rules. See documentation for details or use web-interface +# to configure. rules: # See below for examples - actions: # Array of actions to take when this rule matches - # Issue a ban on the user who wrote the chat-line - - ban: "reason of ban" - - # Command to execute for the chat message, must return an JSON encoded array of actions - - command: [/bin/bash, -c, "echo '[{\"respond\": \"Text\"}]'"] - skip_cooldown_on_error: true # Boolean, optional, if set to true a non-zero exit-code - # will prevent the cooldown to be started for the rule - - # Modify an internal counter value (does NOT send a chat line) - - counter: "counterid" # String to identify the counter, applies templating - counter_set: 25 # String, set counter to value (counter_step is ignored if set), - # applies templating but MUST result in a parseable integer - counter_step: 1 # Integer, can be negative or positive, default: +1 - - # Introduce a delay between two actions - - delay: 1m # Duration, how long to wait (fixed) - delay_jitter: 1m # Duration, add random delay to fixed delay between 0 and this value - - # Issue a delete on the message caught - - delete_message: true # Bool, set to true to delete - - # Send raw IRC message to Twitch servers - - raw_message: 'PRIVMSG #{{ .channel }} :Test' # String, applies templating - - # Send responding message to the channel the original message was received in - - respond: 'Hello chatter' # String, applies templating - respond_as_reply: true # Boolean, optional, use Twitch-Reply feature in respond - respond_fallback: 'Oh noes' # String, text to send if the template function causes - # an error, applies templating (default: unset) - to_channel: '#myotherchan' # String, channel to send the response to (default: unset) - - # Issue a timeout on the user who wrote the chat-line - - timeout: 1s # Duration value: 1s / 1m / 1h - - # Update channel information (one of `update_game` and `update_title` must be defined) - - channel: '{{ .channel }}' # String, applies templating - update_game: 'Just Chatting' # String, optional, set game to given category - update_title: 'My special title' # String, optional, set title to given title - - # Set a variable to value defined for later usage - - variable: myvar # String, name of the variable to set (applies templating) - clear: false # Boolean, clear the variable - set: '{{ .channel }}' # String, value to set the variable to (applies templating) - - # Send a whisper (ATTENTION: You need to have a known / verified bot for this!) - # Without being known / verified your whisper will just silently get dropped by Twitch - # Go here to get that verification: https://dev.twitch.tv/limit-increase - - whisper_to: '{{ .username }}' # String, username to send to, applies templating - whisper_message: 'Ohai!' # String, message to send, applies templating + # See the Actors page in the Wiki for available actors: + # https://github.com/Luzifer/twitch-bot/wiki/Actors + - type: "" + attributes: + key: value # Add a cooldown to the rule in general (not to trigger counters twice, ...) # Using this will prevent the rule to be executed in all matching channels