mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-08 08:10:08 +00:00
Breaking: Add configuration interface and switch to more generic config format (#7)
This commit is contained in:
parent
a1e9c35430
commit
b59676492e
45 changed files with 3776 additions and 405 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
||||||
config
|
config
|
||||||
config.hcl
|
config.hcl
|
||||||
config.yaml
|
config.yaml
|
||||||
|
editor/bundle.*
|
||||||
.env
|
.env
|
||||||
storage.json.gz
|
storage.json.gz
|
||||||
twitch-bot
|
twitch-bot
|
||||||
|
|
|
@ -3,16 +3,19 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
run:
|
run:
|
||||||
skip-dirs:
|
# timeout for analysis, e.g. 30s, 5m, default is 1m
|
||||||
- config
|
timeout: 5m
|
||||||
skip-files:
|
# Force readonly modules usage for checking
|
||||||
- assets.go
|
modules-download-mode: readonly
|
||||||
- bindata.go
|
|
||||||
|
|
||||||
output:
|
output:
|
||||||
format: tab
|
format: tab
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- 'fmt\.Errorf' # Should use github.com/pkg/errors
|
||||||
|
|
||||||
funlen:
|
funlen:
|
||||||
lines: 100
|
lines: 100
|
||||||
statements: 60
|
statements: 60
|
||||||
|
@ -21,6 +24,11 @@ linters-settings:
|
||||||
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
||||||
min-complexity: 15
|
min-complexity: 15
|
||||||
|
|
||||||
|
gomnd:
|
||||||
|
settings:
|
||||||
|
mnd:
|
||||||
|
ignored-functions: 'strconv.(?:Format|Parse)\B+'
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
disable-all: true
|
disable-all: true
|
||||||
enable:
|
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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- 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]
|
- gomnd # An analyzer to detect magic numbers. [fast: true, auto-fix: false]
|
||||||
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]
|
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]
|
||||||
|
|
|
@ -6,7 +6,12 @@ WORKDIR /go/src/github.com/Luzifer/twitch-bot
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
&& apk add --update git \
|
&& apk add --update \
|
||||||
|
bash \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
make \
|
||||||
|
&& make frontend \
|
||||||
&& go install \
|
&& go install \
|
||||||
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
|
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
|
||||||
-mod=readonly
|
-mod=readonly
|
||||||
|
|
23
Makefile
23
Makefile
|
@ -3,15 +3,36 @@ default: lint test
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --timeout=5m
|
golangci-lint run --timeout=5m
|
||||||
|
|
||||||
publish:
|
publish: frontend
|
||||||
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
||||||
bash golang.sh
|
bash golang.sh
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -cover -v ./...
|
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
|
# --- Wiki Updates
|
||||||
|
|
||||||
|
actor_docs:
|
||||||
|
go run . actor-docs >wiki/Actors.md
|
||||||
|
|
||||||
pull_wiki:
|
pull_wiki:
|
||||||
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash
|
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/Luzifer/twitch-bot/internal/actors/ban"
|
"github.com/Luzifer/twitch-bot/internal/actors/ban"
|
||||||
"github.com/Luzifer/twitch-bot/internal/actors/delay"
|
"github.com/Luzifer/twitch-bot/internal/actors/delay"
|
||||||
|
@ -43,12 +44,17 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||||
PathPrefix(fmt.Sprintf("/%s/", route.Module)).
|
PathPrefix(fmt.Sprintf("/%s/", route.Module)).
|
||||||
Subrouter()
|
Subrouter()
|
||||||
|
|
||||||
|
var hdl http.Handler = route.HandlerFunc
|
||||||
|
if route.RequiresEditorsAuth {
|
||||||
|
hdl = botEditorAuthMiddleware(hdl)
|
||||||
|
}
|
||||||
|
|
||||||
if route.IsPrefix {
|
if route.IsPrefix {
|
||||||
r.PathPrefix(route.Path).
|
r.PathPrefix(route.Path).
|
||||||
HandlerFunc(route.HandlerFunc).
|
Handler(hdl).
|
||||||
Methods(route.Method)
|
Methods(route.Method)
|
||||||
} else {
|
} else {
|
||||||
r.HandleFunc(route.Path, route.HandlerFunc).
|
r.Handle(route.Path, hdl).
|
||||||
Methods(route.Method)
|
Methods(route.Method)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +71,7 @@ func getRegistrationArguments() plugins.RegistrationArguments {
|
||||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||||
RegisterActor: registerAction,
|
RegisterActor: registerAction,
|
||||||
|
RegisterActorDocumentation: registerActorDocumentation,
|
||||||
RegisterAPIRoute: registerRoute,
|
RegisterAPIRoute: registerRoute,
|
||||||
RegisterCron: cronService.AddFunc,
|
RegisterCron: cronService.AddFunc,
|
||||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||||
|
|
|
@ -12,7 +12,43 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
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{
|
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||||
Description: "Returns the (formatted) value as a plain string",
|
Description: "Returns the (formatted) value as a plain string",
|
||||||
|
@ -68,29 +104,21 @@ func init() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActorCounter struct {
|
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.Counter == nil {
|
counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
counterName, err := formatMessage(*a.Counter, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing response")
|
return false, errors.Wrap(err, "preparing response")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.CounterSet != nil {
|
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" {
|
||||||
parseValue, err := formatMessage(*a.CounterSet, m, r, eventData)
|
parseValue, err := formatMessage(counterSet, m, r, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "execute counter value template")
|
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 {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "parse counter value")
|
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
|
var counterStep int64 = 1
|
||||||
if a.CounterStep != nil {
|
if s := attrs.MustInt64("counter_step", ptrIntZero); s != 0 {
|
||||||
counterStep = *a.CounterStep
|
counterStep = s
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, errors.Wrap(
|
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) IsAsync() bool { return false }
|
||||||
func (a ActorCounter) Name() string { return "counter" }
|
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) {
|
func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
|
||||||
template := r.FormValue("template")
|
template := r.FormValue("template")
|
||||||
if template == "" {
|
if template == "" {
|
||||||
|
@ -132,7 +168,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
|
||||||
value int64
|
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)
|
http.Error(w, errors.Wrap(err, "parsing value").Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,27 +14,51 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
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 {
|
type ActorScript struct{}
|
||||||
Command []string `json:"command" yaml:"command"`
|
|
||||||
SkipCooldownOnError bool `json:"skip_cooldown_on_error" yaml:"skip_cooldown_on_error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if len(a.Command) == 0 {
|
command, err := attrs.StringSlice("command")
|
||||||
return false, nil
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "getting command")
|
||||||
}
|
}
|
||||||
|
|
||||||
var command []string
|
for i := range command {
|
||||||
for _, arg := range a.Command {
|
tmp, err := formatMessage(command[i], m, r, eventData)
|
||||||
tmp, err := formatMessage(arg, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "execute command argument template")
|
return false, errors.Wrap(err, "execute command argument template")
|
||||||
}
|
}
|
||||||
|
|
||||||
command = append(command, tmp)
|
command[i] = tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout)
|
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
|
cmd.Stdout = stdout
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
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 {
|
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 {
|
for _, action := range actions {
|
||||||
apc, err := triggerActions(c, m, r, action, eventData)
|
apc, err := triggerAction(c, m, r, action, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preventCooldown, errors.Wrap(err, "execute returned action")
|
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) IsAsync() bool { return false }
|
||||||
func (a ActorScript) Name() string { return "script" }
|
func (a ActorScript) Name() string { return "script" }
|
||||||
|
|
||||||
|
func (a ActorScript) Validate(attrs plugins.FieldCollection) (err error) {
|
||||||
|
if cmd, err := attrs.StringSlice("command"); err != nil || len(cmd) == 0 {
|
||||||
|
return errors.New("command must be slice of strings with length > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,43 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
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{
|
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||||
Description: "Returns the value as a plain string",
|
Description: "Returns the value as a plain string",
|
||||||
|
@ -53,30 +89,22 @@ func init() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActorSetVariable struct {
|
type ActorSetVariable struct{}
|
||||||
Variable string `json:"variable" yaml:"variable"`
|
|
||||||
Clear bool `json:"clear" yaml:"clear"`
|
|
||||||
Set string `json:"set" yaml:"set"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.Variable == "" {
|
varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
varName, err := formatMessage(a.Variable, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing variable name")
|
return false, errors.Wrap(err, "preparing variable name")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Clear {
|
if attrs.MustBool("clear", ptrBoolFalse) {
|
||||||
return false, errors.Wrap(
|
return false, errors.Wrap(
|
||||||
store.RemoveVariable(varName),
|
store.RemoveVariable(varName),
|
||||||
"removing variable",
|
"removing variable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
value, err := formatMessage(a.Set, m, r, eventData)
|
value, err := formatMessage(attrs.MustString("set", ptrStringEmpty), m, r, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing value")
|
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) IsAsync() bool { return false }
|
||||||
func (a ActorSetVariable) Name() string { return "setvariable" }
|
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) {
|
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text-plain")
|
w.Header().Set("Content-Type", "text-plain")
|
||||||
fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"]))
|
fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"]))
|
||||||
|
|
66
actions.go
66
actions.go
|
@ -10,52 +10,58 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
availableActions []plugins.ActorCreationFunc
|
availableActions = map[string]plugins.ActorCreationFunc{}
|
||||||
availableActionsLock = new(sync.RWMutex)
|
availableActionsLock = new(sync.RWMutex)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time assertion
|
// Compile-time assertion
|
||||||
var _ plugins.ActorRegistrationFunc = registerAction
|
var _ plugins.ActorRegistrationFunc = registerAction
|
||||||
|
|
||||||
func registerAction(af plugins.ActorCreationFunc) {
|
func getActorByName(name string) (plugins.Actor, error) {
|
||||||
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) {
|
|
||||||
availableActionsLock.RLock()
|
availableActionsLock.RLock()
|
||||||
defer availableActionsLock.RUnlock()
|
defer availableActionsLock.RUnlock()
|
||||||
|
|
||||||
for _, acf := range availableActions {
|
acf, ok := availableActions[name]
|
||||||
var (
|
if !ok {
|
||||||
a = acf()
|
return nil, errors.Errorf("undefined actor %q called", name)
|
||||||
logger = log.WithField("actor", a.Name())
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := ra.Unmarshal(a); err != nil {
|
|
||||||
logger.WithError(err).Trace("Unable to unmarshal config")
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
if a.IsAsync() {
|
||||||
go func() {
|
go func() {
|
||||||
if _, err := a.Execute(c, m, rule, eventData); err != nil {
|
if _, err := a.Execute(c, m, rule, eventData, ra.Attributes); err != nil {
|
||||||
logger.WithError(err).Error("Error in async actor")
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return preventCooldown, nil
|
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) {
|
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
|
var preventCooldown bool
|
||||||
|
|
||||||
for _, a := range r.Actions {
|
for _, a := range r.Actions {
|
||||||
apc, err := triggerActions(c, m, r, a, eventData)
|
apc, err := triggerAction(c, m, r, a, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("Unable to trigger action")
|
log.WithError(err).Error("Unable to trigger action")
|
||||||
break // Break execution when one action fails
|
break // Break execution when one action fails
|
||||||
|
|
27
actorDocs.go
Normal file
27
actorDocs.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed actorDocs.tpl
|
||||||
|
var actorDocsTemplate string
|
||||||
|
|
||||||
|
func generateActorDocs() ([]byte, error) {
|
||||||
|
tpl, err := template.New("actorDocs").Parse(actorDocsTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "parsing actorDocs template")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := tpl.Execute(buf, struct{ Actors []plugins.ActionDocumentation }{availableActorDocs}); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "rendering actorDocs template")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
42
actorDocs.tpl
Normal file
42
actorDocs.tpl
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
{{ range .Actors }}
|
||||||
|
## {{ .Name }}
|
||||||
|
|
||||||
|
{{ .Description }}
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: {{ .Type }}
|
||||||
|
{{- if gt (len .Fields) 0 }}
|
||||||
|
attributes:
|
||||||
|
{{- range .Fields }}
|
||||||
|
# {{ .Description }}
|
||||||
|
# Optional: {{ .Optional }}
|
||||||
|
{{- if eq .Type "bool" }}
|
||||||
|
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||||
|
{{ .Key }}: {{ eq .Default "true" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Type "duration" }}
|
||||||
|
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||||
|
{{ .Key }}: {{ if eq .Default "" }}0s{{ else }}{{ .Default }}{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Type "int64" }}
|
||||||
|
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||||
|
{{ .Key }}: {{ if eq .Default "" }}0{{ else }}{{ .Default }}{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Type "string" }}
|
||||||
|
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||||
|
{{ .Key }}: "{{ .Default }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Type "stringslice" }}
|
||||||
|
# Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string) {{ end }}
|
||||||
|
{{ .Key }}: []
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
# Does not have configuration attributes
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if false }}<!-- vim: set ft=markdown: -->{{ end }}
|
|
@ -14,21 +14,20 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
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 {
|
type autoMessage struct {
|
||||||
UUID string `hash:"-" yaml:"uuid"`
|
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
|
||||||
|
|
||||||
Channel string `yaml:"channel"`
|
Channel string `json:"channel,omitempty" yaml:"channel,omitempty"`
|
||||||
Message string `yaml:"message"`
|
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
||||||
UseAction bool `yaml:"use_action"`
|
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"`
|
Cron string `json:"cron,omitempty" yaml:"cron,omitempty"`
|
||||||
MessageInterval int64 `yaml:"message_interval"`
|
MessageInterval int64 `json:"message_interval,omitempty" yaml:"message_interval,omitempty"`
|
||||||
OnlyOnLive bool `yaml:"only_on_live"`
|
OnlyOnLive bool `json:"only_on_live,omitempty" yaml:"only_on_live,omitempty"`
|
||||||
TimeInterval time.Duration `yaml:"time_interval"`
|
|
||||||
|
|
||||||
disabled bool
|
disabled bool
|
||||||
lastMessageSent time.Time
|
lastMessageSent time.Time
|
||||||
|
@ -54,10 +53,6 @@ func (a *autoMessage) CanSend() bool {
|
||||||
// Not enough chatted lines
|
// Not enough chatted lines
|
||||||
return false
|
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 != "":
|
case a.Cron != "":
|
||||||
sched, _ := cronParser.Parse(a.Cron)
|
sched, _ := cronParser.Parse(a.Cron)
|
||||||
nextExecute := sched.Next(a.lastMessageSent)
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,7 +158,7 @@ func (a *autoMessage) Send(c *irc.Client) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
|
func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
|
||||||
if a.DisableOnTemplate == nil {
|
if a.DisableOnTemplate == nil || *a.DisableOnTemplate == "" {
|
||||||
// No match criteria set, does not speak against matching
|
// No match criteria set, does not speak against matching
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
40
botEditor.go
Normal file
40
botEditor.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
"github.com/Luzifer/twitch-bot/twitch"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func botEditorAuthMiddleware(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "no auth-token provided", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tc := twitch.New(cfg.TwitchClient, token)
|
||||||
|
|
||||||
|
user, err := tc.GetAuthorizedUsername()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := tc.GetIDForUsername(user)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting ID for authorized user").Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
|
||||||
|
http.Error(w, "user is not authorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
14
ci/bundle.sh
Normal file
14
ci/bundle.sh
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
outfile=${1:-}
|
||||||
|
[[ -n $outfile ]] || {
|
||||||
|
echo "Missing parameters: $0 <outfile> [libraries]"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shift
|
||||||
|
|
||||||
|
libs=("$@")
|
||||||
|
|
||||||
|
IFS=$','
|
||||||
|
exec curl -sSfLo "${outfile}" "https://cdn.jsdelivr.net/combine/${libs[*]}"
|
178
config.go
178
config.go
|
@ -1,21 +1,58 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Luzifer/twitch-bot/plugins"
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
"github.com/go-irc/irc"
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/gofrs/uuid/v3"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configFile struct {
|
const expectedMinConfigVersion = 2
|
||||||
|
|
||||||
|
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"`
|
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||||
|
BotEditors []string `yaml:"bot_editors"`
|
||||||
Channels []string `yaml:"channels"`
|
Channels []string `yaml:"channels"`
|
||||||
HTTPListen string `yaml:"http_listen"`
|
HTTPListen string `yaml:"http_listen"`
|
||||||
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||||
|
@ -25,7 +62,10 @@ type configFile struct {
|
||||||
Variables map[string]interface{} `yaml:"variables"`
|
Variables map[string]interface{} `yaml:"variables"`
|
||||||
|
|
||||||
rawLogWriter io.WriteCloser
|
rawLogWriter io.WriteCloser
|
||||||
}
|
|
||||||
|
configFileVersioner `yaml:",inline"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
func newConfigFile() *configFile {
|
func newConfigFile() *configFile {
|
||||||
return &configFile{
|
return &configFile{
|
||||||
|
@ -35,19 +75,20 @@ func newConfigFile() *configFile {
|
||||||
|
|
||||||
func loadConfig(filename string) error {
|
func loadConfig(filename string) error {
|
||||||
var (
|
var (
|
||||||
|
configVersion = &configFileVersioner{}
|
||||||
err error
|
err error
|
||||||
tmpConfig *configFile
|
tmpConfig = newConfigFile()
|
||||||
)
|
)
|
||||||
|
|
||||||
switch path.Ext(filename) {
|
if err = parseConfigFromYAML(filename, configVersion, false); err != nil {
|
||||||
case ".yaml", ".yml":
|
return errors.Wrap(err, "parsing config version")
|
||||||
tmpConfig, err = parseConfigFromYAML(filename)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return errors.Errorf("Unknown config format %q", path.Ext(filename))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
return errors.Wrap(err, "parsing config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,11 +100,16 @@ func loadConfig(filename string) error {
|
||||||
log.Warn("Loaded config with empty ruleset")
|
log.Warn("Loaded config with empty ruleset")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = tmpConfig.validateRuleActions(); err != nil {
|
||||||
|
return errors.Wrap(err, "validating rule actions")
|
||||||
|
}
|
||||||
|
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
defer configLock.Unlock()
|
defer configLock.Unlock()
|
||||||
|
|
||||||
tmpConfig.updateAutoMessagesFromConfig(config)
|
tmpConfig.updateAutoMessagesFromConfig(config)
|
||||||
tmpConfig.fixDurations()
|
tmpConfig.fixDurations()
|
||||||
|
tmpConfig.fixMissingUUIDs()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case config != nil && config.RawLog == tmpConfig.RawLog:
|
case config != nil && config.RawLog == tmpConfig.RawLog:
|
||||||
|
@ -96,28 +142,81 @@ func loadConfig(filename string) error {
|
||||||
"channels": len(config.Channels),
|
"channels": len(config.Channels),
|
||||||
}).Info("Config file (re)loaded")
|
}).Info("Config file (re)loaded")
|
||||||
|
|
||||||
|
// Notify listener config has changed
|
||||||
|
configReloadHooksLock.RLock()
|
||||||
|
defer configReloadHooksLock.RUnlock()
|
||||||
|
for _, fn := range configReloadHooks {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseConfigFromYAML(filename string) (*configFile, error) {
|
func parseConfigFromYAML(filename string, obj interface{}, strict bool) error {
|
||||||
f, err := os.Open(filename)
|
f, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "open config file")
|
return errors.Wrap(err, "open config file")
|
||||||
}
|
}
|
||||||
defer f.Close()
|
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 (
|
var (
|
||||||
decoder = yaml.NewDecoder(f)
|
cfgFile = newConfigFile()
|
||||||
tmpConfig = newConfigFile()
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
decoder.SetStrict(true)
|
if err = parseConfigFromYAML(filename, cfgFile, true); err != nil {
|
||||||
|
return errors.Wrap(err, "loading current config")
|
||||||
if err = decoder.Decode(&tmpConfig); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "decode config file")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func (c *configFile) CloseRawMessageWriter() error {
|
||||||
|
@ -155,11 +254,6 @@ func (c *configFile) fixDurations() {
|
||||||
for _, r := range c.Rules {
|
for _, r := range c.Rules {
|
||||||
r.Cooldown = c.fixedDurationPtr(r.Cooldown)
|
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 {
|
func (configFile) fixedDuration(d time.Duration) time.Duration {
|
||||||
|
@ -177,6 +271,22 @@ func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
|
||||||
return &fd
|
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) {
|
func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
||||||
for idx, nam := range c.AutoMessages {
|
for idx, nam := range c.AutoMessages {
|
||||||
// By default assume last message to be sent now
|
// By default assume last message to be sent now
|
||||||
|
@ -208,3 +318,23 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c configFile) validateRuleActions() error {
|
||||||
|
for _, r := range c.Rules {
|
||||||
|
logger := log.WithField("rule", r.MatcherID())
|
||||||
|
for idx, a := range r.Actions {
|
||||||
|
actor, err := getActorByName(a.Type)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithField("index", idx).WithError(err).Error("Cannot get actor by type")
|
||||||
|
return errors.Wrap(err, "getting actor by type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = actor.Validate(a.Attributes); err != nil {
|
||||||
|
logger.WithField("index", idx).WithError(err).Error("Actor reported invalid config")
|
||||||
|
return errors.Wrap(err, "validating action attributes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
597
configEditor.go
Normal file
597
configEditor.go
Normal file
|
@ -0,0 +1,597 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
|
"github.com/Luzifer/twitch-bot/twitch"
|
||||||
|
"github.com/gofrs/uuid/v3"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const websocketPingInterval = 30 * time.Second
|
||||||
|
|
||||||
|
var (
|
||||||
|
availableActorDocs = []plugins.ActionDocumentation{}
|
||||||
|
availableActorDocsLock sync.RWMutex
|
||||||
|
|
||||||
|
//go:embed editor/*
|
||||||
|
configEditorFrontend embed.FS
|
||||||
|
|
||||||
|
upgrader = websocket.Upgrader{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerActorDocumentation(doc plugins.ActionDocumentation) {
|
||||||
|
availableActorDocsLock.Lock()
|
||||||
|
defer availableActorDocsLock.Unlock()
|
||||||
|
|
||||||
|
availableActorDocs = append(availableActorDocs, doc)
|
||||||
|
sort.Slice(availableActorDocs, func(i, j int) bool {
|
||||||
|
return availableActorDocs[i].Name < availableActorDocs[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
configEditorGeneralConfig struct {
|
||||||
|
BotEditors []string `json:"bot_editors"`
|
||||||
|
Channels []string `json:"channels"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerEditorAutoMessageRoutes()
|
||||||
|
registerEditorFrontend()
|
||||||
|
registerEditorGeneralConfigRoutes()
|
||||||
|
registerEditorGlobalMethods()
|
||||||
|
registerEditorRulesRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:funlen // This is a logic unit and shall not be split up
|
||||||
|
func registerEditorAutoMessageRoutes() {
|
||||||
|
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||||
|
{
|
||||||
|
Description: "Returns the current set of configured auto-messages in JSON format",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewEncoder(w).Encode(config.AutoMessages); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Get current auto-messages",
|
||||||
|
Path: "/auto-messages",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Adds a new Auto-Message",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
msg := &autoMessage{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.UUID = uuid.Must(uuid.NewV4()).String()
|
||||||
|
|
||||||
|
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||||
|
c.AutoMessages = append(c.AutoMessages, msg)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
},
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Add Auto-Message",
|
||||||
|
Path: "/auto-messages",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Deletes the given Auto-Message",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||||
|
var (
|
||||||
|
id = mux.Vars(r)["uuid"]
|
||||||
|
tmp []*autoMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range c.AutoMessages {
|
||||||
|
if c.AutoMessages[i].ID() == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tmp = append(tmp, c.AutoMessages[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AutoMessages = tmp
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
},
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Delete Auto-Message",
|
||||||
|
Path: "/auto-messages/{uuid}",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
{
|
||||||
|
Description: "UUID of the auto-message to delete",
|
||||||
|
Name: "uuid",
|
||||||
|
Required: false,
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Updates the given Auto-Message",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
msg := &autoMessage{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||||
|
id := mux.Vars(r)["uuid"]
|
||||||
|
|
||||||
|
for i := range c.AutoMessages {
|
||||||
|
if c.AutoMessages[i].ID() == id {
|
||||||
|
c.AutoMessages[i] = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
},
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Update Auto-Message",
|
||||||
|
Path: "/auto-messages/{uuid}",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||||
|
{
|
||||||
|
Description: "UUID of the auto-message to update",
|
||||||
|
Name: "uuid",
|
||||||
|
Required: false,
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if err := registerRoute(rd); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to register config editor route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerEditorFrontend() {
|
||||||
|
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f, err := configEditorFrontend.Open("editor/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "opening index.html").Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(w, f)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewEncoder(w).Encode(struct {
|
||||||
|
IRCBadges []string
|
||||||
|
KnownEvents []*string
|
||||||
|
TwitchClientID string
|
||||||
|
}{
|
||||||
|
IRCBadges: twitch.KnownBadges,
|
||||||
|
KnownEvents: knownEvents,
|
||||||
|
TwitchClientID: cfg.TwitchClient,
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.PathPrefix("/editor").Handler(http.FileServer(http.FS(configEditorFrontend)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerEditorGeneralConfigRoutes() {
|
||||||
|
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||||
|
{
|
||||||
|
Description: "Returns the current general config",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
||||||
|
BotEditors: config.BotEditors,
|
||||||
|
Channels: config.Channels,
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Get general config",
|
||||||
|
Path: "/general",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Updates the general config",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload configEditorGeneralConfig
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range payload.BotEditors {
|
||||||
|
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.BotEditors[i] = usr.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := patchConfig(cfg.Config, func(cfg *configFile) error {
|
||||||
|
cfg.Channels = payload.Channels
|
||||||
|
cfg.BotEditors = payload.BotEditors
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
},
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Update general config",
|
||||||
|
Path: "/general",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if err := registerRoute(rd); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to register config editor route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:funlen,gocognit,gocyclo // This is a logic unit and shall not be split up
|
||||||
|
func registerEditorGlobalMethods() {
|
||||||
|
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||||
|
{
|
||||||
|
Description: "Returns the documentation for available actions",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
availableActorDocsLock.Lock()
|
||||||
|
defer availableActorDocsLock.Unlock()
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(availableActorDocs); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Get available actions",
|
||||||
|
Path: "/actions",
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Returns information about a Twitch user to properly display bot editors",
|
||||||
|
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usr, err := twitchClient.GetUserInformation(r.FormValue("user"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
36
cors.go
Normal 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
29
default_config.yaml
Normal 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
151
editor/.eslintrc.js
Normal 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
766
editor/app.js
Normal 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
843
editor/index.html
Normal 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…</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>
|
17
events.go
17
events.go
|
@ -17,4 +17,21 @@ var (
|
||||||
eventTypeTwitchStreamOffline = ptrStr("stream_offline")
|
eventTypeTwitchStreamOffline = ptrStr("stream_offline")
|
||||||
eventTypeTwitchStreamOnline = ptrStr("stream_online")
|
eventTypeTwitchStreamOnline = ptrStr("stream_online")
|
||||||
eventTypeTwitchTitleUpdate = ptrStr("title_update")
|
eventTypeTwitchTitleUpdate = ptrStr("title_update")
|
||||||
|
|
||||||
|
knownEvents = []*string{
|
||||||
|
eventTypeJoin,
|
||||||
|
eventTypeHost,
|
||||||
|
eventTypePart,
|
||||||
|
eventTypePermit,
|
||||||
|
eventTypeRaid,
|
||||||
|
eventTypeResub,
|
||||||
|
eventTypeSub,
|
||||||
|
eventTypeSubgift,
|
||||||
|
eventTypeWhisper,
|
||||||
|
|
||||||
|
eventTypeTwitchCategoryUpdate,
|
||||||
|
eventTypeTwitchStreamOffline,
|
||||||
|
eventTypeTwitchStreamOnline,
|
||||||
|
eventTypeTwitchTitleUpdate,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -7,11 +7,14 @@ require (
|
||||||
github.com/Luzifer/korvike/functions v0.6.1
|
github.com/Luzifer/korvike/functions v0.6.1
|
||||||
github.com/Luzifer/rconfig/v2 v2.3.0
|
github.com/Luzifer/rconfig/v2 v2.3.0
|
||||||
github.com/go-irc/irc v2.1.0+incompatible
|
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/mux v1.7.4
|
||||||
|
github.com/gorilla/websocket v1.4.2
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
|
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,7 +35,6 @@ require (
|
||||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // 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/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
||||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
|
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -78,6 +78,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||||
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/go-test/deep v1.0.2-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/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/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.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.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
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/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 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
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 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
|
7
helpers.go
Normal file
7
helpers.go
Normal 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 }("")
|
||||||
|
)
|
|
@ -1,26 +1,51 @@
|
||||||
package ban
|
package ban
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"strings"
|
||||||
|
|
||||||
"github.com/Luzifer/twitch-bot/plugins"
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
"github.com/go-irc/irc"
|
"github.com/go-irc/irc"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "ban"
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
type actor struct{}
|
||||||
Ban *string `json:"ban" yaml:"ban"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.Ban == nil {
|
ptrStringEmpty := func(v string) *string { return &v }("")
|
||||||
return false, nil
|
|
||||||
|
cmd := []string{
|
||||||
|
"/ban",
|
||||||
|
plugins.DeriveUser(m, eventData),
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason := attrs.MustString("reason", ptrStringEmpty); reason != "" {
|
||||||
|
cmd = append(cmd, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, errors.Wrap(
|
return false, errors.Wrap(
|
||||||
|
@ -28,12 +53,14 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||||
Command: "PRIVMSG",
|
Command: "PRIVMSG",
|
||||||
Params: []string{
|
Params: []string{
|
||||||
plugins.DeriveChannel(m, eventData),
|
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) 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 }
|
||||||
|
|
|
@ -8,25 +8,57 @@ import (
|
||||||
"github.com/go-irc/irc"
|
"github.com/go-irc/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "delay"
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
type actor struct{}
|
||||||
Delay time.Duration `json:"delay" yaml:"delay"`
|
|
||||||
DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.Delay == 0 && a.DelayJitter == 0 {
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDelay := a.Delay
|
totalDelay := delay
|
||||||
if a.DelayJitter > 0 {
|
if jitter > 0 {
|
||||||
totalDelay += time.Duration(rand.Int63n(int64(a.DelayJitter))) // #nosec: G404 // It's just time, no need for crypto/rand
|
totalDelay += time.Duration(rand.Int63n(int64(jitter))) // #nosec: G404 // It's just time, no need for crypto/rand
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(totalDelay)
|
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) 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 }
|
||||||
|
|
|
@ -8,21 +8,23 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "delete"
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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")
|
msgID, ok := m.Tags.GetTag("id")
|
||||||
if !ok || msgID == "" {
|
if !ok || msgID == "" {
|
||||||
return false, nil
|
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) 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 }
|
||||||
|
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "modchannel"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
formatMessage plugins.MsgFormatter
|
formatMessage plugins.MsgFormatter
|
||||||
twitchClient *twitch.Client
|
twitchClient *twitch.Client
|
||||||
|
@ -19,52 +21,98 @@ func Register(args plugins.RegistrationArguments) error {
|
||||||
formatMessage = args.FormatMessage
|
formatMessage = args.FormatMessage
|
||||||
twitchClient = args.GetTwitchClient()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.UpdateGame == nil && a.UpdateTitle == nil {
|
var (
|
||||||
|
ptrStringEmpty = func(v string) *string { return &v }("")
|
||||||
|
game = attrs.MustString("game", ptrStringEmpty)
|
||||||
|
title = attrs.MustString("title", ptrStringEmpty)
|
||||||
|
)
|
||||||
|
|
||||||
|
if game == "" && title == "" {
|
||||||
return false, nil
|
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 {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "parsing channel")
|
return false, errors.Wrap(err, "parsing channel")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.UpdateGame != nil {
|
if game != "" {
|
||||||
parsedGame, err := formatMessage(*a.UpdateGame, m, r, eventData)
|
parsedGame, err := formatMessage(game, m, r, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "parsing game")
|
return false, errors.Wrap(err, "parsing game")
|
||||||
}
|
}
|
||||||
|
|
||||||
game = &parsedGame
|
updGame = &parsedGame
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.UpdateTitle != nil {
|
if title != "" {
|
||||||
parsedTitle, err := formatMessage(*a.UpdateTitle, m, r, eventData)
|
parsedTitle, err := formatMessage(title, m, r, eventData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "parsing title")
|
return false, errors.Wrap(err, "parsing title")
|
||||||
}
|
}
|
||||||
|
|
||||||
title = &parsedTitle
|
updTitle = &parsedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, errors.Wrap(
|
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",
|
"updating channel info",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a actor) IsAsync() bool { return false }
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -6,26 +6,40 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "raw"
|
||||||
|
|
||||||
var formatMessage plugins.MsgFormatter
|
var formatMessage plugins.MsgFormatter
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
func Register(args plugins.RegistrationArguments) error {
|
||||||
formatMessage = args.FormatMessage
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
type actor struct{}
|
||||||
RawMessage *string `json:"raw_message" yaml:"raw_message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.RawMessage == nil {
|
rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMsg, err := formatMessage(*a.RawMessage, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing raw message")
|
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) 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
|
||||||
|
}
|
||||||
|
|
|
@ -9,41 +9,84 @@ import (
|
||||||
"github.com/pkg/errors"
|
"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 {
|
func Register(args plugins.RegistrationArguments) error {
|
||||||
formatMessage = args.FormatMessage
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.Respond == nil {
|
msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := formatMessage(*a.Respond, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if a.RespondFallback == nil {
|
if attrs.CanString("fallback") {
|
||||||
return false, errors.Wrap(err, "preparing response")
|
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")
|
return false, errors.Wrap(err, "preparing response fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toChannel := plugins.DeriveChannel(m, eventData)
|
toChannel := plugins.DeriveChannel(m, eventData)
|
||||||
if a.ToChannel != nil {
|
if attrs.CanString("to_channel") && attrs.MustString("to_channel", nil) != "" {
|
||||||
toChannel = fmt.Sprintf("#%s", strings.TrimLeft(*a.ToChannel, "#"))
|
toChannel = fmt.Sprintf("#%s", strings.TrimLeft(attrs.MustString("to_channel", nil), "#"))
|
||||||
}
|
}
|
||||||
|
|
||||||
ircMessage := &irc.Message{
|
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")
|
id, ok := m.GetTag("id")
|
||||||
if ok {
|
if ok {
|
||||||
if ircMessage.Tags == nil {
|
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) 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
|
||||||
|
}
|
||||||
|
|
|
@ -9,27 +9,41 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "timeout"
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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(
|
return false, errors.Wrap(
|
||||||
c.WriteMessage(&irc.Message{
|
c.WriteMessage(&irc.Message{
|
||||||
Command: "PRIVMSG",
|
Command: "PRIVMSG",
|
||||||
Params: []string{
|
Params: []string{
|
||||||
plugins.DeriveChannel(m, eventData),
|
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",
|
"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) 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 {
|
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||||
if d >= time.Second {
|
if v, err := attrs.Duration("duration"); err != nil || v < time.Second {
|
||||||
return d
|
return errors.New("duration must be of type duration greater or equal one second")
|
||||||
}
|
}
|
||||||
|
|
||||||
return d * time.Second
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,32 +8,54 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const actorName = "whisper"
|
||||||
|
|
||||||
var formatMessage plugins.MsgFormatter
|
var formatMessage plugins.MsgFormatter
|
||||||
|
|
||||||
func Register(args plugins.RegistrationArguments) error {
|
func Register(args plugins.RegistrationArguments) error {
|
||||||
formatMessage = args.FormatMessage
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type actor struct {
|
type actor struct{}
|
||||||
WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"`
|
|
||||||
WhisperTo *string `json:"whisper_to" yaml:"whisper_to"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||||
if a.WhisperTo == nil || a.WhisperMessage == nil {
|
to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
to, err := formatMessage(*a.WhisperTo, m, r, eventData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing whisper receiver")
|
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 {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "preparing whisper message")
|
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) 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
33
main.go
|
@ -1,7 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -86,7 +88,8 @@ func main() {
|
||||||
twitchWatch := newTwitchWatcher()
|
twitchWatch := newTwitchWatcher()
|
||||||
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
|
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)
|
router.HandleFunc("/openapi.json", handleSwaggerRequest)
|
||||||
|
|
||||||
if err = initCorePlugins(); err != nil {
|
if err = initCorePlugins(); err != nil {
|
||||||
|
@ -97,7 +100,27 @@ func main() {
|
||||||
log.WithError(err).Fatal("Unable to load plugins")
|
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 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")
|
log.WithError(err).Fatal("Initial config load failed")
|
||||||
}
|
}
|
||||||
defer func() { config.CloseRawMessageWriter() }()
|
defer func() { config.CloseRawMessageWriter() }()
|
||||||
|
@ -134,7 +157,13 @@ func main() {
|
||||||
|
|
||||||
if config.HTTPListen != "" {
|
if config.HTTPListen != "" {
|
||||||
// If listen address is configured start HTTP server
|
// 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{}{}
|
ircDisconnected <- struct{}{}
|
||||||
|
|
32
plugins/action_docs.go
Normal file
32
plugins/action_docs.go
Normal 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"
|
||||||
|
)
|
|
@ -16,6 +16,26 @@ var (
|
||||||
|
|
||||||
type FieldCollection map[string]interface{}
|
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 {
|
func (f FieldCollection) Expect(keys ...string) error {
|
||||||
var missing []string
|
var missing []string
|
||||||
|
|
||||||
|
@ -32,6 +52,16 @@ func (f FieldCollection) Expect(keys ...string) error {
|
||||||
return nil
|
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 {
|
func (f FieldCollection) MustBool(name string, defVal *bool) bool {
|
||||||
v, err := f.Bool(name)
|
v, err := f.Bool(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -19,6 +19,7 @@ type (
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
QueryParams []HTTPRouteParamDocumentation
|
QueryParams []HTTPRouteParamDocumentation
|
||||||
|
RequiresEditorsAuth bool
|
||||||
ResponseType HTTPRouteResponseType
|
ResponseType HTTPRouteResponseType
|
||||||
RouteParams []HTTPRouteParamDocumentation
|
RouteParams []HTTPRouteParamDocumentation
|
||||||
SkipDocumentation bool
|
SkipDocumentation bool
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
type (
|
type (
|
||||||
Actor interface {
|
Actor interface {
|
||||||
// Execute will be called after the config was read into the Actor
|
// 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
|
// 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
|
// in a Go routine as of long runtime. Normally it should return false
|
||||||
// except in very specific cases
|
// except in very specific cases
|
||||||
|
@ -18,11 +18,17 @@ type (
|
||||||
// Name must return an unique name for the actor in order to identify
|
// Name must return an unique name for the actor in order to identify
|
||||||
// it in the logs for debugging purposes
|
// it in the logs for debugging purposes
|
||||||
Name() string
|
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
|
ActorCreationFunc func() Actor
|
||||||
|
|
||||||
ActorRegistrationFunc func(ActorCreationFunc)
|
ActorRegistrationFunc func(name string, acf ActorCreationFunc)
|
||||||
|
|
||||||
|
ActorDocumentationRegistrationFunc func(ActionDocumentation)
|
||||||
|
|
||||||
CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error)
|
CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error)
|
||||||
|
|
||||||
|
@ -45,6 +51,8 @@ type (
|
||||||
GetTwitchClient func() *twitch.Client
|
GetTwitchClient func() *twitch.Client
|
||||||
// RegisterActor is used to register a new IRC rule-actor implementing the Actor interface
|
// RegisterActor is used to register a new IRC rule-actor implementing the Actor interface
|
||||||
RegisterActor ActorRegistrationFunc
|
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 registers a new HTTP handler function including documentation
|
||||||
RegisterAPIRoute HTTPRouteRegistrationFunc
|
RegisterAPIRoute HTTPRouteRegistrationFunc
|
||||||
// RegisterCron is a method to register cron functions in the global cron instance
|
// RegisterCron is a method to register cron functions in the global cron instance
|
||||||
|
|
|
@ -14,29 +14,31 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Rule struct {
|
type (
|
||||||
UUID string `hash:"-" yaml:"uuid"`
|
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"`
|
Cooldown *time.Duration `json:"cooldown,omitempty" yaml:"cooldown,omitempty"`
|
||||||
ChannelCooldown *time.Duration `yaml:"channel_cooldown"`
|
ChannelCooldown *time.Duration `json:"channel_cooldown,omitempty" yaml:"channel_cooldown,omitempty"`
|
||||||
UserCooldown *time.Duration `yaml:"user_cooldown"`
|
UserCooldown *time.Duration `json:"user_cooldown,omitempty" yaml:"user_cooldown,omitempty"`
|
||||||
SkipCooldownFor []string `yaml:"skip_cooldown_for"`
|
SkipCooldownFor []string `json:"skip_cooldown_for,omitempty" yaml:"skip_cooldown_for,omitempty"`
|
||||||
|
|
||||||
MatchChannels []string `yaml:"match_channels"`
|
MatchChannels []string `json:"match_channels,omitempty" yaml:"match_channels,omitempty"`
|
||||||
MatchEvent *string `yaml:"match_event"`
|
MatchEvent *string `json:"match_event,omitempty" yaml:"match_event,omitempty"`
|
||||||
MatchMessage *string `yaml:"match_message"`
|
MatchMessage *string `json:"match_message,omitempty" yaml:"match_message,omitempty"`
|
||||||
MatchUsers []string `yaml:"match_users" `
|
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"`
|
Disable *bool `json:"disable,omitempty" yaml:"disable,omitempty"`
|
||||||
DisableOnOffline *bool `yaml:"disable_on_offline"`
|
DisableOnOffline *bool `json:"disable_on_offline,omitempty" yaml:"disable_on_offline,omitempty"`
|
||||||
DisableOnPermit *bool `yaml:"disable_on_permit"`
|
DisableOnPermit *bool `json:"disable_on_permit,omitempty" yaml:"disable_on_permit,omitempty"`
|
||||||
DisableOnTemplate *string `yaml:"disable_on_template"`
|
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
|
||||||
DisableOn []string `yaml:"disable_on"`
|
DisableOn []string `json:"disable_on,omitempty" yaml:"disable_on,omitempty"`
|
||||||
EnableOn []string `yaml:"enable_on"`
|
EnableOn []string `json:"enable_on,omitempty" yaml:"enable_on,omitempty"`
|
||||||
|
|
||||||
matchMessage *regexp.Regexp
|
matchMessage *regexp.Regexp
|
||||||
disableOnMatchMessages []*regexp.Regexp
|
disableOnMatchMessages []*regexp.Regexp
|
||||||
|
@ -44,7 +46,13 @@ type Rule struct {
|
||||||
msgFormatter MsgFormatter
|
msgFormatter MsgFormatter
|
||||||
timerStore TimerStore
|
timerStore TimerStore
|
||||||
twitchClient *twitch.Client
|
twitchClient *twitch.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RuleAction struct {
|
||||||
|
Type string `json:"type" yaml:"type,omitempty"`
|
||||||
|
Attributes FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
func (r Rule) MatcherID() string {
|
func (r Rule) MatcherID() string {
|
||||||
if r.UUID != "" {
|
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 {
|
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
|
// No match criteria set, does not speak against matching
|
||||||
return true
|
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 {
|
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
|
// No match criteria set, does not speak against matching
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
13
swagger.go
13
swagger.go
|
@ -31,6 +31,9 @@ var (
|
||||||
"inputErrorResponse": spec.TextPlainResponse(nil).WithDescription("Data sent to API is invalid: See error message"),
|
"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"),
|
"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 {
|
switch route.ResponseType {
|
||||||
case plugins.HTTPRouteResponseTypeJSON:
|
case plugins.HTTPRouteResponseTypeJSON:
|
||||||
op.Responses["200"] = spec.JSONResponse(nil).WithDescription("Successful execution with JSON object response")
|
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).
|
specParam := spec.QueryParam(param.Name, ps).
|
||||||
WithDescription(param.Description)
|
WithDescription(param.Description)
|
||||||
|
|
||||||
if !param.Required {
|
specParam.Required = param.Required
|
||||||
specParam = specParam.AsOptional()
|
|
||||||
}
|
|
||||||
|
|
||||||
op.Parameters = append(
|
op.Parameters = append(
|
||||||
op.Parameters,
|
op.Parameters,
|
||||||
|
|
|
@ -12,8 +12,17 @@ const (
|
||||||
BadgeFounder = "founder"
|
BadgeFounder = "founder"
|
||||||
BadgeModerator = "moderator"
|
BadgeModerator = "moderator"
|
||||||
BadgeSubscriber = "subscriber"
|
BadgeSubscriber = "subscriber"
|
||||||
|
BadgeVIP = "vip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var KnownBadges = []string{
|
||||||
|
BadgeBroadcaster,
|
||||||
|
BadgeFounder,
|
||||||
|
BadgeModerator,
|
||||||
|
BadgeSubscriber,
|
||||||
|
BadgeVIP,
|
||||||
|
}
|
||||||
|
|
||||||
type BadgeCollection map[string]*int
|
type BadgeCollection map[string]*int
|
||||||
|
|
||||||
func ParseBadgeLevels(m *irc.Message) BadgeCollection {
|
func ParseBadgeLevels(m *irc.Message) BadgeCollection {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/backoff"
|
"github.com/Luzifer/go_helpers/v2/backoff"
|
||||||
|
@ -37,6 +38,13 @@ type (
|
||||||
|
|
||||||
apiCache *APICache
|
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 {
|
func New(clientID, token string) *Client {
|
||||||
|
@ -52,10 +60,7 @@ func (c Client) APICache() *APICache { return c.apiCache }
|
||||||
|
|
||||||
func (c Client) GetAuthorizedUsername() (string, error) {
|
func (c Client) GetAuthorizedUsername() (string, error) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data []struct {
|
Data []User `json:"data"`
|
||||||
ID string `json:"id"`
|
|
||||||
Login string `json:"login"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(
|
||||||
|
@ -82,11 +87,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data []struct {
|
Data []User `json:"data"`
|
||||||
ID string `json:"id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Login string `json:"login"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(
|
||||||
|
@ -150,6 +151,46 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||||
return payload.Data[0].FollowedAt, nil
|
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) {
|
func (c Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
|
||||||
var out []Category
|
var out []Category
|
||||||
|
|
||||||
|
@ -219,10 +260,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data []struct {
|
Data []User `json:"data"`
|
||||||
ID string `json:"id"`
|
|
||||||
Login string `json:"login"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(
|
||||||
|
|
189
wiki/Actors.md
Normal file
189
wiki/Actors.md
Normal 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
|
||||||
|
```
|
72
wiki/Home.md
72
wiki/Home.md
|
@ -3,7 +3,18 @@
|
||||||
```yaml
|
```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:
|
channels:
|
||||||
- mychannel
|
- mychannel
|
||||||
|
|
||||||
|
@ -23,6 +34,8 @@ variables:
|
||||||
myvariable: true
|
myvariable: true
|
||||||
anothervariable: "string"
|
anothervariable: "string"
|
||||||
|
|
||||||
|
# List of auto-messages. See documentation for details or use
|
||||||
|
# web-interface to configure.
|
||||||
auto_messages:
|
auto_messages:
|
||||||
- channel: 'mychannel' # String, channel to send message to
|
- channel: 'mychannel' # String, channel to send message to
|
||||||
message: 'Automated message' # String, message to send
|
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
|
# 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
|
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
|
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
|
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 message using templating, must yield string `true` to disable the automated message
|
||||||
disable_on_template: '{{ ne .myvariable true }}'
|
disable_on_template: '{{ ne .myvariable true }}'
|
||||||
|
|
||||||
|
# List of rules. See documentation for details or use web-interface
|
||||||
|
# to configure.
|
||||||
rules: # See below for examples
|
rules: # See below for examples
|
||||||
|
|
||||||
- actions: # Array of actions to take when this rule matches
|
- actions: # Array of actions to take when this rule matches
|
||||||
|
|
||||||
# Issue a ban on the user who wrote the chat-line
|
# See the Actors page in the Wiki for available actors:
|
||||||
- ban: "reason of ban"
|
# https://github.com/Luzifer/twitch-bot/wiki/Actors
|
||||||
|
- type: "<actor type>"
|
||||||
# Command to execute for the chat message, must return an JSON encoded array of actions
|
attributes:
|
||||||
- command: [/bin/bash, -c, "echo '[{\"respond\": \"Text\"}]'"]
|
key: value
|
||||||
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
|
|
||||||
|
|
||||||
# Add a cooldown to the rule in general (not to trigger counters twice, ...)
|
# 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
|
# Using this will prevent the rule to be executed in all matching channels
|
||||||
|
|
Loading…
Reference in a new issue