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

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

1
.gitignore vendored
View File

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

View File

@ -3,16 +3,19 @@
---
run:
skip-dirs:
- config
skip-files:
- assets.go
- bindata.go
# timeout for analysis, e.g. 30s, 5m, default is 1m
timeout: 5m
# Force readonly modules usage for checking
modules-download-mode: readonly
output:
format: tab
linters-settings:
forbidigo:
forbid:
- 'fmt\.Errorf' # Should use github.com/pkg/errors
funlen:
lines: 100
statements: 60
@ -21,6 +24,11 @@ linters-settings:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 15
gomnd:
settings:
mnd:
ignored-functions: 'strconv.(?:Format|Parse)\B+'
linters:
disable-all: true
enable:
@ -30,6 +38,7 @@ linters:
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false]
- exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false]
- forbidigo # Forbids identifiers [fast: true, auto-fix: false]
- funlen # Tool for detection of long functions [fast: true, auto-fix: false]
- gocognit # Computes and checks the cognitive complexity of functions [fast: true, auto-fix: false]
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
@ -37,6 +46,7 @@ linters:
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
- godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false]
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
- gofumpt # Gofumpt checks whether code was gofumpt-ed. [fast: true, auto-fix: true]
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
- gomnd # An analyzer to detect magic numbers. [fast: true, auto-fix: false]
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]

View File

@ -6,7 +6,12 @@ WORKDIR /go/src/github.com/Luzifer/twitch-bot
ENV CGO_ENABLED=0
RUN set -ex \
&& apk add --update git \
&& apk add --update \
bash \
curl \
git \
make \
&& make frontend \
&& go install \
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
-mod=readonly

View File

@ -3,15 +3,36 @@ default: lint test
lint:
golangci-lint run --timeout=5m
publish:
publish: frontend
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
bash golang.sh
test:
go test -cover -v ./...
# --- Editor frontend
frontend: editor/bundle.css
frontend: editor/bundle.js
editor/bundle.js:
bash ci/bundle.sh $@ \
npm/axios@0.21.4/dist/axios.min.js \
npm/vue@2 \
npm/bootstrap-vue@2/dist/bootstrap-vue.min.js \
npm/moment@2
editor/bundle.css:
bash ci/bundle.sh $@ \
npm/bootstrap@4/dist/css/bootstrap.min.css \
npm/bootstrap-vue@2/dist/bootstrap-vue.min.css \
npm/bootswatch@4/dist/darkly/bootstrap.min.css
# --- Wiki Updates
actor_docs:
go run . actor-docs >wiki/Actors.md
pull_wiki:
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"net/http"
"github.com/Luzifer/twitch-bot/internal/actors/ban"
"github.com/Luzifer/twitch-bot/internal/actors/delay"
@ -43,12 +44,17 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
PathPrefix(fmt.Sprintf("/%s/", route.Module)).
Subrouter()
var hdl http.Handler = route.HandlerFunc
if route.RequiresEditorsAuth {
hdl = botEditorAuthMiddleware(hdl)
}
if route.IsPrefix {
r.PathPrefix(route.Path).
HandlerFunc(route.HandlerFunc).
Handler(hdl).
Methods(route.Method)
} else {
r.HandleFunc(route.Path, route.HandlerFunc).
r.Handle(route.Path, hdl).
Methods(route.Method)
}
@ -61,14 +67,15 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
func getRegistrationArguments() plugins.RegistrationArguments {
return plugins.RegistrationArguments{
FormatMessage: formatMessage,
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
GetTwitchClient: func() *twitch.Client { return twitchClient },
RegisterActor: registerAction,
RegisterAPIRoute: registerRoute,
RegisterCron: cronService.AddFunc,
RegisterRawMessageHandler: registerRawMessageHandler,
RegisterTemplateFunction: tplFuncs.Register,
SendMessage: sendMessage,
FormatMessage: formatMessage,
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
GetTwitchClient: func() *twitch.Client { return twitchClient },
RegisterActor: registerAction,
RegisterActorDocumentation: registerActorDocumentation,
RegisterAPIRoute: registerRoute,
RegisterCron: cronService.AddFunc,
RegisterRawMessageHandler: registerRawMessageHandler,
RegisterTemplateFunction: tplFuncs.Register,
SendMessage: sendMessage,
}
}

View File

@ -12,7 +12,43 @@ import (
)
func init() {
registerAction(func() plugins.Actor { return &ActorCounter{} })
registerAction("counter", func() plugins.Actor { return &ActorCounter{} })
registerActorDocumentation(plugins.ActionDocumentation{
Description: "Update counter values",
Name: "Modify Counter",
Type: "counter",
Fields: []plugins.ActionDocumentationField{
{
Default: "",
Description: "Name of the counter to update",
Key: "counter",
Name: "Counter",
Optional: false,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
{
Default: "1",
Description: "Value to add to the counter",
Key: "counter_step",
Name: "Counter Step",
Optional: true,
SupportTemplate: false,
Type: plugins.ActionDocumentationFieldTypeInt64,
},
{
Default: "",
Description: "Value to set the counter to",
Key: "counter_set",
Name: "Counter Set",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
},
})
registerRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Returns the (formatted) value as a plain string",
@ -68,29 +104,21 @@ func init() {
})
}
type ActorCounter struct {
CounterSet *string `json:"counter_set" yaml:"counter_set"`
CounterStep *int64 `json:"counter_step" yaml:"counter_step"`
Counter *string `json:"counter" yaml:"counter"`
}
type ActorCounter struct{}
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
if a.Counter == nil {
return false, nil
}
counterName, err := formatMessage(*a.Counter, m, r, eventData)
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "preparing response")
}
if a.CounterSet != nil {
parseValue, err := formatMessage(*a.CounterSet, m, r, eventData)
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" {
parseValue, err := formatMessage(counterSet, m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "execute counter value template")
}
counterValue, err := strconv.ParseInt(parseValue, 10, 64) //nolint:gomnd // Those numbers are static enough
counterValue, err := strconv.ParseInt(parseValue, 10, 64)
if err != nil {
return false, errors.Wrap(err, "parse counter value")
}
@ -102,8 +130,8 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
}
var counterStep int64 = 1
if a.CounterStep != nil {
counterStep = *a.CounterStep
if s := attrs.MustInt64("counter_step", ptrIntZero); s != 0 {
counterStep = s
}
return false, errors.Wrap(
@ -115,6 +143,14 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
func (a ActorCounter) IsAsync() bool { return false }
func (a ActorCounter) Name() string { return "counter" }
func (a ActorCounter) Validate(attrs plugins.FieldCollection) (err error) {
if cn, err := attrs.String("counter"); err != nil || cn == "" {
return errors.New("counter name must be non-empty string")
}
return nil
}
func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
template := r.FormValue("template")
if template == "" {
@ -132,7 +168,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
value int64
)
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil { //nolint:gomnd // Those numbers are static enough
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil {
http.Error(w, errors.Wrap(err, "parsing value").Error(), http.StatusBadRequest)
return
}

View File

@ -14,27 +14,51 @@ import (
)
func init() {
registerAction(func() plugins.Actor { return &ActorScript{} })
registerAction("script", func() plugins.Actor { return &ActorScript{} })
registerActorDocumentation(plugins.ActionDocumentation{
Description: "Execute external script / command",
Name: "Execute Script / Command",
Type: "script",
Fields: []plugins.ActionDocumentationField{
{
Default: "",
Description: "Command to execute",
Key: "command",
Name: "Command",
Optional: false,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeStringSlice,
},
{
Default: "false",
Description: "Do not activate cooldown for route when command exits non-zero",
Key: "skip_cooldown_on_error",
Name: "Skip Cooldown on Error",
Optional: true,
SupportTemplate: false,
Type: plugins.ActionDocumentationFieldTypeBool,
},
},
})
}
type ActorScript struct {
Command []string `json:"command" yaml:"command"`
SkipCooldownOnError bool `json:"skip_cooldown_on_error" yaml:"skip_cooldown_on_error"`
}
type ActorScript struct{}
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
if len(a.Command) == 0 {
return false, nil
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
command, err := attrs.StringSlice("command")
if err != nil {
return false, errors.Wrap(err, "getting command")
}
var command []string
for _, arg := range a.Command {
tmp, err := formatMessage(arg, m, r, eventData)
for i := range command {
tmp, err := formatMessage(command[i], m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "execute command argument template")
}
command = append(command, tmp)
command[i] = tmp
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout)
@ -67,7 +91,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
cmd.Stdout = stdout
if err := cmd.Run(); err != nil {
return a.SkipCooldownOnError, errors.Wrap(err, "running command")
return attrs.MustBool("skip_cooldown_on_error", ptrBoolFalse), errors.Wrap(err, "running command")
}
if stdout.Len() == 0 {
@ -86,7 +110,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
}
for _, action := range actions {
apc, err := triggerActions(c, m, r, action, eventData)
apc, err := triggerAction(c, m, r, action, eventData)
if err != nil {
return preventCooldown, errors.Wrap(err, "execute returned action")
}
@ -98,3 +122,11 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
func (a ActorScript) IsAsync() bool { return false }
func (a ActorScript) Name() string { return "script" }
func (a ActorScript) Validate(attrs plugins.FieldCollection) (err error) {
if cmd, err := attrs.StringSlice("command"); err != nil || len(cmd) == 0 {
return errors.New("command must be slice of strings with length > 0")
}
return nil
}

View File

@ -11,7 +11,43 @@ import (
)
func init() {
registerAction(func() plugins.Actor { return &ActorSetVariable{} })
registerAction("setvariable", func() plugins.Actor { return &ActorSetVariable{} })
registerActorDocumentation(plugins.ActionDocumentation{
Description: "Modify variable contents",
Name: "Modify Variable",
Type: "setvariable",
Fields: []plugins.ActionDocumentationField{
{
Default: "",
Description: "Name of the variable to update",
Key: "variable",
Name: "Variable",
Optional: false,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
{
Default: "false",
Description: "Clear variable content and unset the variable",
Key: "clear",
Name: "Clear",
Optional: true,
SupportTemplate: false,
Type: plugins.ActionDocumentationFieldTypeBool,
},
{
Default: "",
Description: "Value to set the variable to",
Key: "set",
Name: "Set Content",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
},
})
registerRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Returns the value as a plain string",
@ -53,30 +89,22 @@ func init() {
})
}
type ActorSetVariable struct {
Variable string `json:"variable" yaml:"variable"`
Clear bool `json:"clear" yaml:"clear"`
Set string `json:"set" yaml:"set"`
}
type ActorSetVariable struct{}
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
if a.Variable == "" {
return false, nil
}
varName, err := formatMessage(a.Variable, m, r, eventData)
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "preparing variable name")
}
if a.Clear {
if attrs.MustBool("clear", ptrBoolFalse) {
return false, errors.Wrap(
store.RemoveVariable(varName),
"removing variable",
)
}
value, err := formatMessage(a.Set, m, r, eventData)
value, err := formatMessage(attrs.MustString("set", ptrStringEmpty), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "preparing value")
}
@ -90,6 +118,14 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
func (a ActorSetVariable) IsAsync() bool { return false }
func (a ActorSetVariable) Name() string { return "setvariable" }
func (a ActorSetVariable) Validate(attrs plugins.FieldCollection) (err error) {
if v, err := attrs.String("variable"); err != nil || v == "" {
return errors.New("variable name must be non-empty string")
}
return nil
}
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text-plain")
fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"]))

View File

@ -10,52 +10,58 @@ import (
)
var (
availableActions []plugins.ActorCreationFunc
availableActions = map[string]plugins.ActorCreationFunc{}
availableActionsLock = new(sync.RWMutex)
)
// Compile-time assertion
var _ plugins.ActorRegistrationFunc = registerAction
func registerAction(af plugins.ActorCreationFunc) {
availableActionsLock.Lock()
defer availableActionsLock.Unlock()
availableActions = append(availableActions, af)
}
func triggerActions(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
func getActorByName(name string) (plugins.Actor, error) {
availableActionsLock.RLock()
defer availableActionsLock.RUnlock()
for _, acf := range availableActions {
var (
a = acf()
logger = log.WithField("actor", a.Name())
)
if err := ra.Unmarshal(a); err != nil {
logger.WithError(err).Trace("Unable to unmarshal config")
continue
}
if a.IsAsync() {
go func() {
if _, err := a.Execute(c, m, rule, eventData); err != nil {
logger.WithError(err).Error("Error in async actor")
}
}()
continue
}
apc, err := a.Execute(c, m, rule, eventData)
preventCooldown = preventCooldown || apc
if err != nil {
return preventCooldown, errors.Wrap(err, "execute action")
}
acf, ok := availableActions[name]
if !ok {
return nil, errors.Errorf("undefined actor %q called", name)
}
return preventCooldown, nil
return acf(), nil
}
func registerAction(name string, acf plugins.ActorCreationFunc) {
availableActionsLock.Lock()
defer availableActionsLock.Unlock()
if _, ok := availableActions[name]; ok {
log.WithField("name", name).Fatal("Duplicate registration of actor")
}
availableActions[name] = acf
}
func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
availableActionsLock.RLock()
defer availableActionsLock.RUnlock()
a, err := getActorByName(ra.Type)
if err != nil {
return false, errors.Wrap(err, "getting actor")
}
logger := log.WithField("actor", a.Name())
if a.IsAsync() {
go func() {
if _, err := a.Execute(c, m, rule, eventData, ra.Attributes); err != nil {
logger.WithError(err).Error("Error in async actor")
}
}()
return preventCooldown, nil
}
apc, err := a.Execute(c, m, rule, eventData, ra.Attributes)
return apc, errors.Wrap(err, "execute action")
}
func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugins.FieldCollection) {
@ -63,7 +69,7 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugi
var preventCooldown bool
for _, a := range r.Actions {
apc, err := triggerActions(c, m, r, a, eventData)
apc, err := triggerAction(c, m, r, a, eventData)
if err != nil {
log.WithError(err).Error("Unable to trigger action")
break // Break execution when one action fails

27
actorDocs.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"bytes"
_ "embed"
"text/template"
"github.com/Luzifer/twitch-bot/plugins"
"github.com/pkg/errors"
)
//go:embed actorDocs.tpl
var actorDocsTemplate string
func generateActorDocs() ([]byte, error) {
tpl, err := template.New("actorDocs").Parse(actorDocsTemplate)
if err != nil {
return nil, errors.Wrap(err, "parsing actorDocs template")
}
buf := new(bytes.Buffer)
if err := tpl.Execute(buf, struct{ Actors []plugins.ActionDocumentation }{availableActorDocs}); err != nil {
return nil, errors.Wrap(err, "rendering actorDocs template")
}
return buf.Bytes(), nil
}

42
actorDocs.tpl Normal file
View File

@ -0,0 +1,42 @@
# Available Actions
{{ range .Actors }}
## {{ .Name }}
{{ .Description }}
```yaml
- type: {{ .Type }}
{{- if gt (len .Fields) 0 }}
attributes:
{{- range .Fields }}
# {{ .Description }}
# Optional: {{ .Optional }}
{{- if eq .Type "bool" }}
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
{{ .Key }}: {{ eq .Default "true" }}
{{- end }}
{{- if eq .Type "duration" }}
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
{{ .Key }}: {{ if eq .Default "" }}0s{{ else }}{{ .Default }}{{ end }}
{{- end }}
{{- if eq .Type "int64" }}
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
{{ .Key }}: {{ if eq .Default "" }}0{{ else }}{{ .Default }}{{ end }}
{{- end }}
{{- if eq .Type "string" }}
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
{{ .Key }}: "{{ .Default }}"
{{- end }}
{{- if eq .Type "stringslice" }}
# Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string) {{ end }}
{{ .Key }}: []
{{- end }}
{{- end }}
{{- else }}
# Does not have configuration attributes
{{- end }}
```
{{ end }}
{{ if false }}<!-- vim: set ft=markdown: -->{{ end }}

View File

@ -14,21 +14,20 @@ import (
log "github.com/sirupsen/logrus"
)
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
type autoMessage struct {
UUID string `hash:"-" yaml:"uuid"`
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
Channel string `yaml:"channel"`
Message string `yaml:"message"`
UseAction bool `yaml:"use_action"`
Channel string `json:"channel,omitempty" yaml:"channel,omitempty"`
Message string `json:"message,omitempty" yaml:"message,omitempty"`
UseAction bool `json:"use_action,omitempty" yaml:"use_action,omitempty"`
DisableOnTemplate *string `yaml:"disable_on_template"`
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
Cron string `yaml:"cron"`
MessageInterval int64 `yaml:"message_interval"`
OnlyOnLive bool `yaml:"only_on_live"`
TimeInterval time.Duration `yaml:"time_interval"`
Cron string `json:"cron,omitempty" yaml:"cron,omitempty"`
MessageInterval int64 `json:"message_interval,omitempty" yaml:"message_interval,omitempty"`
OnlyOnLive bool `json:"only_on_live,omitempty" yaml:"only_on_live,omitempty"`
disabled bool
lastMessageSent time.Time
@ -54,10 +53,6 @@ func (a *autoMessage) CanSend() bool {
// Not enough chatted lines
return false
case a.TimeInterval > 0 && a.lastMessageSent.Add(a.TimeInterval).After(time.Now()):
// Simple timer is not yet expired
return false
case a.Cron != "":
sched, _ := cronParser.Parse(a.Cron)
nextExecute := sched.Next(a.lastMessageSent)
@ -126,7 +121,7 @@ func (a *autoMessage) IsValid() bool {
}
}
if a.MessageInterval == 0 && a.TimeInterval == 0 && a.Cron == "" {
if a.MessageInterval == 0 && a.Cron == "" {
return false
}
@ -163,7 +158,7 @@ func (a *autoMessage) Send(c *irc.Client) error {
}
func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
if a.DisableOnTemplate == nil {
if a.DisableOnTemplate == nil || *a.DisableOnTemplate == "" {
// No match criteria set, does not speak against matching
return true
}

40
botEditor.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"net/http"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/twitch"
"github.com/pkg/errors"
)
func botEditorAuthMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "no auth-token provided", http.StatusForbidden)
return
}
tc := twitch.New(cfg.TwitchClient, token)
user, err := tc.GetAuthorizedUsername()
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusForbidden)
return
}
id, err := tc.GetIDForUsername(user)
if err != nil {
http.Error(w, errors.Wrap(err, "getting ID for authorized user").Error(), http.StatusForbidden)
return
}
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
http.Error(w, "user is not authorized", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}

14
ci/bundle.sh Normal file
View File

@ -0,0 +1,14 @@
#!/bin/bash
set -euxo pipefail
outfile=${1:-}
[[ -n $outfile ]] || {
echo "Missing parameters: $0 <outfile> [libraries]"
exit 1
}
shift
libs=("$@")
IFS=$','
exec curl -sSfLo "${outfile}" "https://cdn.jsdelivr.net/combine/${libs[*]}"

196
config.go
View File

@ -1,32 +1,72 @@
package main
import (
_ "embed"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"sync"
"time"
"github.com/Luzifer/twitch-bot/plugins"
"github.com/go-irc/irc"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
)
type configFile struct {
AutoMessages []*autoMessage `yaml:"auto_messages"`
Channels []string `yaml:"channels"`
HTTPListen string `yaml:"http_listen"`
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
PermitTimeout time.Duration `yaml:"permit_timeout"`
RawLog string `yaml:"raw_log"`
Rules []*plugins.Rule `yaml:"rules"`
Variables map[string]interface{} `yaml:"variables"`
const expectedMinConfigVersion = 2
rawLogWriter io.WriteCloser
var (
//go:embed default_config.yaml
defaultConfigurationYAML []byte
hashstructUUIDNamespace = uuid.Must(uuid.FromString("3a0ccc46-d3ba-46b5-ac07-27528c933174"))
configReloadHooks = map[string]func(){}
configReloadHooksLock sync.RWMutex
)
func registerConfigReloadHook(hook func()) func() {
configReloadHooksLock.Lock()
defer configReloadHooksLock.Unlock()
id := uuid.Must(uuid.NewV4()).String()
configReloadHooks[id] = hook
return func() {
configReloadHooksLock.Lock()
defer configReloadHooksLock.Unlock()
delete(configReloadHooks, id)
}
}
type (
configFileVersioner struct {
ConfigVersion int64 `yaml:"config_version"`
}
configFile struct {
AutoMessages []*autoMessage `yaml:"auto_messages"`
BotEditors []string `yaml:"bot_editors"`
Channels []string `yaml:"channels"`
HTTPListen string `yaml:"http_listen"`
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
PermitTimeout time.Duration `yaml:"permit_timeout"`
RawLog string `yaml:"raw_log"`
Rules []*plugins.Rule `yaml:"rules"`
Variables map[string]interface{} `yaml:"variables"`
rawLogWriter io.WriteCloser
configFileVersioner `yaml:",inline"`
}
)
func newConfigFile() *configFile {
return &configFile{
PermitTimeout: time.Minute,
@ -35,19 +75,20 @@ func newConfigFile() *configFile {
func loadConfig(filename string) error {
var (
err error
tmpConfig *configFile
configVersion = &configFileVersioner{}
err error
tmpConfig = newConfigFile()
)
switch path.Ext(filename) {
case ".yaml", ".yml":
tmpConfig, err = parseConfigFromYAML(filename)
default:
return errors.Errorf("Unknown config format %q", path.Ext(filename))
if err = parseConfigFromYAML(filename, configVersion, false); err != nil {
return errors.Wrap(err, "parsing config version")
}
if err != nil {
if configVersion.ConfigVersion < expectedMinConfigVersion {
return errors.Errorf("config version too old: %d < %d - Please have a look at the documentation!", configVersion.ConfigVersion, expectedMinConfigVersion)
}
if err = parseConfigFromYAML(filename, tmpConfig, true); err != nil {
return errors.Wrap(err, "parsing config")
}
@ -59,11 +100,16 @@ func loadConfig(filename string) error {
log.Warn("Loaded config with empty ruleset")
}
if err = tmpConfig.validateRuleActions(); err != nil {
return errors.Wrap(err, "validating rule actions")
}
configLock.Lock()
defer configLock.Unlock()
tmpConfig.updateAutoMessagesFromConfig(config)
tmpConfig.fixDurations()
tmpConfig.fixMissingUUIDs()
switch {
case config != nil && config.RawLog == tmpConfig.RawLog:
@ -96,28 +142,81 @@ func loadConfig(filename string) error {
"channels": len(config.Channels),
}).Info("Config file (re)loaded")
// Notify listener config has changed
configReloadHooksLock.RLock()
defer configReloadHooksLock.RUnlock()
for _, fn := range configReloadHooks {
fn()
}
return nil
}
func parseConfigFromYAML(filename string) (*configFile, error) {
func parseConfigFromYAML(filename string, obj interface{}, strict bool) error {
f, err := os.Open(filename)
if err != nil {
return nil, errors.Wrap(err, "open config file")
return errors.Wrap(err, "open config file")
}
defer f.Close()
decoder := yaml.NewDecoder(f)
decoder.SetStrict(strict)
return errors.Wrap(decoder.Decode(obj), "decode config file")
}
func patchConfig(filename string, patcher func(*configFile) error) error {
var (
decoder = yaml.NewDecoder(f)
tmpConfig = newConfigFile()
cfgFile = newConfigFile()
err error
)
decoder.SetStrict(true)
if err = decoder.Decode(&tmpConfig); err != nil {
return nil, errors.Wrap(err, "decode config file")
if err = parseConfigFromYAML(filename, cfgFile, true); err != nil {
return errors.Wrap(err, "loading current config")
}
return tmpConfig, nil
cfgFile.fixMissingUUIDs()
if err = patcher(cfgFile); err != nil {
return errors.Wrap(err, "patching config")
}
return errors.Wrap(
writeConfigToYAML(filename, cfgFile),
"replacing config",
)
}
func writeConfigToYAML(filename string, obj interface{}) error {
tmpFile, err := ioutil.TempFile(path.Dir(filename), "twitch-bot-*.yaml")
if err != nil {
return errors.Wrap(err, "opening tempfile")
}
tmpFileName := tmpFile.Name()
fmt.Fprintf(tmpFile, "# Automatically updated by Config-Editor frontend, last update: %s\n", time.Now().Format(time.RFC3339))
if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil {
tmpFile.Close()
return errors.Wrap(err, "encoding config")
}
tmpFile.Close()
return errors.Wrap(
os.Rename(tmpFileName, filename),
"moving config to location",
)
}
func writeDefaultConfigFile(filename string) error {
f, err := os.Create(filename)
if err != nil {
return errors.Wrap(err, "creating config file")
}
defer f.Close()
_, err = f.Write(defaultConfigurationYAML)
return errors.Wrap(err, "writing default config")
}
func (c *configFile) CloseRawMessageWriter() error {
@ -155,11 +254,6 @@ func (c *configFile) fixDurations() {
for _, r := range c.Rules {
r.Cooldown = c.fixedDurationPtr(r.Cooldown)
}
// Fix auto-messages
for _, a := range c.AutoMessages {
a.TimeInterval = c.fixedDuration(a.TimeInterval)
}
}
func (configFile) fixedDuration(d time.Duration) time.Duration {
@ -177,6 +271,22 @@ func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
return &fd
}
func (c *configFile) fixMissingUUIDs() {
for i := range c.AutoMessages {
if c.AutoMessages[i].UUID != "" {
continue
}
c.AutoMessages[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.AutoMessages[i].ID()).String()
}
for i := range c.Rules {
if c.Rules[i].UUID != "" {
continue
}
c.Rules[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.Rules[i].MatcherID()).String()
}
}
func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
for idx, nam := range c.AutoMessages {
// By default assume last message to be sent now
@ -208,3 +318,23 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
}
}
}
func (c configFile) validateRuleActions() error {
for _, r := range c.Rules {
logger := log.WithField("rule", r.MatcherID())
for idx, a := range r.Actions {
actor, err := getActorByName(a.Type)
if err != nil {
logger.WithField("index", idx).WithError(err).Error("Cannot get actor by type")
return errors.Wrap(err, "getting actor by type")
}
if err = actor.Validate(a.Attributes); err != nil {
logger.WithField("index", idx).WithError(err).Error("Actor reported invalid config")
return errors.Wrap(err, "validating action attributes")
}
}
}
return nil
}

597
configEditor.go Normal file
View File

@ -0,0 +1,597 @@
package main
import (
"embed"
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"sync"
"time"
"github.com/Luzifer/twitch-bot/plugins"
"github.com/Luzifer/twitch-bot/twitch"
"github.com/gofrs/uuid/v3"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
const websocketPingInterval = 30 * time.Second
var (
availableActorDocs = []plugins.ActionDocumentation{}
availableActorDocsLock sync.RWMutex
//go:embed editor/*
configEditorFrontend embed.FS
upgrader = websocket.Upgrader{}
)
func registerActorDocumentation(doc plugins.ActionDocumentation) {
availableActorDocsLock.Lock()
defer availableActorDocsLock.Unlock()
availableActorDocs = append(availableActorDocs, doc)
sort.Slice(availableActorDocs, func(i, j int) bool {
return availableActorDocs[i].Name < availableActorDocs[j].Name
})
}
type (
configEditorGeneralConfig struct {
BotEditors []string `json:"bot_editors"`
Channels []string `json:"channels"`
}
)
func init() {
registerEditorAutoMessageRoutes()
registerEditorFrontend()
registerEditorGeneralConfigRoutes()
registerEditorGlobalMethods()
registerEditorRulesRoutes()
}
//nolint:funlen // This is a logic unit and shall not be split up
func registerEditorAutoMessageRoutes() {
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
{
Description: "Returns the current set of configured auto-messages in JSON format",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(config.AutoMessages); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
},
Method: http.MethodGet,
Module: "config-editor",
Name: "Get current auto-messages",
Path: "/auto-messages",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Adds a new Auto-Message",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
msg := &autoMessage{}
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msg.UUID = uuid.Must(uuid.NewV4()).String()
if err := patchConfig(cfg.Config, func(c *configFile) error {
c.AutoMessages = append(c.AutoMessages, msg)
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
},
Method: http.MethodPost,
Module: "config-editor",
Name: "Add Auto-Message",
Path: "/auto-messages",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
{
Description: "Deletes the given Auto-Message",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
if err := patchConfig(cfg.Config, func(c *configFile) error {
var (
id = mux.Vars(r)["uuid"]
tmp []*autoMessage
)
for i := range c.AutoMessages {
if c.AutoMessages[i].ID() == id {
continue
}
tmp = append(tmp, c.AutoMessages[i])
}
c.AutoMessages = tmp
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
},
Method: http.MethodDelete,
Module: "config-editor",
Name: "Delete Auto-Message",
Path: "/auto-messages/{uuid}",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "UUID of the auto-message to delete",
Name: "uuid",
Required: false,
Type: "string",
},
},
},
{
Description: "Updates the given Auto-Message",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
msg := &autoMessage{}
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := patchConfig(cfg.Config, func(c *configFile) error {
id := mux.Vars(r)["uuid"]
for i := range c.AutoMessages {
if c.AutoMessages[i].ID() == id {
c.AutoMessages[i] = msg
}
}
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
},
Method: http.MethodPut,
Module: "config-editor",
Name: "Update Auto-Message",
Path: "/auto-messages/{uuid}",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "UUID of the auto-message to update",
Name: "uuid",
Required: false,
Type: "string",
},
},
},
} {
if err := registerRoute(rd); err != nil {
log.WithError(err).Fatal("Unable to register config editor route")
}
}
}
func registerEditorFrontend() {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
f, err := configEditorFrontend.Open("editor/index.html")
if err != nil {
http.Error(w, errors.Wrap(err, "opening index.html").Error(), http.StatusNotFound)
return
}
io.Copy(w, f)
})
router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(struct {
IRCBadges []string
KnownEvents []*string
TwitchClientID string
}{
IRCBadges: twitch.KnownBadges,
KnownEvents: knownEvents,
TwitchClientID: cfg.TwitchClient,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
router.PathPrefix("/editor").Handler(http.FileServer(http.FS(configEditorFrontend)))
}
func registerEditorGeneralConfigRoutes() {
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
{
Description: "Returns the current general config",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
BotEditors: config.BotEditors,
Channels: config.Channels,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
},
Method: http.MethodGet,
Module: "config-editor",
Name: "Get general config",
Path: "/general",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Updates the general config",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
var payload configEditorGeneralConfig
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for i := range payload.BotEditors {
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i])
if err != nil {
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
return
}
payload.BotEditors[i] = usr.ID
}
if err := patchConfig(cfg.Config, func(cfg *configFile) error {
cfg.Channels = payload.Channels
cfg.BotEditors = payload.BotEditors
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
},
Method: http.MethodPut,
Module: "config-editor",
Name: "Update general config",
Path: "/general",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
} {
if err := registerRoute(rd); err != nil {
log.WithError(err).Fatal("Unable to register config editor route")
}
}
}
//nolint:funlen,gocognit,gocyclo // This is a logic unit and shall not be split up
func registerEditorGlobalMethods() {
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
{
Description: "Returns the documentation for available actions",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
availableActorDocsLock.Lock()
defer availableActorDocsLock.Unlock()
if err := json.NewEncoder(w).Encode(availableActorDocs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
},
Method: http.MethodGet,
Module: "config-editor",
Name: "Get available actions",
Path: "/actions",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Returns information about a Twitch user to properly display bot editors",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
usr, err := twitchClient.GetUserInformation(r.FormValue("user"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
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")
}
}
}

36
cors.go Normal file
View File

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

29
default_config.yaml Normal file
View File

@ -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: []
...

151
editor/.eslintrc.js Normal file
View File

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

766
editor/app.js Normal file
View File

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

843
editor/index.html Normal file
View File

@ -0,0 +1,843 @@
<html>
<title>Twitch-Bot: Config-Editor</title>
<link rel="stylesheet" href="editor/bundle.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.4/css/all.min.css">
<style>
[v-cloak] {
display: none;
}
.btn-twitch {
background-color: #6441a5;
}
</style>
<div id="app" v-cloak>
<b-navbar toggleable="lg" type="dark" variant="primary" class="mb-3">
<b-navbar-brand href="#"><i class="fas fa-fw fa-robot mr-1"></i> Twitch-Bot</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<b-navbar-nav v-if="authToken">
<b-nav-item :active="editMode === 'general'" @click="editMode = 'general'">
<i class="fas fa-fw fa-cog mr-1"></i>
General
</b-nav-item>
<b-nav-item :active="editMode === 'automessages'" @click="editMode = 'automessages'">
<i class="fas fa-fw fa-envelope-open-text mr-1"></i>
Auto-Messages
<b-badge pill>{{ autoMessages.length }}
</b-badge></b-nav-item>
<b-nav-item :active="editMode === 'rules'" @click="editMode = 'rules'">
<i class="fas fa-fw fa-inbox mr-1"></i>
Rules
<b-badge pill>{{ rules.length }}</b-badge>
</b-nav-item>
</b-navbar-nav>
<b-navbar-nav class="ml-auto">
<b-nav-text>
<span v-if="configNotifySocketConnected">
<i
class="fas fa-fw fa-ethernet mr-1 text-success"
title="Connected to Bot"
v-b-tooltip.hover
></i>
</span>
<span v-else>
<i
class="fas fa-fw fa-ethernet mr-1 text-danger"
title="Disconnected from Bot"
v-b-tooltip.hover
></i>
</span>
</b-nav-text>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<b-container>
<!-- Error display -->
<b-row v-if="error">
<b-col>
<b-alert
dismissible
@dismissed="error = null"
show
variant="danger"
>
<i class="fas fa-fw fa-exclamation-circle mr-1"></i> {{ error }}
</b-alert>
</b-col>
</b-row>
<!-- Working display -->
<b-row v-if="changePending">
<b-col>
<b-alert
show
variant="info"
>
<i class="fas fa-fw fa-spinner fa-pulse mr-1"></i>
Your change was submitted and is pending, please wait for config to be updated!
</b-alert>
</b-col>
</b-row>
<!-- Logged-out state -->
<b-row
v-if="!authToken"
>
<b-col
class="text-center"
>
<b-button
:disabled="!vars.TwitchClientID"
:href="authURL"
variant="twitch"
>
<i class="fab fa-fw fa-twitch mr-1"></i> Login with Twitch
</b-button>
</b-col>
</b-row>
<!-- Logged-in state -->
<template v-else>
<b-row v-if="editMode === 'general'">
<b-col>
<b-card-group columns>
<b-card no-body>
<b-card-header>
<i class="fas fa-fw fa-hashtag mr-1"></i> Channels
</b-card-header>
<b-list-group flush>
<b-list-group-item
class="d-flex align-items-center align-middle"
:key="channel"
v-for="channel in sortedChannels"
>
<span class="mr-auto">
<i class="fas fa-fw fa-hashtag mr-1"></i>
{{ channel }}
</span>
<b-button
@click="removeChannel(channel)"
size="sm"
variant="danger"
>
<i class="fas fa-fw fa-minus"></i>
</b-button>
</b-list-group-item>
<b-list-group-item>
<b-input-group>
<b-form-input @keyup.enter="addChannel" v-model="models.addChannel"></b-form-input>
<b-input-group-append>
<b-button @click="addChannel" variant="success"><i class="fas fa-fw fa-plus mr-1"></i> Add</b-button>
</b-input-group-append>
</b-input-group>
</b-list-group-item>
</b-list-group>
</b-card>
<b-card no-body>
<b-card-header>
<i class="fas fa-fw fa-users mr-1"></i> Bot-Editors
</b-card-header>
<b-list-group flush>
<b-list-group-item
class="d-flex align-items-center align-middle"
:key="editor"
v-for="editor in sortedEditors"
>
<b-avatar class="mr-3" :src="userProfiles[editor]?.profile_image_url"></b-avatar>
<span class="mr-auto">{{ userProfiles[editor] ? userProfiles[editor].display_name : editor }}</span>
<b-button
@click="removeEditor(editor)"
size="sm"
variant="danger"
>
<i class="fas fa-fw fa-minus"></i>
</b-button>
</b-list-group-item>
<b-list-group-item>
<b-input-group>
<b-form-input @keyup.enter="addEditor" v-model="models.addEditor"></b-form-input>
<b-input-group-append>
<b-button @click="addEditor" variant="success"><i class="fas fa-fw fa-plus mr-1"></i> Add</b-button>
</b-input-group-append>
</b-input-group>
</b-list-group-item>
</b-list-group>
</b-card>
</b-card-group>
</b-col>
</b-row>
<b-row v-else-if="editMode === 'automessages'">
<b-col>
<b-table
:busy="!autoMessages"
:fields="autoMessageFields"
hover
:items="autoMessages"
key="autoMessagesTable"
striped
>
<template #cell(actions)="data">
<b-button-group size="sm">
<b-button @click="editAutoMessage(data.item)"><i class="fas fa-fw fa-pen"></i></b-button>
<b-button @click="deleteAutoMessage(data.item.uuid)" variant="danger"><i class="fas fa-fw fa-minus"></i></b-button>
</b-button-group>
</template>
<template #cell(channel)="data">
<i class="fas fa-fw fa-hashtag mr-1"></i>
{{ data.value }}
</template>
<template #cell(cron)="data">
<code>{{ data.value }}</code>
</template>
<template #head(actions)="data">
<b-button-group size="sm">
<b-button @click="newAutoMessage" variant="success"><i class="fas fa-fw fa-plus"></i></b-button>
</b-button-group>
</template>
</b-table>
</b-col>
</b-row>
<b-row v-else-if="editMode === 'rules'">
<b-col>
<b-table
:busy="!rules"
:fields="rulesFields"
hover
:items="rules"
key="rulesTable"
striped
>
<template #cell(_actions)="data">
<b-button-group size="sm">
<b-button @click="editRule(data.item)"><i class="fas fa-fw fa-pen"></i></b-button>
<b-button @click="deleteRule(data.item.uuid)" variant="danger"><i class="fas fa-fw fa-minus"></i></b-button>
</b-button-group>
</template>
<template #cell(_match)="data">
<b-badge
class="m-1 text-truncate text-left col-12"
style="max-width: 250px;"
v-for="badge in formatRuleMatch(data.item)"
>
<strong>{{ badge.key }}</strong> <code class="ml-2">{{ badge.value }}</code>
</b-badge>
</template>
<template #cell(_description)="data">
<template v-if="data.item.description">{{ data.item.description }}<br></template>
<b-badge
class="mt-1 mr-1"
v-for="badge in formatRuleActions(data.item)"
>
{{ badge }}
</b-badge>
</template>
<template #head(_actions)="data">
<b-button-group size="sm">
<b-button @click="newRule" variant="success"><i class="fas fa-fw fa-plus"></i></b-button>
</b-button-group>
</template>
</b-table>
</b-col>
</b-row>
</template>
<!-- Auto-Message Editor -->
<b-modal
@hidden="showAutoMessageEditModal=false"
hide-header-close
@ok="saveAutoMessage"
:ok-disabled="!validateAutoMessage"
ok-title="Save"
size="lg"
:visible="showAutoMessageEditModal"
title="Edit Auto-Message"
v-if="showAutoMessageEditModal"
>
<b-row>
<b-col cols="8">
<b-form-group
label="Channel"
label-for="formAutoMessageChannel"
>
<b-input-group
prepend="#"
>
<b-form-input
id="formAutoMessageChannel"
:state="validateAutoMessageChannel"
type="text"
required
v-model="models.autoMessage.channel"
></b-form-input>
</b-input-group>
</b-form-group>
<hr>
<b-form-group
:description="`${models.autoMessage.message?.length || 0} / ${validateAutoMessageMessageLength}`"
label="Message"
label-for="formAutoMessageMessage"
>
<b-form-textarea
id="formAutoMessageMessage"
max-rows="6"
required
rows="3"
:state="models.autoMessage.message?.length <= validateAutoMessageMessageLength"
v-model="models.autoMessage.message"
></b-form-textarea>
</b-form-group>
<b-form-group>
<b-form-checkbox
switch
v-model="models.autoMessage.use_action"
>
Send message as action (<code>/me</code>)
</b-form-checkbox>
</b-form-group>
<hr>
<b-form-group
label="Sending Mode"
label-for="formAutoMessageSendMode"
>
<b-form-select
id="formAutoMessageSendMode"
:options="autoMessageSendModes"
v-model="models.autoMessage.sendMode"
></b-form-select>
</b-form-group>
<b-form-group
label="Send at"
label-for="formAutoMessageCron"
v-if="models.autoMessage.sendMode === 'cron'"
>
<b-form-input
id="formAutoMessageCron"
v-model="models.autoMessage.cron"
:state="validateAutoMessageCron"
type="text"
></b-form-input>
<div slot="description">
<code>@every [time]</code> or Cron syntax
</div>
</b-form-group>
<b-form-group
label="Send every"
label-for="formAutoMessageNLines"
v-if="models.autoMessage.sendMode === 'lines'"
>
<b-input-group
append="Lines"
>
<b-form-input
id="formAutoMessageNLines"
v-model="models.autoMessage.message_interval"
type="number"
></b-form-input>
</b-input-group>
</b-form-group>
<hr>
<b-form-group>
<b-form-checkbox
switch
v-model="models.autoMessage.only_on_live"
>
Send only when channel is live
</b-form-checkbox>
</b-form-group>
<b-form-group
label="Disable on Template"
label-for="formAutoMessageDisableOnTemplate"
>
<div slot="description">
Template expression resulting in <code>true</code> to disable the rule or <code>false</code> to enable it
</div>
<b-form-textarea
id="formAutoMessageDisableOnTemplate"
max-rows="6"
required
rows="1"
v-model="models.autoMessage.disable_on_template"
></b-form-textarea>
</b-form-group>
</b-col>
<b-col cols="4">
<h6>Getting Help</h6>
<p>
For information about available template functions and variables to use in the <strong>Message</strong> see the <a href="https://github.com/Luzifer/twitch-bot/wiki#templating" target="_blank">Templating</a> section of the Wiki.
</p>
<p>
For information about the <strong>Cron</strong> syntax have a look at the <a href="https://cron.help/" target="_blank">cron.help</a> site. Aditionally you can use <code>@every [time]</code> syntax. The <code>[time]</code> part is in format <code>1h30m20s</code>. You can leave out every segment but need to specify the unit of every segment. So for example <code>@every 1h</code> or <code>@every 10m</code> would be a valid specification.
</p>
</b-col>
</b-row>
</b-modal>
<!-- Rule Editor -->
<b-modal
@hidden="showRuleEditModal=false"
hide-header-close
@ok="saveRule"
:ok-disabled="!validateRule"
ok-title="Save"
scrollable
size="xl"
:visible="showRuleEditModal"
title="Edit Rule"
v-if="showRuleEditModal"
>
<b-row>
<b-col cols="6">
<b-form-group
description="Human readable description for the rules list"
label="Description"
label-for="formRuleDescription"
>
<b-form-input
id="formRuleDescription"
type="text"
v-model="models.rule.description"
></b-form-input>
</b-form-group>
<hr>
<b-tabs content-class="mt-3">
<b-tab>
<div slot="title">
Matcher <b-badge>{{ countRuleMatchers }}</b-badge>
</div>
<b-form-group
description="Channel with leading hash: #mychannel - matches all channels if none are given"
label="Match Channels"
label-for="formRuleMatchChannels"
>
<b-form-tags
id="formRuleMatchChannels"
no-add-on-enter
placeholder="Enter channels separated by space or comma"
remove-on-delete
separator=" ,"
:tag-validator="(tag) => Boolean(tag.match(/^#[a-zA-Z0-9_]{4,25}$/))"
v-model="models.rule.match_channels"
></b-form-tags>
</b-form-group>
<b-form-group
description="Matches no events if not set"
label="Match Event"
label-for="formRuleMatchEvent"
>
<b-form-select
id="formRuleMatchEvent"
:options="availableEvents"
v-model="models.rule.match_event"
></b-form-select>
</b-form-group>
<b-form-group
description="Regular expression to match the message, matches all messages when not set"
label="Match Message"
label-for="formRuleMatchMessage"
>
<b-form-input
id="formRuleMatchMessage"
:state="models.rule.match_message__validation"
type="text"
v-model="models.rule.match_message"
></b-form-input>
</b-form-group>
<b-form-group
description="Matches all users if none are given"
label="Match Users"
label-for="formRuleMatchUsers"
>
<b-form-tags
id="formRuleMatchUsers"
no-add-on-enter
placeholder="Enter usernames separated by space or comma"
remove-on-delete
separator=" ,"
:tag-validator="(tag) => Boolean(tag.match(/^[a-z0-9_]{4,25}$/))"
v-model="models.rule.match_users"
></b-form-tags>
</b-form-group>
</b-tab>
<b-tab>
<div slot="title">
Cooldown <b-badge>{{ countRuleCooldowns }}</b-badge>
</div>
<b-row>
<b-col>
<b-form-group
label="Rule Cooldown"
label-for="formRuleRuleCooldown"
>
<b-form-input
id="formRuleRuleCooldown"
placeholder="No Cooldown"
:state="validateDuration(models.rule.cooldown, false)"
type="text"
v-model="models.rule.cooldown"
></b-form-input>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Channel Cooldown"
label-for="formRuleChannelCooldown"
>
<b-form-input
id="formRuleChannelCooldown"
placeholder="No Cooldown"
:state="validateDuration(models.rule.channel_cooldown, false)"
type="text"
v-model="models.rule.channel_cooldown"
></b-form-input>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="User Cooldown"
label-for="formRuleUserCooldown"
>
<b-form-input
id="formRuleUserCooldown"
placeholder="No Cooldown"
:state="validateDuration(models.rule.user_cooldown, false)"
type="text"
v-model="models.rule.user_cooldown"
></b-form-input>
</b-form-group>
</b-row>
<b-form-group
:description="`Available badges: ${vars.IRCBadges?.join(', ')}`"
label="Skip Cooldown for"
label-for="formRuleSkipCooldown"
>
<b-form-tags
id="formRuleSkipCooldown"
no-add-on-enter
placeholder="Enter badges separated by space or comma"
remove-on-delete
separator=" ,"
:tag-validator="validateTwitchBadge"
v-model="models.rule.skip_cooldown_for"
></b-form-tags>
</b-form-group>
</b-tab>
<b-tab>
<div slot="title">
Conditions <b-badge>{{ countRuleConditions }}</b-badge>
</div>
<p>Disable rule&hellip;</p>
<b-row>
<b-col>
<b-form-group>
<b-form-checkbox
switch
v-model="models.rule.disable"
>
completely
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col>
<b-form-group>
<b-form-checkbox
switch
v-model="models.rule.disable_on_offline"
>
when channel is offline
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col>
<b-form-group>
<b-form-checkbox
switch
v-model="models.rule.disable_on_permit"
>
when user has permit
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<b-form-group
:description="`Available badges: ${vars.IRCBadges?.join(', ')}`"
label="Disable Rule for"
label-for="formRuleDisableOn"
>
<b-form-tags
id="formRuleDisableOn"
no-add-on-enter
placeholder="Enter badges separated by space or comma"
remove-on-delete
separator=" ,"
:tag-validator="validateTwitchBadge"
v-model="models.rule.disable_on"
></b-form-tags>
</b-form-group>
<b-form-group
:description="`Available badges: ${vars.IRCBadges?.join(', ')}`"
label="Enable Rule for"
label-for="formRuleEnableOn"
>
<b-form-tags
id="formRuleEnableOn"
no-add-on-enter
placeholder="Enter badges separated by space or comma"
remove-on-delete
separator=" ,"
:tag-validator="validateTwitchBadge"
v-model="models.rule.enable_on"
></b-form-tags>
</b-form-group>
<b-form-group
label="Disable on Template"
label-for="formRuleDisableOnTemplate"
>
<div slot="description">
Template expression resulting in <code>true</code> to disable the rule or <code>false</code> to enable it
</div>
<b-form-textarea
id="formRuleDisableOnTemplate"
max-rows="6"
required
rows="1"
v-model="models.rule.disable_on_template"
></b-form-textarea>
</b-form-group>
</b-tab>
</b-tabs>
</b-col>
<b-col cols="6">
<div class="accordion" role="tablist">
<b-card
:key="`${models.rule.uuid}-action-${idx}`"
no-body
class="mb-1"
v-for="(action, idx) in models.rule.actions"
>
<b-card-header header-tag="header" class="p-1 d-flex" role="tab">
<b-button-group class="flex-fill">
<b-button
block
v-b-toggle="`${models.rule.uuid}-action-${idx}`"
variant="primary"
>
{{ getActionDefinitionByType(action.type).name }}
<i class="fas fa-fw fa-exclamation-triangle text-danger" v-if="actionHasValidationError(idx)"></i>
</b-button>
<b-button
@click="moveAction(idx, -1)"
:disabled="idx === 0"
variant="secondary"
><i class="fas fa-fw fa-chevron-up"></i></b-button>
<b-button
@click="moveAction(idx, +1)"
:disabled="idx === models.rule.actions.length - 1"
variant="secondary"
><i class="fas fa-fw fa-chevron-down"></i></b-button>
<b-button
@click="removeAction(idx)"
variant="danger"
><i class="fas fa-fw fa-trash"></i></b-button>
</b-button-group>
</b-card-header>
<b-collapse
:id="`${models.rule.uuid}-action-${idx}`"
accordion="my-accordion"
role="tabpanel"
>
<b-card-body v-if="getActionDefinitionByType(action.type).fields?.length > 0">
<template
v-for="field in getActionDefinitionByType(action.type).fields"
>
<b-form-group
v-if="field.type === 'bool'"
>
<div slot="description">
<i
class="fas fa-fw fa-code mr-1 text-success"
title="Supports Templating"
v-if="field.support_template"
></i>
{{ field.description }}
</div>
<b-form-checkbox
switch
v-model="models.rule.actions[idx].attributes[field.key]"
>
{{ field.name }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="field.name"
:label-for="`${models.rule.uuid}-action-${idx}-${field.key}`"
v-else-if="field.type === 'stringslice'"
>
<div slot="description">
<i
class="fas fa-fw fa-code mr-1 text-success"
title="Supports Templating"
v-if="field.support_template"
></i>
{{ field.description }}
</div>
<b-form-tags
:id="`${models.rule.uuid}-action-${idx}-${field.key}`"
:state="validateActionArgument(idx, field.key)"
placeholder="Enter elements and press enter to add the element"
remove-on-delete
v-model="models.rule.actions[idx].attributes[field.key]"
></b-form-tags>
</b-form-group>
<b-form-group
:label="field.name"
:label-for="`${models.rule.uuid}-action-${idx}-${field.key}`"
v-else-if="field.type === 'string' && field.long"
>
<div slot="description">
<i
class="fas fa-fw fa-code mr-1 text-success"
title="Supports Templating"
v-if="field.support_template"
></i>
{{ field.description }}
</div>
<b-form-textarea
:id="`${models.rule.uuid}-action-${idx}-${field.key}`"
max-rows="6"
:required="!field.optional"
rows="3"
:state="validateActionArgument(idx, field.key)"
v-model="models.rule.actions[idx].attributes[field.key]"
></b-form-textarea>
</b-form-group>
<b-form-group
:label="field.name"
:label-for="`${models.rule.uuid}-action-${idx}-${field.key}`"
v-else
>
<div slot="description">
<i
class="fas fa-fw fa-code mr-1 text-success"
title="Supports Templating"
v-if="field.support_template"
></i>
{{ field.description }}
</div>
<b-form-input
:id="`${models.rule.uuid}-action-${idx}-${field.key}`"
:placeholder="field.default"
:required="!field.optional"
:state="validateActionArgument(idx, field.key)"
type="text"
v-model="models.rule.actions[idx].attributes[field.key]"
></b-form-input>
</b-form-group>
</template>
</b-card-body>
<b-card-body v-else>
This action has no attributes.
</b-card-body>
</b-collapse>
</b-card>
<hr>
<b-form-group
label="Add Action"
label-for="ruleAddAction"
>
<b-input-group>
<b-form-select
id="ruleAddAction"
:options="availableActionsForAdd"
v-model="models.addAction"
></b-form-select>
<b-input-group-append>
<b-button
@click="addAction"
:disabled="!models.addAction"
variant="success"
><i class="fas fa-fw fa-plus mr-1"></i> Add</b-button>
</b-input-group-append>
</b-input-group>
</b-form-group>
<div>
</b-col>
</b-row>
</b-modal>
</b-container>
</div>
<script src="editor/bundle.js"></script>
<script src="editor/app.js"></script>
</html>

View File

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

4
go.mod
View File

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

4
go.sum
View File

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

7
helpers.go Normal file
View File

@ -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 }("")
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

33
main.go
View File

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

32
plugins/action_docs.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

189
wiki/Actors.md Normal file
View File

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

View File

@ -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: "<actor 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