mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-09-07 20:39:07 +00:00
Breaking: Add configuration interface and switch to more generic config format (#7)
This commit is contained in:
parent
a1e9c35430
commit
b59676492e
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
config
|
||||
config.hcl
|
||||
config.yaml
|
||||
editor/bundle.*
|
||||
.env
|
||||
storage.json.gz
|
||||
twitch-bot
|
||||
|
@ -3,16 +3,19 @@
|
||||
---
|
||||
|
||||
run:
|
||||
skip-dirs:
|
||||
- config
|
||||
skip-files:
|
||||
- assets.go
|
||||
- bindata.go
|
||||
# timeout for analysis, e.g. 30s, 5m, default is 1m
|
||||
timeout: 5m
|
||||
# Force readonly modules usage for checking
|
||||
modules-download-mode: readonly
|
||||
|
||||
output:
|
||||
format: tab
|
||||
|
||||
linters-settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- 'fmt\.Errorf' # Should use github.com/pkg/errors
|
||||
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 60
|
||||
@ -21,6 +24,11 @@ linters-settings:
|
||||
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
||||
min-complexity: 15
|
||||
|
||||
gomnd:
|
||||
settings:
|
||||
mnd:
|
||||
ignored-functions: 'strconv.(?:Format|Parse)\B+'
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
@ -30,6 +38,7 @@ linters:
|
||||
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
|
||||
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false]
|
||||
- exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false]
|
||||
- forbidigo # Forbids identifiers [fast: true, auto-fix: false]
|
||||
- funlen # Tool for detection of long functions [fast: true, auto-fix: false]
|
||||
- gocognit # Computes and checks the cognitive complexity of functions [fast: true, auto-fix: false]
|
||||
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
|
||||
@ -37,6 +46,7 @@ linters:
|
||||
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
|
||||
- godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false]
|
||||
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
|
||||
- gofumpt # Gofumpt checks whether code was gofumpt-ed. [fast: true, auto-fix: true]
|
||||
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
|
||||
- gomnd # An analyzer to detect magic numbers. [fast: true, auto-fix: false]
|
||||
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]
|
||||
|
@ -6,7 +6,12 @@ WORKDIR /go/src/github.com/Luzifer/twitch-bot
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
RUN set -ex \
|
||||
&& apk add --update git \
|
||||
&& apk add --update \
|
||||
bash \
|
||||
curl \
|
||||
git \
|
||||
make \
|
||||
&& make frontend \
|
||||
&& go install \
|
||||
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
|
||||
-mod=readonly
|
||||
|
23
Makefile
23
Makefile
@ -3,15 +3,36 @@ default: lint test
|
||||
lint:
|
||||
golangci-lint run --timeout=5m
|
||||
|
||||
publish:
|
||||
publish: frontend
|
||||
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
|
||||
bash golang.sh
|
||||
|
||||
test:
|
||||
go test -cover -v ./...
|
||||
|
||||
# --- Editor frontend
|
||||
|
||||
frontend: editor/bundle.css
|
||||
frontend: editor/bundle.js
|
||||
|
||||
editor/bundle.js:
|
||||
bash ci/bundle.sh $@ \
|
||||
npm/axios@0.21.4/dist/axios.min.js \
|
||||
npm/vue@2 \
|
||||
npm/bootstrap-vue@2/dist/bootstrap-vue.min.js \
|
||||
npm/moment@2
|
||||
|
||||
editor/bundle.css:
|
||||
bash ci/bundle.sh $@ \
|
||||
npm/bootstrap@4/dist/css/bootstrap.min.css \
|
||||
npm/bootstrap-vue@2/dist/bootstrap-vue.min.css \
|
||||
npm/bootswatch@4/dist/darkly/bootstrap.min.css
|
||||
|
||||
# --- Wiki Updates
|
||||
|
||||
actor_docs:
|
||||
go run . actor-docs >wiki/Actors.md
|
||||
|
||||
pull_wiki:
|
||||
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash
|
||||
|
||||
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/internal/actors/ban"
|
||||
"github.com/Luzifer/twitch-bot/internal/actors/delay"
|
||||
@ -43,12 +44,17 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||
PathPrefix(fmt.Sprintf("/%s/", route.Module)).
|
||||
Subrouter()
|
||||
|
||||
var hdl http.Handler = route.HandlerFunc
|
||||
if route.RequiresEditorsAuth {
|
||||
hdl = botEditorAuthMiddleware(hdl)
|
||||
}
|
||||
|
||||
if route.IsPrefix {
|
||||
r.PathPrefix(route.Path).
|
||||
HandlerFunc(route.HandlerFunc).
|
||||
Handler(hdl).
|
||||
Methods(route.Method)
|
||||
} else {
|
||||
r.HandleFunc(route.Path, route.HandlerFunc).
|
||||
r.Handle(route.Path, hdl).
|
||||
Methods(route.Method)
|
||||
}
|
||||
|
||||
@ -61,14 +67,15 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||
|
||||
func getRegistrationArguments() plugins.RegistrationArguments {
|
||||
return plugins.RegistrationArguments{
|
||||
FormatMessage: formatMessage,
|
||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||
RegisterActor: registerAction,
|
||||
RegisterAPIRoute: registerRoute,
|
||||
RegisterCron: cronService.AddFunc,
|
||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||
RegisterTemplateFunction: tplFuncs.Register,
|
||||
SendMessage: sendMessage,
|
||||
FormatMessage: formatMessage,
|
||||
GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) },
|
||||
GetTwitchClient: func() *twitch.Client { return twitchClient },
|
||||
RegisterActor: registerAction,
|
||||
RegisterActorDocumentation: registerActorDocumentation,
|
||||
RegisterAPIRoute: registerRoute,
|
||||
RegisterCron: cronService.AddFunc,
|
||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||
RegisterTemplateFunction: tplFuncs.Register,
|
||||
SendMessage: sendMessage,
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,43 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorCounter{} })
|
||||
registerAction("counter", func() plugins.Actor { return &ActorCounter{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Update counter values",
|
||||
Name: "Modify Counter",
|
||||
Type: "counter",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Name of the counter to update",
|
||||
Key: "counter",
|
||||
Name: "Counter",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "1",
|
||||
Description: "Value to add to the counter",
|
||||
Key: "counter_step",
|
||||
Name: "Counter Step",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeInt64,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Value to set the counter to",
|
||||
Key: "counter_set",
|
||||
Name: "Counter Set",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||
Description: "Returns the (formatted) value as a plain string",
|
||||
@ -68,29 +104,21 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
type ActorCounter struct {
|
||||
CounterSet *string `json:"counter_set" yaml:"counter_set"`
|
||||
CounterStep *int64 `json:"counter_step" yaml:"counter_step"`
|
||||
Counter *string `json:"counter" yaml:"counter"`
|
||||
}
|
||||
type ActorCounter struct{}
|
||||
|
||||
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Counter == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
counterName, err := formatMessage(*a.Counter, m, r, eventData)
|
||||
func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing response")
|
||||
}
|
||||
|
||||
if a.CounterSet != nil {
|
||||
parseValue, err := formatMessage(*a.CounterSet, m, r, eventData)
|
||||
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" {
|
||||
parseValue, err := formatMessage(counterSet, m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "execute counter value template")
|
||||
}
|
||||
|
||||
counterValue, err := strconv.ParseInt(parseValue, 10, 64) //nolint:gomnd // Those numbers are static enough
|
||||
counterValue, err := strconv.ParseInt(parseValue, 10, 64)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "parse counter value")
|
||||
}
|
||||
@ -102,8 +130,8 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
|
||||
}
|
||||
|
||||
var counterStep int64 = 1
|
||||
if a.CounterStep != nil {
|
||||
counterStep = *a.CounterStep
|
||||
if s := attrs.MustInt64("counter_step", ptrIntZero); s != 0 {
|
||||
counterStep = s
|
||||
}
|
||||
|
||||
return false, errors.Wrap(
|
||||
@ -115,6 +143,14 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
|
||||
func (a ActorCounter) IsAsync() bool { return false }
|
||||
func (a ActorCounter) Name() string { return "counter" }
|
||||
|
||||
func (a ActorCounter) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if cn, err := attrs.String("counter"); err != nil || cn == "" {
|
||||
return errors.New("counter name must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
|
||||
template := r.FormValue("template")
|
||||
if template == "" {
|
||||
@ -132,7 +168,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
|
||||
value int64
|
||||
)
|
||||
|
||||
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil { //nolint:gomnd // Those numbers are static enough
|
||||
if value, err = strconv.ParseInt(r.FormValue("value"), 10, 64); err != nil {
|
||||
http.Error(w, errors.Wrap(err, "parsing value").Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
@ -14,27 +14,51 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorScript{} })
|
||||
registerAction("script", func() plugins.Actor { return &ActorScript{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Execute external script / command",
|
||||
Name: "Execute Script / Command",
|
||||
Type: "script",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Command to execute",
|
||||
Key: "command",
|
||||
Name: "Command",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeStringSlice,
|
||||
},
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Do not activate cooldown for route when command exits non-zero",
|
||||
Key: "skip_cooldown_on_error",
|
||||
Name: "Skip Cooldown on Error",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeBool,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ActorScript struct {
|
||||
Command []string `json:"command" yaml:"command"`
|
||||
SkipCooldownOnError bool `json:"skip_cooldown_on_error" yaml:"skip_cooldown_on_error"`
|
||||
}
|
||||
type ActorScript struct{}
|
||||
|
||||
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if len(a.Command) == 0 {
|
||||
return false, nil
|
||||
func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
command, err := attrs.StringSlice("command")
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting command")
|
||||
}
|
||||
|
||||
var command []string
|
||||
for _, arg := range a.Command {
|
||||
tmp, err := formatMessage(arg, m, r, eventData)
|
||||
for i := range command {
|
||||
tmp, err := formatMessage(command[i], m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "execute command argument template")
|
||||
}
|
||||
|
||||
command = append(command, tmp)
|
||||
command[i] = tmp
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout)
|
||||
@ -67,7 +91,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
||||
cmd.Stdout = stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return a.SkipCooldownOnError, errors.Wrap(err, "running command")
|
||||
return attrs.MustBool("skip_cooldown_on_error", ptrBoolFalse), errors.Wrap(err, "running command")
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
@ -86,7 +110,7 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
apc, err := triggerActions(c, m, r, action, eventData)
|
||||
apc, err := triggerAction(c, m, r, action, eventData)
|
||||
if err != nil {
|
||||
return preventCooldown, errors.Wrap(err, "execute returned action")
|
||||
}
|
||||
@ -98,3 +122,11 @@ func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
|
||||
|
||||
func (a ActorScript) IsAsync() bool { return false }
|
||||
func (a ActorScript) Name() string { return "script" }
|
||||
|
||||
func (a ActorScript) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if cmd, err := attrs.StringSlice("command"); err != nil || len(cmd) == 0 {
|
||||
return errors.New("command must be slice of strings with length > 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -11,7 +11,43 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerAction(func() plugins.Actor { return &ActorSetVariable{} })
|
||||
registerAction("setvariable", func() plugins.Actor { return &ActorSetVariable{} })
|
||||
|
||||
registerActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Modify variable contents",
|
||||
Name: "Modify Variable",
|
||||
Type: "setvariable",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Name of the variable to update",
|
||||
Key: "variable",
|
||||
Name: "Variable",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Clear variable content and unset the variable",
|
||||
Key: "clear",
|
||||
Name: "Clear",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeBool,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Value to set the variable to",
|
||||
Key: "set",
|
||||
Name: "Set Content",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
registerRoute(plugins.HTTPRouteRegistrationArgs{
|
||||
Description: "Returns the value as a plain string",
|
||||
@ -53,30 +89,22 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
type ActorSetVariable struct {
|
||||
Variable string `json:"variable" yaml:"variable"`
|
||||
Clear bool `json:"clear" yaml:"clear"`
|
||||
Set string `json:"set" yaml:"set"`
|
||||
}
|
||||
type ActorSetVariable struct{}
|
||||
|
||||
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Variable == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
varName, err := formatMessage(a.Variable, m, r, eventData)
|
||||
func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing variable name")
|
||||
}
|
||||
|
||||
if a.Clear {
|
||||
if attrs.MustBool("clear", ptrBoolFalse) {
|
||||
return false, errors.Wrap(
|
||||
store.RemoveVariable(varName),
|
||||
"removing variable",
|
||||
)
|
||||
}
|
||||
|
||||
value, err := formatMessage(a.Set, m, r, eventData)
|
||||
value, err := formatMessage(attrs.MustString("set", ptrStringEmpty), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing value")
|
||||
}
|
||||
@ -90,6 +118,14 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
|
||||
func (a ActorSetVariable) IsAsync() bool { return false }
|
||||
func (a ActorSetVariable) Name() string { return "setvariable" }
|
||||
|
||||
func (a ActorSetVariable) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("variable"); err != nil || v == "" {
|
||||
return errors.New("variable name must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text-plain")
|
||||
fmt.Fprint(w, store.GetVariable(mux.Vars(r)["name"]))
|
||||
|
78
actions.go
78
actions.go
@ -10,52 +10,58 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
availableActions []plugins.ActorCreationFunc
|
||||
availableActions = map[string]plugins.ActorCreationFunc{}
|
||||
availableActionsLock = new(sync.RWMutex)
|
||||
)
|
||||
|
||||
// Compile-time assertion
|
||||
var _ plugins.ActorRegistrationFunc = registerAction
|
||||
|
||||
func registerAction(af plugins.ActorCreationFunc) {
|
||||
availableActionsLock.Lock()
|
||||
defer availableActionsLock.Unlock()
|
||||
|
||||
availableActions = append(availableActions, af)
|
||||
}
|
||||
|
||||
func triggerActions(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
func getActorByName(name string) (plugins.Actor, error) {
|
||||
availableActionsLock.RLock()
|
||||
defer availableActionsLock.RUnlock()
|
||||
|
||||
for _, acf := range availableActions {
|
||||
var (
|
||||
a = acf()
|
||||
logger = log.WithField("actor", a.Name())
|
||||
)
|
||||
|
||||
if err := ra.Unmarshal(a); err != nil {
|
||||
logger.WithError(err).Trace("Unable to unmarshal config")
|
||||
continue
|
||||
}
|
||||
|
||||
if a.IsAsync() {
|
||||
go func() {
|
||||
if _, err := a.Execute(c, m, rule, eventData); err != nil {
|
||||
logger.WithError(err).Error("Error in async actor")
|
||||
}
|
||||
}()
|
||||
continue
|
||||
}
|
||||
|
||||
apc, err := a.Execute(c, m, rule, eventData)
|
||||
preventCooldown = preventCooldown || apc
|
||||
if err != nil {
|
||||
return preventCooldown, errors.Wrap(err, "execute action")
|
||||
}
|
||||
acf, ok := availableActions[name]
|
||||
if !ok {
|
||||
return nil, errors.Errorf("undefined actor %q called", name)
|
||||
}
|
||||
|
||||
return preventCooldown, nil
|
||||
return acf(), nil
|
||||
}
|
||||
|
||||
func registerAction(name string, acf plugins.ActorCreationFunc) {
|
||||
availableActionsLock.Lock()
|
||||
defer availableActionsLock.Unlock()
|
||||
|
||||
if _, ok := availableActions[name]; ok {
|
||||
log.WithField("name", name).Fatal("Duplicate registration of actor")
|
||||
}
|
||||
|
||||
availableActions[name] = acf
|
||||
}
|
||||
|
||||
func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
availableActionsLock.RLock()
|
||||
defer availableActionsLock.RUnlock()
|
||||
|
||||
a, err := getActorByName(ra.Type)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting actor")
|
||||
}
|
||||
|
||||
logger := log.WithField("actor", a.Name())
|
||||
|
||||
if a.IsAsync() {
|
||||
go func() {
|
||||
if _, err := a.Execute(c, m, rule, eventData, ra.Attributes); err != nil {
|
||||
logger.WithError(err).Error("Error in async actor")
|
||||
}
|
||||
}()
|
||||
return preventCooldown, nil
|
||||
}
|
||||
|
||||
apc, err := a.Execute(c, m, rule, eventData, ra.Attributes)
|
||||
return apc, errors.Wrap(err, "execute action")
|
||||
}
|
||||
|
||||
func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugins.FieldCollection) {
|
||||
@ -63,7 +69,7 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData plugi
|
||||
var preventCooldown bool
|
||||
|
||||
for _, a := range r.Actions {
|
||||
apc, err := triggerActions(c, m, r, a, eventData)
|
||||
apc, err := triggerAction(c, m, r, a, eventData)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to trigger action")
|
||||
break // Break execution when one action fails
|
||||
|
27
actorDocs.go
Normal file
27
actorDocs.go
Normal file
@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"text/template"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
//go:embed actorDocs.tpl
|
||||
var actorDocsTemplate string
|
||||
|
||||
func generateActorDocs() ([]byte, error) {
|
||||
tpl, err := template.New("actorDocs").Parse(actorDocsTemplate)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parsing actorDocs template")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tpl.Execute(buf, struct{ Actors []plugins.ActionDocumentation }{availableActorDocs}); err != nil {
|
||||
return nil, errors.Wrap(err, "rendering actorDocs template")
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
42
actorDocs.tpl
Normal file
42
actorDocs.tpl
Normal file
@ -0,0 +1,42 @@
|
||||
# Available Actions
|
||||
|
||||
{{ range .Actors }}
|
||||
## {{ .Name }}
|
||||
|
||||
{{ .Description }}
|
||||
|
||||
```yaml
|
||||
- type: {{ .Type }}
|
||||
{{- if gt (len .Fields) 0 }}
|
||||
attributes:
|
||||
{{- range .Fields }}
|
||||
# {{ .Description }}
|
||||
# Optional: {{ .Optional }}
|
||||
{{- if eq .Type "bool" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ eq .Default "true" }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "duration" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ if eq .Default "" }}0s{{ else }}{{ .Default }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "int64" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: {{ if eq .Default "" }}0{{ else }}{{ .Default }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if eq .Type "string" }}
|
||||
# Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }}
|
||||
{{ .Key }}: "{{ .Default }}"
|
||||
{{- end }}
|
||||
{{- if eq .Type "stringslice" }}
|
||||
# Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string) {{ end }}
|
||||
{{ .Key }}: []
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
# Does not have configuration attributes
|
||||
{{- end }}
|
||||
```
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}<!-- vim: set ft=markdown: -->{{ end }}
|
@ -14,21 +14,20 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
type autoMessage struct {
|
||||
UUID string `hash:"-" yaml:"uuid"`
|
||||
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
|
||||
|
||||
Channel string `yaml:"channel"`
|
||||
Message string `yaml:"message"`
|
||||
UseAction bool `yaml:"use_action"`
|
||||
Channel string `json:"channel,omitempty" yaml:"channel,omitempty"`
|
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
||||
UseAction bool `json:"use_action,omitempty" yaml:"use_action,omitempty"`
|
||||
|
||||
DisableOnTemplate *string `yaml:"disable_on_template"`
|
||||
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
|
||||
|
||||
Cron string `yaml:"cron"`
|
||||
MessageInterval int64 `yaml:"message_interval"`
|
||||
OnlyOnLive bool `yaml:"only_on_live"`
|
||||
TimeInterval time.Duration `yaml:"time_interval"`
|
||||
Cron string `json:"cron,omitempty" yaml:"cron,omitempty"`
|
||||
MessageInterval int64 `json:"message_interval,omitempty" yaml:"message_interval,omitempty"`
|
||||
OnlyOnLive bool `json:"only_on_live,omitempty" yaml:"only_on_live,omitempty"`
|
||||
|
||||
disabled bool
|
||||
lastMessageSent time.Time
|
||||
@ -54,10 +53,6 @@ func (a *autoMessage) CanSend() bool {
|
||||
// Not enough chatted lines
|
||||
return false
|
||||
|
||||
case a.TimeInterval > 0 && a.lastMessageSent.Add(a.TimeInterval).After(time.Now()):
|
||||
// Simple timer is not yet expired
|
||||
return false
|
||||
|
||||
case a.Cron != "":
|
||||
sched, _ := cronParser.Parse(a.Cron)
|
||||
nextExecute := sched.Next(a.lastMessageSent)
|
||||
@ -126,7 +121,7 @@ func (a *autoMessage) IsValid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
if a.MessageInterval == 0 && a.TimeInterval == 0 && a.Cron == "" {
|
||||
if a.MessageInterval == 0 && a.Cron == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -163,7 +158,7 @@ func (a *autoMessage) Send(c *irc.Client) error {
|
||||
}
|
||||
|
||||
func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
|
||||
if a.DisableOnTemplate == nil {
|
||||
if a.DisableOnTemplate == nil || *a.DisableOnTemplate == "" {
|
||||
// No match criteria set, does not speak against matching
|
||||
return true
|
||||
}
|
||||
|
40
botEditor.go
Normal file
40
botEditor.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/str"
|
||||
"github.com/Luzifer/twitch-bot/twitch"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func botEditorAuthMiddleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
http.Error(w, "no auth-token provided", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
tc := twitch.New(cfg.TwitchClient, token)
|
||||
|
||||
user, err := tc.GetAuthorizedUsername()
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := tc.GetIDForUsername(user)
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "getting ID for authorized user").Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
|
||||
http.Error(w, "user is not authorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
14
ci/bundle.sh
Normal file
14
ci/bundle.sh
Normal file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euxo pipefail
|
||||
|
||||
outfile=${1:-}
|
||||
[[ -n $outfile ]] || {
|
||||
echo "Missing parameters: $0 <outfile> [libraries]"
|
||||
exit 1
|
||||
}
|
||||
shift
|
||||
|
||||
libs=("$@")
|
||||
|
||||
IFS=$','
|
||||
exec curl -sSfLo "${outfile}" "https://cdn.jsdelivr.net/combine/${libs[*]}"
|
196
config.go
196
config.go
@ -1,32 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/go-irc/irc"
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type configFile struct {
|
||||
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||
Channels []string `yaml:"channels"`
|
||||
HTTPListen string `yaml:"http_listen"`
|
||||
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||
PermitTimeout time.Duration `yaml:"permit_timeout"`
|
||||
RawLog string `yaml:"raw_log"`
|
||||
Rules []*plugins.Rule `yaml:"rules"`
|
||||
Variables map[string]interface{} `yaml:"variables"`
|
||||
const expectedMinConfigVersion = 2
|
||||
|
||||
rawLogWriter io.WriteCloser
|
||||
var (
|
||||
//go:embed default_config.yaml
|
||||
defaultConfigurationYAML []byte
|
||||
|
||||
hashstructUUIDNamespace = uuid.Must(uuid.FromString("3a0ccc46-d3ba-46b5-ac07-27528c933174"))
|
||||
|
||||
configReloadHooks = map[string]func(){}
|
||||
configReloadHooksLock sync.RWMutex
|
||||
)
|
||||
|
||||
func registerConfigReloadHook(hook func()) func() {
|
||||
configReloadHooksLock.Lock()
|
||||
defer configReloadHooksLock.Unlock()
|
||||
|
||||
id := uuid.Must(uuid.NewV4()).String()
|
||||
configReloadHooks[id] = hook
|
||||
|
||||
return func() {
|
||||
configReloadHooksLock.Lock()
|
||||
defer configReloadHooksLock.Unlock()
|
||||
|
||||
delete(configReloadHooks, id)
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
configFileVersioner struct {
|
||||
ConfigVersion int64 `yaml:"config_version"`
|
||||
}
|
||||
|
||||
configFile struct {
|
||||
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||
BotEditors []string `yaml:"bot_editors"`
|
||||
Channels []string `yaml:"channels"`
|
||||
HTTPListen string `yaml:"http_listen"`
|
||||
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||
PermitTimeout time.Duration `yaml:"permit_timeout"`
|
||||
RawLog string `yaml:"raw_log"`
|
||||
Rules []*plugins.Rule `yaml:"rules"`
|
||||
Variables map[string]interface{} `yaml:"variables"`
|
||||
|
||||
rawLogWriter io.WriteCloser
|
||||
|
||||
configFileVersioner `yaml:",inline"`
|
||||
}
|
||||
)
|
||||
|
||||
func newConfigFile() *configFile {
|
||||
return &configFile{
|
||||
PermitTimeout: time.Minute,
|
||||
@ -35,19 +75,20 @@ func newConfigFile() *configFile {
|
||||
|
||||
func loadConfig(filename string) error {
|
||||
var (
|
||||
err error
|
||||
tmpConfig *configFile
|
||||
configVersion = &configFileVersioner{}
|
||||
err error
|
||||
tmpConfig = newConfigFile()
|
||||
)
|
||||
|
||||
switch path.Ext(filename) {
|
||||
case ".yaml", ".yml":
|
||||
tmpConfig, err = parseConfigFromYAML(filename)
|
||||
|
||||
default:
|
||||
return errors.Errorf("Unknown config format %q", path.Ext(filename))
|
||||
if err = parseConfigFromYAML(filename, configVersion, false); err != nil {
|
||||
return errors.Wrap(err, "parsing config version")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if configVersion.ConfigVersion < expectedMinConfigVersion {
|
||||
return errors.Errorf("config version too old: %d < %d - Please have a look at the documentation!", configVersion.ConfigVersion, expectedMinConfigVersion)
|
||||
}
|
||||
|
||||
if err = parseConfigFromYAML(filename, tmpConfig, true); err != nil {
|
||||
return errors.Wrap(err, "parsing config")
|
||||
}
|
||||
|
||||
@ -59,11 +100,16 @@ func loadConfig(filename string) error {
|
||||
log.Warn("Loaded config with empty ruleset")
|
||||
}
|
||||
|
||||
if err = tmpConfig.validateRuleActions(); err != nil {
|
||||
return errors.Wrap(err, "validating rule actions")
|
||||
}
|
||||
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
|
||||
tmpConfig.updateAutoMessagesFromConfig(config)
|
||||
tmpConfig.fixDurations()
|
||||
tmpConfig.fixMissingUUIDs()
|
||||
|
||||
switch {
|
||||
case config != nil && config.RawLog == tmpConfig.RawLog:
|
||||
@ -96,28 +142,81 @@ func loadConfig(filename string) error {
|
||||
"channels": len(config.Channels),
|
||||
}).Info("Config file (re)loaded")
|
||||
|
||||
// Notify listener config has changed
|
||||
configReloadHooksLock.RLock()
|
||||
defer configReloadHooksLock.RUnlock()
|
||||
for _, fn := range configReloadHooks {
|
||||
fn()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfigFromYAML(filename string) (*configFile, error) {
|
||||
func parseConfigFromYAML(filename string, obj interface{}, strict bool) error {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "open config file")
|
||||
return errors.Wrap(err, "open config file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
decoder := yaml.NewDecoder(f)
|
||||
decoder.SetStrict(strict)
|
||||
|
||||
return errors.Wrap(decoder.Decode(obj), "decode config file")
|
||||
}
|
||||
|
||||
func patchConfig(filename string, patcher func(*configFile) error) error {
|
||||
var (
|
||||
decoder = yaml.NewDecoder(f)
|
||||
tmpConfig = newConfigFile()
|
||||
cfgFile = newConfigFile()
|
||||
err error
|
||||
)
|
||||
|
||||
decoder.SetStrict(true)
|
||||
|
||||
if err = decoder.Decode(&tmpConfig); err != nil {
|
||||
return nil, errors.Wrap(err, "decode config file")
|
||||
if err = parseConfigFromYAML(filename, cfgFile, true); err != nil {
|
||||
return errors.Wrap(err, "loading current config")
|
||||
}
|
||||
|
||||
return tmpConfig, nil
|
||||
cfgFile.fixMissingUUIDs()
|
||||
|
||||
if err = patcher(cfgFile); err != nil {
|
||||
return errors.Wrap(err, "patching config")
|
||||
}
|
||||
|
||||
return errors.Wrap(
|
||||
writeConfigToYAML(filename, cfgFile),
|
||||
"replacing config",
|
||||
)
|
||||
}
|
||||
|
||||
func writeConfigToYAML(filename string, obj interface{}) error {
|
||||
tmpFile, err := ioutil.TempFile(path.Dir(filename), "twitch-bot-*.yaml")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "opening tempfile")
|
||||
}
|
||||
tmpFileName := tmpFile.Name()
|
||||
|
||||
fmt.Fprintf(tmpFile, "# Automatically updated by Config-Editor frontend, last update: %s\n", time.Now().Format(time.RFC3339))
|
||||
|
||||
if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil {
|
||||
tmpFile.Close()
|
||||
return errors.Wrap(err, "encoding config")
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
return errors.Wrap(
|
||||
os.Rename(tmpFileName, filename),
|
||||
"moving config to location",
|
||||
)
|
||||
}
|
||||
|
||||
func writeDefaultConfigFile(filename string) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "creating config file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(defaultConfigurationYAML)
|
||||
return errors.Wrap(err, "writing default config")
|
||||
}
|
||||
|
||||
func (c *configFile) CloseRawMessageWriter() error {
|
||||
@ -155,11 +254,6 @@ func (c *configFile) fixDurations() {
|
||||
for _, r := range c.Rules {
|
||||
r.Cooldown = c.fixedDurationPtr(r.Cooldown)
|
||||
}
|
||||
|
||||
// Fix auto-messages
|
||||
for _, a := range c.AutoMessages {
|
||||
a.TimeInterval = c.fixedDuration(a.TimeInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (configFile) fixedDuration(d time.Duration) time.Duration {
|
||||
@ -177,6 +271,22 @@ func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
|
||||
return &fd
|
||||
}
|
||||
|
||||
func (c *configFile) fixMissingUUIDs() {
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].UUID != "" {
|
||||
continue
|
||||
}
|
||||
c.AutoMessages[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.AutoMessages[i].ID()).String()
|
||||
}
|
||||
|
||||
for i := range c.Rules {
|
||||
if c.Rules[i].UUID != "" {
|
||||
continue
|
||||
}
|
||||
c.Rules[i].UUID = uuid.NewV5(hashstructUUIDNamespace, c.Rules[i].MatcherID()).String()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
||||
for idx, nam := range c.AutoMessages {
|
||||
// By default assume last message to be sent now
|
||||
@ -208,3 +318,23 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c configFile) validateRuleActions() error {
|
||||
for _, r := range c.Rules {
|
||||
logger := log.WithField("rule", r.MatcherID())
|
||||
for idx, a := range r.Actions {
|
||||
actor, err := getActorByName(a.Type)
|
||||
if err != nil {
|
||||
logger.WithField("index", idx).WithError(err).Error("Cannot get actor by type")
|
||||
return errors.Wrap(err, "getting actor by type")
|
||||
}
|
||||
|
||||
if err = actor.Validate(a.Attributes); err != nil {
|
||||
logger.WithField("index", idx).WithError(err).Error("Actor reported invalid config")
|
||||
return errors.Wrap(err, "validating action attributes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
597
configEditor.go
Normal file
597
configEditor.go
Normal file
@ -0,0 +1,597 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/Luzifer/twitch-bot/twitch"
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const websocketPingInterval = 30 * time.Second
|
||||
|
||||
var (
|
||||
availableActorDocs = []plugins.ActionDocumentation{}
|
||||
availableActorDocsLock sync.RWMutex
|
||||
|
||||
//go:embed editor/*
|
||||
configEditorFrontend embed.FS
|
||||
|
||||
upgrader = websocket.Upgrader{}
|
||||
)
|
||||
|
||||
func registerActorDocumentation(doc plugins.ActionDocumentation) {
|
||||
availableActorDocsLock.Lock()
|
||||
defer availableActorDocsLock.Unlock()
|
||||
|
||||
availableActorDocs = append(availableActorDocs, doc)
|
||||
sort.Slice(availableActorDocs, func(i, j int) bool {
|
||||
return availableActorDocs[i].Name < availableActorDocs[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
type (
|
||||
configEditorGeneralConfig struct {
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerEditorAutoMessageRoutes()
|
||||
registerEditorFrontend()
|
||||
registerEditorGeneralConfigRoutes()
|
||||
registerEditorGlobalMethods()
|
||||
registerEditorRulesRoutes()
|
||||
}
|
||||
|
||||
//nolint:funlen // This is a logic unit and shall not be split up
|
||||
func registerEditorAutoMessageRoutes() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the current set of configured auto-messages in JSON format",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(config.AutoMessages); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get current auto-messages",
|
||||
Path: "/auto-messages",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Adds a new Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
msg := &autoMessage{}
|
||||
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
msg.UUID = uuid.Must(uuid.NewV4()).String()
|
||||
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
c.AutoMessages = append(c.AutoMessages, msg)
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
},
|
||||
Method: http.MethodPost,
|
||||
Module: "config-editor",
|
||||
Name: "Add Auto-Message",
|
||||
Path: "/auto-messages",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
},
|
||||
{
|
||||
Description: "Deletes the given Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
var (
|
||||
id = mux.Vars(r)["uuid"]
|
||||
tmp []*autoMessage
|
||||
)
|
||||
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].ID() == id {
|
||||
continue
|
||||
}
|
||||
tmp = append(tmp, c.AutoMessages[i])
|
||||
}
|
||||
|
||||
c.AutoMessages = tmp
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodDelete,
|
||||
Module: "config-editor",
|
||||
Name: "Delete Auto-Message",
|
||||
Path: "/auto-messages/{uuid}",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "UUID of the auto-message to delete",
|
||||
Name: "uuid",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Description: "Updates the given Auto-Message",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
msg := &autoMessage{}
|
||||
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := patchConfig(cfg.Config, func(c *configFile) error {
|
||||
id := mux.Vars(r)["uuid"]
|
||||
|
||||
for i := range c.AutoMessages {
|
||||
if c.AutoMessages[i].ID() == id {
|
||||
c.AutoMessages[i] = msg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
Module: "config-editor",
|
||||
Name: "Update Auto-Message",
|
||||
Path: "/auto-messages/{uuid}",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "UUID of the auto-message to update",
|
||||
Name: "uuid",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
if err := registerRoute(rd); err != nil {
|
||||
log.WithError(err).Fatal("Unable to register config editor route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerEditorFrontend() {
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := configEditorFrontend.Open("editor/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "opening index.html").Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
io.Copy(w, f)
|
||||
})
|
||||
|
||||
router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(struct {
|
||||
IRCBadges []string
|
||||
KnownEvents []*string
|
||||
TwitchClientID string
|
||||
}{
|
||||
IRCBadges: twitch.KnownBadges,
|
||||
KnownEvents: knownEvents,
|
||||
TwitchClientID: cfg.TwitchClient,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
router.PathPrefix("/editor").Handler(http.FileServer(http.FS(configEditorFrontend)))
|
||||
}
|
||||
|
||||
func registerEditorGeneralConfigRoutes() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the current general config",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
||||
BotEditors: config.BotEditors,
|
||||
Channels: config.Channels,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get general config",
|
||||
Path: "/general",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Updates the general config",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload configEditorGeneralConfig
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range payload.BotEditors {
|
||||
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i])
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
payload.BotEditors[i] = usr.ID
|
||||
}
|
||||
|
||||
if err := patchConfig(cfg.Config, func(cfg *configFile) error {
|
||||
cfg.Channels = payload.Channels
|
||||
cfg.BotEditors = payload.BotEditors
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
Module: "config-editor",
|
||||
Name: "Update general config",
|
||||
Path: "/general",
|
||||
RequiresEditorsAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
},
|
||||
} {
|
||||
if err := registerRoute(rd); err != nil {
|
||||
log.WithError(err).Fatal("Unable to register config editor route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:funlen,gocognit,gocyclo // This is a logic unit and shall not be split up
|
||||
func registerEditorGlobalMethods() {
|
||||
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||
{
|
||||
Description: "Returns the documentation for available actions",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
availableActorDocsLock.Lock()
|
||||
defer availableActorDocsLock.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(availableActorDocs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
},
|
||||
Method: http.MethodGet,
|
||||
Module: "config-editor",
|
||||
Name: "Get available actions",
|
||||
Path: "/actions",
|
||||
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||
},
|
||||
{
|
||||
Description: "Returns information about a Twitch user to properly display bot editors",
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
usr, err := twitchClient.GetUserInformation(r.FormValue("user"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
eventTypeTwitchStreamOnline = ptrStr("stream_online")
|
||||
eventTypeTwitchTitleUpdate = ptrStr("title_update")
|
||||
|
||||
knownEvents = []*string{
|
||||
eventTypeJoin,
|
||||
eventTypeHost,
|
||||
eventTypePart,
|
||||
eventTypePermit,
|
||||
eventTypeRaid,
|
||||
eventTypeResub,
|
||||
eventTypeSub,
|
||||
eventTypeSubgift,
|
||||
eventTypeWhisper,
|
||||
|
||||
eventTypeTwitchCategoryUpdate,
|
||||
eventTypeTwitchStreamOffline,
|
||||
eventTypeTwitchStreamOnline,
|
||||
eventTypeTwitchTitleUpdate,
|
||||
}
|
||||
)
|
||||
|
4
go.mod
4
go.mod
@ -7,11 +7,14 @@ require (
|
||||
github.com/Luzifer/korvike/functions v0.6.1
|
||||
github.com/Luzifer/rconfig/v2 v2.3.0
|
||||
github.com/go-irc/irc v2.1.0+incompatible
|
||||
github.com/gofrs/uuid/v3 v3.1.2
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@ -32,7 +35,6 @@ require (
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb // indirect
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
||||
|
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/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY=
|
||||
github.com/gofrs/uuid/v3 v3.1.2/go.mod h1:xPwMqoocQ1L5G6pXX5BcE7N5jlzn2o19oqAKxwZW/kI=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
@ -108,6 +110,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
|
7
helpers.go
Normal file
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
"github.com/go-irc/irc"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "ban"
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Ban user from chat",
|
||||
Name: "Ban User",
|
||||
Type: "ban",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Reason why the user was banned",
|
||||
Key: "reason",
|
||||
Name: "Reason",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
Ban *string `json:"ban" yaml:"ban"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Ban == nil {
|
||||
return false, nil
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
ptrStringEmpty := func(v string) *string { return &v }("")
|
||||
|
||||
cmd := []string{
|
||||
"/ban",
|
||||
plugins.DeriveUser(m, eventData),
|
||||
}
|
||||
|
||||
if reason := attrs.MustString("reason", ptrStringEmpty); reason != "" {
|
||||
cmd = append(cmd, reason)
|
||||
}
|
||||
|
||||
return false, errors.Wrap(
|
||||
@ -28,12 +53,14 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
Command: "PRIVMSG",
|
||||
Params: []string{
|
||||
plugins.DeriveChannel(m, eventData),
|
||||
fmt.Sprintf("/ban %s %s", plugins.DeriveUser(m, eventData), *a.Ban),
|
||||
strings.Join(cmd, " "),
|
||||
},
|
||||
}),
|
||||
"sending timeout",
|
||||
"sending ban",
|
||||
)
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "ban" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil }
|
||||
|
@ -8,25 +8,57 @@ import (
|
||||
"github.com/go-irc/irc"
|
||||
)
|
||||
|
||||
const actorName = "delay"
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Delay next action",
|
||||
Name: "Delay",
|
||||
Type: "delay",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Static delay to wait",
|
||||
Key: "delay",
|
||||
Name: "Delay",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeDuration,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Dynamic jitter to add to the static delay (the added extra delay will be between 0 and this value)",
|
||||
Key: "jitter",
|
||||
Name: "Jitter",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeDuration,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
Delay time.Duration `json:"delay" yaml:"delay"`
|
||||
DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Delay == 0 && a.DelayJitter == 0 {
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
var (
|
||||
ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0)
|
||||
delay = attrs.MustDuration("delay", ptrZeroDuration)
|
||||
jitter = attrs.MustDuration("jitter", ptrZeroDuration)
|
||||
)
|
||||
|
||||
if delay == 0 && jitter == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
totalDelay := a.Delay
|
||||
if a.DelayJitter > 0 {
|
||||
totalDelay += time.Duration(rand.Int63n(int64(a.DelayJitter))) // #nosec: G404 // It's just time, no need for crypto/rand
|
||||
totalDelay := delay
|
||||
if jitter > 0 {
|
||||
totalDelay += time.Duration(rand.Int63n(int64(jitter))) // #nosec: G404 // It's just time, no need for crypto/rand
|
||||
}
|
||||
|
||||
time.Sleep(totalDelay)
|
||||
@ -34,4 +66,6 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "delay" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil }
|
||||
|
@ -8,21 +8,23 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "delete"
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Delete message which caused the rule to be executed",
|
||||
Name: "Delete Message",
|
||||
Type: "delete",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
DeleteMessage *bool `json:"delete_message" yaml:"delete_message"`
|
||||
}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.DeleteMessage == nil || !*a.DeleteMessage || m == nil {
|
||||
return false, nil
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
msgID, ok := m.Tags.GetTag("id")
|
||||
if !ok || msgID == "" {
|
||||
return false, nil
|
||||
@ -41,4 +43,6 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "delete" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) { return nil }
|
||||
|
@ -10,6 +10,8 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "modchannel"
|
||||
|
||||
var (
|
||||
formatMessage plugins.MsgFormatter
|
||||
twitchClient *twitch.Client
|
||||
@ -19,52 +21,98 @@ func Register(args plugins.RegistrationArguments) error {
|
||||
formatMessage = args.FormatMessage
|
||||
twitchClient = args.GetTwitchClient()
|
||||
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Update stream information",
|
||||
Name: "Modify Stream",
|
||||
Type: "modchannel",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Channel to update",
|
||||
Key: "channel",
|
||||
Name: "Channel",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Category / Game to set",
|
||||
Key: "game",
|
||||
Name: "Game",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Stream title to set",
|
||||
Key: "title",
|
||||
Name: "Title",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
Channel string `json:"channel" yaml:"channel"`
|
||||
UpdateGame *string `json:"update_game" yaml:"update_game"`
|
||||
UpdateTitle *string `json:"update_title" yaml:"update_title"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.UpdateGame == nil && a.UpdateTitle == nil {
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
var (
|
||||
ptrStringEmpty = func(v string) *string { return &v }("")
|
||||
game = attrs.MustString("game", ptrStringEmpty)
|
||||
title = attrs.MustString("title", ptrStringEmpty)
|
||||
)
|
||||
|
||||
if game == "" && title == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var game, title *string
|
||||
var updGame, updTitle *string
|
||||
|
||||
channel, err := formatMessage(a.Channel, m, r, eventData)
|
||||
channel, err := formatMessage(attrs.MustString("channel", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "parsing channel")
|
||||
}
|
||||
|
||||
if a.UpdateGame != nil {
|
||||
parsedGame, err := formatMessage(*a.UpdateGame, m, r, eventData)
|
||||
if game != "" {
|
||||
parsedGame, err := formatMessage(game, m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "parsing game")
|
||||
}
|
||||
|
||||
game = &parsedGame
|
||||
updGame = &parsedGame
|
||||
}
|
||||
|
||||
if a.UpdateTitle != nil {
|
||||
parsedTitle, err := formatMessage(*a.UpdateTitle, m, r, eventData)
|
||||
if title != "" {
|
||||
parsedTitle, err := formatMessage(title, m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "parsing title")
|
||||
}
|
||||
|
||||
title = &parsedTitle
|
||||
updTitle = &parsedTitle
|
||||
}
|
||||
|
||||
return false, errors.Wrap(
|
||||
twitchClient.ModifyChannelInformation(context.Background(), strings.TrimLeft(channel, "#"), game, title),
|
||||
twitchClient.ModifyChannelInformation(context.Background(), strings.TrimLeft(channel, "#"), updGame, updTitle),
|
||||
"updating channel info",
|
||||
)
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "modchannel" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("channel"); err != nil || v == "" {
|
||||
return errors.New("channel must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -6,26 +6,40 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "raw"
|
||||
|
||||
var formatMessage plugins.MsgFormatter
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
formatMessage = args.FormatMessage
|
||||
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Send raw IRC message",
|
||||
Name: "Send RAW Message",
|
||||
Type: "raw",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Raw message to send (must be a valid IRC protocol message)",
|
||||
Key: "message",
|
||||
Name: "Message",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
RawMessage *string `json:"raw_message" yaml:"raw_message"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.RawMessage == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
rawMsg, err := formatMessage(*a.RawMessage, m, r, eventData)
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing raw message")
|
||||
}
|
||||
@ -42,4 +56,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "raw" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("message"); err != nil || v == "" {
|
||||
return errors.New("message must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -9,41 +9,84 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var formatMessage plugins.MsgFormatter
|
||||
const actorName = "respond"
|
||||
|
||||
var (
|
||||
formatMessage plugins.MsgFormatter
|
||||
|
||||
ptrBoolFalse = func(v bool) *bool { return &v }(false)
|
||||
)
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
formatMessage = args.FormatMessage
|
||||
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Respond to message with a new message",
|
||||
Name: "Respond to Message",
|
||||
Type: "respond",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Message text to send",
|
||||
Key: "message",
|
||||
Long: true,
|
||||
Name: "Message",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Fallback message text to send if message cannot be generated",
|
||||
Key: "fallback",
|
||||
Name: "Fallback",
|
||||
Optional: true,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Send message as a native Twitch-reply to the original message",
|
||||
Key: "as_reply",
|
||||
Name: "As Reply",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeBool,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "Send message to a different channel than the original message",
|
||||
Key: "to_channel",
|
||||
Name: "To Channel",
|
||||
Optional: true,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
Respond *string `json:"respond" yaml:"respond"`
|
||||
RespondAsReply *bool `json:"respond_as_reply" yaml:"respond_as_reply"`
|
||||
RespondFallback *string `json:"respond_fallback" yaml:"respond_fallback"`
|
||||
ToChannel *string `json:"to_channel" yaml:"to_channel"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Respond == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
msg, err := formatMessage(*a.Respond, m, r, eventData)
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
if a.RespondFallback == nil {
|
||||
if attrs.CanString("fallback") {
|
||||
return false, errors.Wrap(err, "preparing response")
|
||||
}
|
||||
if msg, err = formatMessage(*a.RespondFallback, m, r, eventData); err != nil {
|
||||
if msg, err = formatMessage(attrs.MustString("fallback", nil), m, r, eventData); err != nil {
|
||||
return false, errors.Wrap(err, "preparing response fallback")
|
||||
}
|
||||
}
|
||||
|
||||
toChannel := plugins.DeriveChannel(m, eventData)
|
||||
if a.ToChannel != nil {
|
||||
toChannel = fmt.Sprintf("#%s", strings.TrimLeft(*a.ToChannel, "#"))
|
||||
if attrs.CanString("to_channel") && attrs.MustString("to_channel", nil) != "" {
|
||||
toChannel = fmt.Sprintf("#%s", strings.TrimLeft(attrs.MustString("to_channel", nil), "#"))
|
||||
}
|
||||
|
||||
ircMessage := &irc.Message{
|
||||
@ -54,7 +97,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
},
|
||||
}
|
||||
|
||||
if a.RespondAsReply != nil && *a.RespondAsReply && m != nil {
|
||||
if attrs.MustBool("as_reply", ptrBoolFalse) {
|
||||
id, ok := m.GetTag("id")
|
||||
if ok {
|
||||
if ircMessage.Tags == nil {
|
||||
@ -71,4 +114,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "respond" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("message"); err != nil || v == "" {
|
||||
return errors.New("message must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -9,27 +9,41 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "timeout"
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Timeout user from chat",
|
||||
Name: "Timeout User",
|
||||
Type: "timeout",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Duration of the timeout",
|
||||
Key: "duration",
|
||||
Name: "Duration",
|
||||
Optional: false,
|
||||
SupportTemplate: false,
|
||||
Type: plugins.ActionDocumentationFieldTypeDuration,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
Timeout *time.Duration `json:"timeout" yaml:"timeout"`
|
||||
}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.Timeout == nil {
|
||||
return false, nil
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
return false, errors.Wrap(
|
||||
c.WriteMessage(&irc.Message{
|
||||
Command: "PRIVMSG",
|
||||
Params: []string{
|
||||
plugins.DeriveChannel(m, eventData),
|
||||
fmt.Sprintf("/timeout %s %d", plugins.DeriveUser(m, eventData), fixDurationValue(*a.Timeout)/time.Second),
|
||||
fmt.Sprintf("/timeout %s %d", plugins.DeriveUser(m, eventData), attrs.MustDuration("duration", nil)/time.Second),
|
||||
},
|
||||
}),
|
||||
"sending timeout",
|
||||
@ -37,12 +51,12 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "timeout" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func fixDurationValue(d time.Duration) time.Duration {
|
||||
if d >= time.Second {
|
||||
return d
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.Duration("duration"); err != nil || v < time.Second {
|
||||
return errors.New("duration must be of type duration greater or equal one second")
|
||||
}
|
||||
|
||||
return d * time.Second
|
||||
return nil
|
||||
}
|
||||
|
@ -8,32 +8,54 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const actorName = "whisper"
|
||||
|
||||
var formatMessage plugins.MsgFormatter
|
||||
|
||||
func Register(args plugins.RegistrationArguments) error {
|
||||
formatMessage = args.FormatMessage
|
||||
|
||||
args.RegisterActor(func() plugins.Actor { return &actor{} })
|
||||
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
|
||||
|
||||
args.RegisterActorDocumentation(plugins.ActionDocumentation{
|
||||
Description: "Send a whisper (requires a verified bot!)",
|
||||
Name: "Send Whisper",
|
||||
Type: "whisper",
|
||||
|
||||
Fields: []plugins.ActionDocumentationField{
|
||||
{
|
||||
Default: "",
|
||||
Description: "Message to whisper to the user",
|
||||
Key: "message",
|
||||
Name: "Message",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
{
|
||||
Default: "",
|
||||
Description: "User to send the message to",
|
||||
Key: "to",
|
||||
Name: "To User",
|
||||
Optional: false,
|
||||
SupportTemplate: true,
|
||||
Type: plugins.ActionDocumentationFieldTypeString,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"`
|
||||
WhisperTo *string `json:"whisper_to" yaml:"whisper_to"`
|
||||
}
|
||||
type actor struct{}
|
||||
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
if a.WhisperTo == nil || a.WhisperMessage == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
to, err := formatMessage(*a.WhisperTo, m, r, eventData)
|
||||
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) {
|
||||
to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing whisper receiver")
|
||||
}
|
||||
|
||||
msg, err := formatMessage(*a.WhisperMessage, m, r, eventData)
|
||||
msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "preparing whisper message")
|
||||
}
|
||||
@ -53,4 +75,16 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
|
||||
}
|
||||
|
||||
func (a actor) IsAsync() bool { return false }
|
||||
func (a actor) Name() string { return "whisper" }
|
||||
func (a actor) Name() string { return actorName }
|
||||
|
||||
func (a actor) Validate(attrs plugins.FieldCollection) (err error) {
|
||||
if v, err := attrs.String("to"); err != nil || v == "" {
|
||||
return errors.New("to must be non-empty string")
|
||||
}
|
||||
|
||||
if v, err := attrs.String("message"); err != nil || v == "" {
|
||||
return errors.New("message must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
33
main.go
33
main.go
@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@ -86,7 +88,8 @@ func main() {
|
||||
twitchWatch := newTwitchWatcher()
|
||||
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
|
||||
|
||||
router.HandleFunc("/", handleSwaggerHTML)
|
||||
router.Use(corsMiddleware)
|
||||
router.HandleFunc("/openapi.html", handleSwaggerHTML)
|
||||
router.HandleFunc("/openapi.json", handleSwaggerRequest)
|
||||
|
||||
if err = initCorePlugins(); err != nil {
|
||||
@ -97,7 +100,27 @@ func main() {
|
||||
log.WithError(err).Fatal("Unable to load plugins")
|
||||
}
|
||||
|
||||
if len(rconfig.Args()) == 2 && rconfig.Args()[1] == "actor-docs" {
|
||||
doc, err := generateActorDocs()
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("Unable to generate actor docs")
|
||||
}
|
||||
if _, err = os.Stdout.Write(append(bytes.TrimSpace(doc), '\n')); err != nil {
|
||||
log.WithError(err).Fatal("Unable to write actor docs to stdout")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err = loadConfig(cfg.Config); err != nil {
|
||||
if os.IsNotExist(errors.Cause(err)) {
|
||||
if err = writeDefaultConfigFile(cfg.Config); err != nil {
|
||||
log.WithError(err).Fatal("Initial config not found and not able to create example config")
|
||||
}
|
||||
|
||||
log.WithField("filename", cfg.Config).Warn("No config was found, created example config: Please review that config!")
|
||||
return
|
||||
}
|
||||
|
||||
log.WithError(err).Fatal("Initial config load failed")
|
||||
}
|
||||
defer func() { config.CloseRawMessageWriter() }()
|
||||
@ -134,7 +157,13 @@ func main() {
|
||||
|
||||
if config.HTTPListen != "" {
|
||||
// If listen address is configured start HTTP server
|
||||
go http.ListenAndServe(config.HTTPListen, router)
|
||||
listener, err := net.Listen("tcp", config.HTTPListen)
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("Unable to open http_listen port")
|
||||
}
|
||||
|
||||
go http.Serve(listener, router)
|
||||
log.WithField("address", listener.Addr().String()).Info("HTTP server started")
|
||||
}
|
||||
|
||||
ircDisconnected <- struct{}{}
|
||||
|
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{}
|
||||
|
||||
func (f FieldCollection) CanBool(name string) bool {
|
||||
_, err := f.Bool(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (f FieldCollection) CanDuration(name string) bool {
|
||||
_, err := f.Duration(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (f FieldCollection) CanInt64(name string) bool {
|
||||
_, err := f.Int64(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (f FieldCollection) CanString(name string) bool {
|
||||
_, err := f.String(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (f FieldCollection) Expect(keys ...string) error {
|
||||
var missing []string
|
||||
|
||||
@ -32,6 +52,16 @@ func (f FieldCollection) Expect(keys ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FieldCollection) HasAll(keys ...string) bool {
|
||||
for _, k := range keys {
|
||||
if _, ok := f[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (f FieldCollection) MustBool(name string, defVal *bool) bool {
|
||||
v, err := f.Bool(name)
|
||||
if err != nil {
|
||||
|
@ -11,17 +11,18 @@ type (
|
||||
}
|
||||
|
||||
HTTPRouteRegistrationArgs struct {
|
||||
Description string
|
||||
HandlerFunc http.HandlerFunc
|
||||
IsPrefix bool
|
||||
Method string
|
||||
Module string
|
||||
Name string
|
||||
Path string
|
||||
QueryParams []HTTPRouteParamDocumentation
|
||||
ResponseType HTTPRouteResponseType
|
||||
RouteParams []HTTPRouteParamDocumentation
|
||||
SkipDocumentation bool
|
||||
Description string
|
||||
HandlerFunc http.HandlerFunc
|
||||
IsPrefix bool
|
||||
Method string
|
||||
Module string
|
||||
Name string
|
||||
Path string
|
||||
QueryParams []HTTPRouteParamDocumentation
|
||||
RequiresEditorsAuth bool
|
||||
ResponseType HTTPRouteResponseType
|
||||
RouteParams []HTTPRouteParamDocumentation
|
||||
SkipDocumentation bool
|
||||
}
|
||||
|
||||
HTTPRouteResponseType uint64
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
type (
|
||||
Actor interface {
|
||||
// Execute will be called after the config was read into the Actor
|
||||
Execute(*irc.Client, *irc.Message, *Rule, FieldCollection) (preventCooldown bool, err error)
|
||||
Execute(c *irc.Client, m *irc.Message, r *Rule, evtData FieldCollection, attrs FieldCollection) (preventCooldown bool, err error)
|
||||
// IsAsync may return true if the Execute function is to be executed
|
||||
// in a Go routine as of long runtime. Normally it should return false
|
||||
// except in very specific cases
|
||||
@ -18,11 +18,17 @@ type (
|
||||
// Name must return an unique name for the actor in order to identify
|
||||
// it in the logs for debugging purposes
|
||||
Name() string
|
||||
// Validate will be called to validate the loaded configuration. It should
|
||||
// return an error if required keys are missing from the AttributeStore
|
||||
// or if keys contain broken configs
|
||||
Validate(FieldCollection) error
|
||||
}
|
||||
|
||||
ActorCreationFunc func() Actor
|
||||
|
||||
ActorRegistrationFunc func(ActorCreationFunc)
|
||||
ActorRegistrationFunc func(name string, acf ActorCreationFunc)
|
||||
|
||||
ActorDocumentationRegistrationFunc func(ActionDocumentation)
|
||||
|
||||
CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error)
|
||||
|
||||
@ -45,6 +51,8 @@ type (
|
||||
GetTwitchClient func() *twitch.Client
|
||||
// RegisterActor is used to register a new IRC rule-actor implementing the Actor interface
|
||||
RegisterActor ActorRegistrationFunc
|
||||
// RegisterActorDocumentation is used to register an ActorDocumentation for the config editor
|
||||
RegisterActorDocumentation ActorDocumentationRegistrationFunc
|
||||
// RegisterAPIRoute registers a new HTTP handler function including documentation
|
||||
RegisterAPIRoute HTTPRouteRegistrationFunc
|
||||
// RegisterCron is a method to register cron functions in the global cron instance
|
||||
|
@ -14,37 +14,45 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
UUID string `hash:"-" yaml:"uuid"`
|
||||
type (
|
||||
Rule struct {
|
||||
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
|
||||
Actions []*RuleAction `yaml:"actions"`
|
||||
Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
|
||||
Cooldown *time.Duration `yaml:"cooldown"`
|
||||
ChannelCooldown *time.Duration `yaml:"channel_cooldown"`
|
||||
UserCooldown *time.Duration `yaml:"user_cooldown"`
|
||||
SkipCooldownFor []string `yaml:"skip_cooldown_for"`
|
||||
Cooldown *time.Duration `json:"cooldown,omitempty" yaml:"cooldown,omitempty"`
|
||||
ChannelCooldown *time.Duration `json:"channel_cooldown,omitempty" yaml:"channel_cooldown,omitempty"`
|
||||
UserCooldown *time.Duration `json:"user_cooldown,omitempty" yaml:"user_cooldown,omitempty"`
|
||||
SkipCooldownFor []string `json:"skip_cooldown_for,omitempty" yaml:"skip_cooldown_for,omitempty"`
|
||||
|
||||
MatchChannels []string `yaml:"match_channels"`
|
||||
MatchEvent *string `yaml:"match_event"`
|
||||
MatchMessage *string `yaml:"match_message"`
|
||||
MatchUsers []string `yaml:"match_users" `
|
||||
MatchChannels []string `json:"match_channels,omitempty" yaml:"match_channels,omitempty"`
|
||||
MatchEvent *string `json:"match_event,omitempty" yaml:"match_event,omitempty"`
|
||||
MatchMessage *string `json:"match_message,omitempty" yaml:"match_message,omitempty"`
|
||||
MatchUsers []string `json:"match_users,omitempty" yaml:"match_users,omitempty" `
|
||||
|
||||
DisableOnMatchMessages []string `yaml:"disable_on_match_messages"`
|
||||
DisableOnMatchMessages []string `json:"disable_on_match_messages,omitempty" yaml:"disable_on_match_messages,omitempty"`
|
||||
|
||||
Disable *bool `yaml:"disable"`
|
||||
DisableOnOffline *bool `yaml:"disable_on_offline"`
|
||||
DisableOnPermit *bool `yaml:"disable_on_permit"`
|
||||
DisableOnTemplate *string `yaml:"disable_on_template"`
|
||||
DisableOn []string `yaml:"disable_on"`
|
||||
EnableOn []string `yaml:"enable_on"`
|
||||
Disable *bool `json:"disable,omitempty" yaml:"disable,omitempty"`
|
||||
DisableOnOffline *bool `json:"disable_on_offline,omitempty" yaml:"disable_on_offline,omitempty"`
|
||||
DisableOnPermit *bool `json:"disable_on_permit,omitempty" yaml:"disable_on_permit,omitempty"`
|
||||
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
|
||||
DisableOn []string `json:"disable_on,omitempty" yaml:"disable_on,omitempty"`
|
||||
EnableOn []string `json:"enable_on,omitempty" yaml:"enable_on,omitempty"`
|
||||
|
||||
matchMessage *regexp.Regexp
|
||||
disableOnMatchMessages []*regexp.Regexp
|
||||
matchMessage *regexp.Regexp
|
||||
disableOnMatchMessages []*regexp.Regexp
|
||||
|
||||
msgFormatter MsgFormatter
|
||||
timerStore TimerStore
|
||||
twitchClient *twitch.Client
|
||||
}
|
||||
msgFormatter MsgFormatter
|
||||
timerStore TimerStore
|
||||
twitchClient *twitch.Client
|
||||
}
|
||||
|
||||
RuleAction struct {
|
||||
Type string `json:"type" yaml:"type,omitempty"`
|
||||
Attributes FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func (r Rule) MatcherID() string {
|
||||
if r.UUID != "" {
|
||||
@ -225,7 +233,7 @@ func (r *Rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, ev
|
||||
}
|
||||
|
||||
func (r *Rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData FieldCollection) bool {
|
||||
if r.DisableOnTemplate == nil {
|
||||
if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" {
|
||||
// No match criteria set, does not speak against matching
|
||||
return true
|
||||
}
|
||||
@ -246,7 +254,7 @@ func (r *Rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message,
|
||||
}
|
||||
|
||||
func (r *Rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, event *string, badges twitch.BadgeCollection, evtData FieldCollection) bool {
|
||||
if r.MatchEvent == nil {
|
||||
if r.MatchEvent == nil || *r.MatchEvent == "" {
|
||||
// No match criteria set, does not speak against matching
|
||||
return true
|
||||
}
|
||||
|
@ -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"),
|
||||
"notFoundResponse": spec.TextPlainResponse(nil).WithDescription("Document was not found or insufficient permissions"),
|
||||
},
|
||||
SecuritySchemes: map[string]*spec.SecurityScheme{
|
||||
"authenticated": spec.APIKeyAuth("Authorization", spec.InHeader),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -87,6 +90,12 @@ func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||
},
|
||||
}
|
||||
|
||||
if route.RequiresEditorsAuth {
|
||||
op.Security = []map[string]spec.SecurityRequirement{
|
||||
{"authenticated": {}},
|
||||
}
|
||||
}
|
||||
|
||||
switch route.ResponseType {
|
||||
case plugins.HTTPRouteResponseTypeJSON:
|
||||
op.Responses["200"] = spec.JSONResponse(nil).WithDescription("Successful execution with JSON object response")
|
||||
@ -119,9 +128,7 @@ func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error {
|
||||
specParam := spec.QueryParam(param.Name, ps).
|
||||
WithDescription(param.Description)
|
||||
|
||||
if !param.Required {
|
||||
specParam = specParam.AsOptional()
|
||||
}
|
||||
specParam.Required = param.Required
|
||||
|
||||
op.Parameters = append(
|
||||
op.Parameters,
|
||||
|
@ -12,8 +12,17 @@ const (
|
||||
BadgeFounder = "founder"
|
||||
BadgeModerator = "moderator"
|
||||
BadgeSubscriber = "subscriber"
|
||||
BadgeVIP = "vip"
|
||||
)
|
||||
|
||||
var KnownBadges = []string{
|
||||
BadgeBroadcaster,
|
||||
BadgeFounder,
|
||||
BadgeModerator,
|
||||
BadgeSubscriber,
|
||||
BadgeVIP,
|
||||
}
|
||||
|
||||
type BadgeCollection map[string]*int
|
||||
|
||||
func ParseBadgeLevels(m *irc.Message) BadgeCollection {
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/backoff"
|
||||
@ -37,6 +38,13 @@ type (
|
||||
|
||||
apiCache *APICache
|
||||
}
|
||||
|
||||
User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
ProfileImageURL string `json:"profile_image_url"`
|
||||
}
|
||||
)
|
||||
|
||||
func New(clientID, token string) *Client {
|
||||
@ -52,10 +60,7 @@ func (c Client) APICache() *APICache { return c.apiCache }
|
||||
|
||||
func (c Client) GetAuthorizedUsername() (string, error) {
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"data"`
|
||||
Data []User `json:"data"`
|
||||
}
|
||||
|
||||
if err := c.request(
|
||||
@ -82,11 +87,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Login string `json:"login"`
|
||||
} `json:"data"`
|
||||
Data []User `json:"data"`
|
||||
}
|
||||
|
||||
if err := c.request(
|
||||
@ -150,6 +151,46 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||
return payload.Data[0].FollowedAt, nil
|
||||
}
|
||||
|
||||
func (c Client) GetUserInformation(user string) (*User, error) {
|
||||
var (
|
||||
out User
|
||||
param = "login"
|
||||
payload struct {
|
||||
Data []User `json:"data"`
|
||||
}
|
||||
)
|
||||
|
||||
cacheKey := []string{"userInformation", user}
|
||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||
out = d.(User)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
if _, err := strconv.ParseInt(user, 10, 64); err == nil {
|
||||
param = "id"
|
||||
}
|
||||
|
||||
if err := c.request(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user),
|
||||
nil,
|
||||
&payload,
|
||||
); err != nil {
|
||||
return nil, errors.Wrap(err, "request user info")
|
||||
}
|
||||
|
||||
if l := len(payload.Data); l != 1 {
|
||||
return nil, errors.Errorf("unexpected number of records returned: %d", l)
|
||||
}
|
||||
|
||||
// Follow date will not change that often, cache for a long time
|
||||
c.apiCache.Set(cacheKey, timeDay, payload.Data[0])
|
||||
out = payload.Data[0]
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
|
||||
var out []Category
|
||||
|
||||
@ -219,10 +260,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"data"`
|
||||
Data []User `json:"data"`
|
||||
}
|
||||
|
||||
if err := c.request(
|
||||
|
189
wiki/Actors.md
Normal file
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
|
||||
---
|
||||
|
||||
# Channels to join (only those can be acted on)
|
||||
# This must be the config version you've used below. Current version
|
||||
# is version 2 so probably keep it at 2 until the bot tells you to
|
||||
# upgrade.
|
||||
config_version: 2
|
||||
|
||||
# List of strings: Either Twitch user-ids or nicknames (best to stick
|
||||
# with IDs as they can't change while nicknames can be changed every
|
||||
# 60 days). Those users are able to use the config editor web-interface.
|
||||
bot_editors: []
|
||||
|
||||
# List of channels to join. Channels not listed here will not be
|
||||
# joined and therefore cannot be actioned on.
|
||||
channels:
|
||||
- mychannel
|
||||
|
||||
@ -23,6 +34,8 @@ variables:
|
||||
myvariable: true
|
||||
anothervariable: "string"
|
||||
|
||||
# List of auto-messages. See documentation for details or use
|
||||
# web-interface to configure.
|
||||
auto_messages:
|
||||
- channel: 'mychannel' # String, channel to send message to
|
||||
message: 'Automated message' # String, message to send
|
||||
@ -31,66 +44,23 @@ auto_messages:
|
||||
# Even though all of these are optional, at least one MUST be specified for the entry to be valid
|
||||
cron: '*/10 * * * *' # String, optional, cron syntax when to send the message
|
||||
message_interval: 3 # Integer, optional, how many non-bot-messages must be sent in between
|
||||
time_interval: 900s # Duration, optional, how long to wait before repeating the message
|
||||
|
||||
only_on_live: true # Boolean, optional, only send the message when channel is live
|
||||
|
||||
# Disable message using templating, must yield string `true` to disable the automated message
|
||||
disable_on_template: '{{ ne .myvariable true }}'
|
||||
|
||||
# List of rules. See documentation for details or use web-interface
|
||||
# to configure.
|
||||
rules: # See below for examples
|
||||
|
||||
- actions: # Array of actions to take when this rule matches
|
||||
|
||||
# Issue a ban on the user who wrote the chat-line
|
||||
- ban: "reason of ban"
|
||||
|
||||
# Command to execute for the chat message, must return an JSON encoded array of actions
|
||||
- command: [/bin/bash, -c, "echo '[{\"respond\": \"Text\"}]'"]
|
||||
skip_cooldown_on_error: true # Boolean, optional, if set to true a non-zero exit-code
|
||||
# will prevent the cooldown to be started for the rule
|
||||
|
||||
# Modify an internal counter value (does NOT send a chat line)
|
||||
- counter: "counterid" # String to identify the counter, applies templating
|
||||
counter_set: 25 # String, set counter to value (counter_step is ignored if set),
|
||||
# applies templating but MUST result in a parseable integer
|
||||
counter_step: 1 # Integer, can be negative or positive, default: +1
|
||||
|
||||
# Introduce a delay between two actions
|
||||
- delay: 1m # Duration, how long to wait (fixed)
|
||||
delay_jitter: 1m # Duration, add random delay to fixed delay between 0 and this value
|
||||
|
||||
# Issue a delete on the message caught
|
||||
- delete_message: true # Bool, set to true to delete
|
||||
|
||||
# Send raw IRC message to Twitch servers
|
||||
- raw_message: 'PRIVMSG #{{ .channel }} :Test' # String, applies templating
|
||||
|
||||
# Send responding message to the channel the original message was received in
|
||||
- respond: 'Hello chatter' # String, applies templating
|
||||
respond_as_reply: true # Boolean, optional, use Twitch-Reply feature in respond
|
||||
respond_fallback: 'Oh noes' # String, text to send if the template function causes
|
||||
# an error, applies templating (default: unset)
|
||||
to_channel: '#myotherchan' # String, channel to send the response to (default: unset)
|
||||
|
||||
# Issue a timeout on the user who wrote the chat-line
|
||||
- timeout: 1s # Duration value: 1s / 1m / 1h
|
||||
|
||||
# Update channel information (one of `update_game` and `update_title` must be defined)
|
||||
- channel: '{{ .channel }}' # String, applies templating
|
||||
update_game: 'Just Chatting' # String, optional, set game to given category
|
||||
update_title: 'My special title' # String, optional, set title to given title
|
||||
|
||||
# Set a variable to value defined for later usage
|
||||
- variable: myvar # String, name of the variable to set (applies templating)
|
||||
clear: false # Boolean, clear the variable
|
||||
set: '{{ .channel }}' # String, value to set the variable to (applies templating)
|
||||
|
||||
# Send a whisper (ATTENTION: You need to have a known / verified bot for this!)
|
||||
# Without being known / verified your whisper will just silently get dropped by Twitch
|
||||
# Go here to get that verification: https://dev.twitch.tv/limit-increase
|
||||
- whisper_to: '{{ .username }}' # String, username to send to, applies templating
|
||||
whisper_message: 'Ohai!' # String, message to send, applies templating
|
||||
# See the Actors page in the Wiki for available actors:
|
||||
# https://github.com/Luzifer/twitch-bot/wiki/Actors
|
||||
- type: "<actor type>"
|
||||
attributes:
|
||||
key: value
|
||||
|
||||
# Add a cooldown to the rule in general (not to trigger counters twice, ...)
|
||||
# Using this will prevent the rule to be executed in all matching channels
|
||||
|
Loading…
Reference in New Issue
Block a user