Compare commits

...

12 commits

Author SHA1 Message Date
4a05a8a7f2
prepare release v3.24.1 2024-01-24 20:54:11 +01:00
6f3a2b6193
Update dependencies
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-01-24 20:46:02 +01:00
0ebc68254d
[linkcheck] Fix tests broken by domain grabbers
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-01-24 20:36:14 +01:00
1298b76da9
prepare release v3.24.0 2024-01-24 20:19:13 +01:00
29b0e41218
[overlays] Add WebDAV support for remote Overlay editing
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-01-01 19:52:42 +01:00
c78356f68f
Lint: Update linter config, improve code quality
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-01-01 17:52:39 +01:00
7189232093
[overlays] Add support for replaying events
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-31 14:05:39 +01:00
4fdcd86dee
Update dependencies
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-31 13:48:40 +01:00
e1434eb403
[core] Add support for watch_streak event
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-24 15:32:48 +01:00
fa9f5591f6
[overlays] Fix: Do not spam logs with errors when overlay reloaded
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-24 14:40:41 +01:00
eb02858280
[linkcheck] Refactor: Improve wait-code
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-23 23:41:58 +01:00
61bab3c984
[eventsub] Fix: Log error when giving up subscription retries
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-22 15:18:52 +01:00
139 changed files with 1782 additions and 1205 deletions

View file

@ -12,34 +12,27 @@ output:
format: tab format: tab
issues: issues:
# This disables the included exclude-list in golangci-lint as that
# list for example fully hides G304 gosec rule, errcheck, exported
# rule of revive and other errors one really wants to see.
# Smme detail: https://github.com/golangci/golangci-lint/issues/456
exclude-use-default: false
# Don't limit the number of shown issues: Report ALL of them # Don't limit the number of shown issues: Report ALL of them
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
linters-settings:
forbidigo:
forbid:
- 'fmt\.Errorf' # Should use github.com/pkg/errors
funlen:
lines: 100
statements: 60
gocyclo:
# 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: linters:
disable-all: true disable-all: true
enable: enable:
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false] - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false]
- bidichk # Checks for dangerous unicode character sequences [fast: true, auto-fix: false]
- bodyclose # checks whether HTTP response body is closed successfully [fast: true, auto-fix: false] - bodyclose # checks whether HTTP response body is closed successfully [fast: true, auto-fix: false]
- containedctx # containedctx is a linter that detects struct contained context.Context field [fast: true, auto-fix: false]
- contextcheck # check the function whether use a non-inherited context [fast: false, auto-fix: false]
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false] - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false]
- durationcheck # check for two durations multiplied together [fast: false, auto-fix: false]
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: false, auto-fix: false]
- errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and optionally reports occations, where the check for the returned error can be omitted. [fast: false, auto-fix: false]
- exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false] - exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false]
- forbidigo # Forbids identifiers [fast: true, auto-fix: false] - forbidigo # Forbids identifiers [fast: true, auto-fix: false]
- funlen # Tool for detection of long functions [fast: true, auto-fix: false] - funlen # Tool for detection of long functions [fast: true, auto-fix: false]
@ -58,13 +51,124 @@ linters:
- ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false] - ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false]
- misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true] - misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true]
- nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false] - nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false]
- nilerr # Finds the code that returns nil even if it checks that the error is not nil. [fast: false, auto-fix: false]
- nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value. [fast: false, auto-fix: false]
- noctx # noctx finds sending http request without context.Context [fast: true, auto-fix: false] - noctx # noctx finds sending http request without context.Context [fast: true, auto-fix: false]
- nolintlint # Reports ill-formed or insufficient nolint directives [fast: true, auto-fix: false] - nolintlint # Reports ill-formed or insufficient nolint directives [fast: true, auto-fix: false]
- revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. [fast: false, auto-fix: false] - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. [fast: false, auto-fix: false]
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: true, auto-fix: false] - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: true, auto-fix: false]
- stylecheck # Stylecheck is a replacement for golint [fast: true, auto-fix: false] - stylecheck # Stylecheck is a replacement for golint [fast: true, auto-fix: false]
- tenv # tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 [fast: false, auto-fix: false]
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false] - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false]
- unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false] - unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false]
- unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false] - unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false]
- wastedassign # wastedassign finds wasted assignment statements. [fast: false, auto-fix: false]
- wrapcheck # Checks that errors returned from external packages are wrapped [fast: false, auto-fix: false]
linters-settings:
funlen:
lines: 100
statements: 60
gocyclo:
# 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+'
revive:
rules:
#- name: add-constant # Suggests using constant for magic numbers and string literals
# Opinion: Makes sense for strings, not for numbers but checks numbers
#- name: argument-limit # Specifies the maximum number of arguments a function can receive | Opinion: Don't need this
- name: atomic # Check for common mistaken usages of the `sync/atomic` package
- name: banned-characters # Checks banned characters in identifiers
arguments:
- ';' # Greek question mark
- name: bare-return # Warns on bare returns
- name: blank-imports # Disallows blank imports
- name: bool-literal-in-expr # Suggests removing Boolean literals from logic expressions
- name: call-to-gc # Warns on explicit call to the garbage collector
#- name: cognitive-complexity # Sets restriction for maximum Cognitive complexity.
# There is a dedicated linter for this
- name: confusing-naming # Warns on methods with names that differ only by capitalization
- name: confusing-results # Suggests to name potentially confusing function results
- name: constant-logical-expr # Warns on constant logical expressions
- name: context-as-argument # `context.Context` should be the first argument of a function.
- name: context-keys-type # Disallows the usage of basic types in `context.WithValue`.
#- name: cyclomatic # Sets restriction for maximum Cyclomatic complexity.
# There is a dedicated linter for this
#- name: datarace # Spots potential dataraces
# Is not (yet) available?
- name: deep-exit # Looks for program exits in funcs other than `main()` or `init()`
- name: defer # Warns on some [defer gotchas](https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-iii-36a1ab3d6ef1)
- name: dot-imports # Forbids `.` imports.
- name: duplicated-imports # Looks for packages that are imported two or more times
- name: early-return # Spots if-then-else statements that can be refactored to simplify code reading
- name: empty-block # Warns on empty code blocks
- name: empty-lines # Warns when there are heading or trailing newlines in a block
- name: errorf # Should replace `errors.New(fmt.Sprintf())` with `fmt.Errorf()`
- name: error-naming # Naming of error variables.
- name: error-return # The error return parameter should be last.
- name: error-strings # Conventions around error strings.
- name: exported # Naming and commenting conventions on exported symbols.
arguments: ['sayRepetitiveInsteadOfStutters']
#- name: file-header # Header which each file should have.
# Useless without config, have no config for it
- name: flag-parameter # Warns on boolean parameters that create a control coupling
#- name: function-length # Warns on functions exceeding the statements or lines max
# There is a dedicated linter for this
#- name: function-result-limit # Specifies the maximum number of results a function can return
# Opinion: Don't need this
- name: get-return # Warns on getters that do not yield any result
- name: identical-branches # Spots if-then-else statements with identical `then` and `else` branches
- name: if-return # Redundant if when returning an error.
#- name: imports-blacklist # Disallows importing the specified packages
# Useless without config, have no config for it
- name: import-shadowing # Spots identifiers that shadow an import
- name: increment-decrement # Use `i++` and `i--` instead of `i += 1` and `i -= 1`.
- name: indent-error-flow # Prevents redundant else statements.
#- name: line-length-limit # Specifies the maximum number of characters in a lined
# There is a dedicated linter for this
#- name: max-public-structs # The maximum number of public structs in a file.
# Opinion: Don't need this
- name: modifies-parameter # Warns on assignments to function parameters
- name: modifies-value-receiver # Warns on assignments to value-passed method receivers
#- name: nested-structs # Warns on structs within structs
# Opinion: Don't need this
- name: optimize-operands-order # Checks inefficient conditional expressions
#- name: package-comments # Package commenting conventions.
# Opinion: Don't need this
- name: range # Prevents redundant variables when iterating over a collection.
- name: range-val-address # Warns if address of range value is used dangerously
- name: range-val-in-closure # Warns if range value is used in a closure dispatched as goroutine
- name: receiver-naming # Conventions around the naming of receivers.
- name: redefines-builtin-id # Warns on redefinitions of builtin identifiers
#- name: string-format # Warns on specific string literals that fail one or more user-configured regular expressions
# Useless without config, have no config for it
- name: string-of-int # Warns on suspicious casts from int to string
- name: struct-tag # Checks common struct tags like `json`,`xml`,`yaml`
- name: superfluous-else # Prevents redundant else statements (extends indent-error-flow)
- name: time-equal # Suggests to use `time.Time.Equal` instead of `==` and `!=` for equality check time.
- name: time-naming # Conventions around the naming of time variables.
- name: unconditional-recursion # Warns on function calls that will lead to (direct) infinite recursion
- name: unexported-naming # Warns on wrongly named un-exported symbols
- name: unexported-return # Warns when a public return is from unexported type.
- name: unhandled-error # Warns on unhandled errors returned by funcion calls
arguments:
- "fmt.(Fp|P)rint(f|ln|)"
- name: unnecessary-stmt # Suggests removing or simplifying unnecessary statements
- name: unreachable-code # Warns on unreachable code
- name: unused-parameter # Suggests to rename or remove unused function parameters
- name: unused-receiver # Suggests to rename or remove unused method receivers
#- name: use-any # Proposes to replace `interface{}` with its alias `any`
# Is not (yet) available?
- name: useless-break # Warns on useless `break` statements in case clauses
- name: var-declaration # Reduces redundancies around variable declaration.
- name: var-naming # Naming rules.
- name: waitgroup-by-value # Warns on functions taking sync.WaitGroup as a by-value parameter
... ...

View file

@ -1,3 +1,36 @@
# 3.24.1 / 2024-01-24
* New Features
* [core] Add support for `watch_streak` event
* [overlays] Add support for replaying events
* Improvements
* [linkcheck] Refactor: Improve wait-code
* [overlays] Add WebDAV support for remote Overlay editing
* Bugfixes
* [ci] Lint: Update linter config, improve code quality
* [core] Update dependencies
* [eventsub] Fix: Log error when giving up subscription retries
* [linkcheck] Fix tests broken by domain grabbers
* [overlays] Fix: Do not spam logs with errors when overlay reloaded
# 3.24.0 / 2024-01-24
* New Features
* [core] Add support for `watch_streak` event
* [overlays] Add support for replaying events
* Improvements
* [linkcheck] Refactor: Improve wait-code
* [overlays] Add WebDAV support for remote Overlay editing
* Bugfixes
* [ci] Lint: Update linter config, improve code quality
* [core] Update dependencies
* [eventsub] Fix: Log error when giving up subscription retries
* [overlays] Fix: Do not spam logs with errors when overlay reloaded
# 3.23.1 / 2023-12-20 # 3.23.1 / 2023-12-20
* Bugfixes * Bugfixes

View file

@ -56,7 +56,7 @@ trivy:
# -- Documentation Site -- # -- Documentation Site --
docs: actor_docs template_docs docs: actor_docs eventclient_docs template_docs
actor_docs: actor_docs:
go run . --storage-conn-string $(shell mktemp).db actor-docs >docs/content/configuration/actors.md go run . --storage-conn-string $(shell mktemp).db actor-docs >docs/content/configuration/actors.md

View file

@ -45,8 +45,10 @@ func init() {
}) })
} }
// ActorScript contains an actor to execute arbitrary commands and scripts
type ActorScript struct{} type ActorScript struct{}
// Execute implements actor interface
func (ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (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") command, err := attrs.StringSlice("command")
if err != nil { if err != nil {
@ -121,9 +123,13 @@ func (ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, event
return preventCooldown, nil return preventCooldown, nil
} }
// IsAsync implements actor interface
func (ActorScript) IsAsync() bool { return false } func (ActorScript) IsAsync() bool { return false }
func (ActorScript) Name() string { return "script" }
// Name implements actor interface
func (ActorScript) Name() string { return "script" }
// Validate implements actor interface
func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
cmd, err := attrs.StringSlice("command") cmd, err := attrs.StringSlice("command")
if err != nil || len(cmd) == 0 { if err != nil || len(cmd) == 0 {

20
auth.go
View file

@ -9,7 +9,7 @@ import (
"github.com/gofrs/uuid/v3" "github.com/gofrs/uuid/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -39,7 +39,7 @@ func init() {
}, },
} { } {
if err := registerRoute(rd); err != nil { if err := registerRoute(rd); err != nil {
log.WithError(err).Fatal("Unable to register auth routes") logrus.WithError(err).Fatal("Unable to register auth routes")
} }
} }
} }
@ -71,7 +71,11 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
return return
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
var rData twitch.OAuthTokenResponse var rData twitch.OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil { if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
@ -79,7 +83,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
return return
} }
_, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser() _, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser(r.Context())
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -129,7 +133,11 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
return return
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
var rData twitch.OAuthTokenResponse var rData twitch.OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil { if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
@ -137,7 +145,7 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
return return
} }
_, grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser() _, grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser(r.Context())
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return

View file

@ -31,7 +31,7 @@ func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time
var httpError twitch.HTTPError var httpError twitch.HTTPError
id, user, err := tc.GetAuthorizedUser() id, user, err := tc.GetAuthorizedUser(context.Background())
switch { switch {
case err == nil: case err == nil:
// We got a valid user, continue check below // We got a valid user, continue check below

View file

@ -43,8 +43,17 @@ func fillAuthToken(token *configAuthToken) error {
func writeAuthMiddleware(h http.Handler, module string) http.Handler { func writeAuthMiddleware(h http.Handler, module string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization") _, pass, hasBasicAuth := r.BasicAuth()
if token == "" {
var token string
switch {
case hasBasicAuth && pass != "":
token = pass
case r.Header.Get("Authorization") != "":
token = r.Header.Get("Authorization")
default:
http.Error(w, "auth not successful", http.StatusForbidden) http.Error(w, "auth not successful", http.StatusForbidden)
return return
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@ -71,7 +72,7 @@ func (a *autoMessage) CanSend() bool {
} }
if a.OnlyOnLive { if a.OnlyOnLive {
streamLive, err := twitchClient.HasLiveStream(strings.TrimLeft(a.Channel, "#")) streamLive, err := twitchClient.HasLiveStream(context.Background(), strings.TrimLeft(a.Channel, "#"))
if err != nil { if err != nil {
log.WithError(err).Error("Unable to determine channel live status") log.WithError(err).Error("Unable to determine channel live status")
return false return false

View file

@ -16,6 +16,6 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
_, user, err := tc.GetAuthorizedUser() _, user, err := tc.GetAuthorizedUser(r.Context())
return user, tc, errors.Wrap(err, "getting authorized user") return user, tc, errors.Wrap(err, "getting authorized user")
} }

View file

@ -13,7 +13,7 @@ import (
"github.com/gofrs/uuid/v3" "github.com/gofrs/uuid/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
@ -23,7 +23,11 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const expectedMinConfigVersion = 2 const (
expectedMinConfigVersion = 2
rawLogDirPerm = 0o755
rawLogFilePerm = 0o644
)
var ( var (
//go:embed default_config.yaml //go:embed default_config.yaml
@ -121,10 +125,10 @@ func loadConfig(filename string) error {
if err = config.CloseRawMessageWriter(); err != nil { if err = config.CloseRawMessageWriter(); err != nil {
return errors.Wrap(err, "closing old raw log writer") return errors.Wrap(err, "closing old raw log writer")
} }
if err = os.MkdirAll(path.Dir(tmpConfig.RawLog), 0o755); err != nil { //nolint:gomnd // This is a common directory permission if err = os.MkdirAll(path.Dir(tmpConfig.RawLog), rawLogDirPerm); err != nil {
return errors.Wrap(err, "creating directories for raw log") return errors.Wrap(err, "creating directories for raw log")
} }
if tmpConfig.rawLogWriter, err = os.OpenFile(tmpConfig.RawLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err != nil { //nolint:gomnd // This is a common file permission if tmpConfig.rawLogWriter, err = os.OpenFile(tmpConfig.RawLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, rawLogFilePerm); err != nil {
return errors.Wrap(err, "opening raw log for appending") return errors.Wrap(err, "opening raw log for appending")
} }
} }
@ -132,7 +136,7 @@ func loadConfig(filename string) error {
config = tmpConfig config = tmpConfig
timerService.UpdatePermitTimeout(tmpConfig.PermitTimeout) timerService.UpdatePermitTimeout(tmpConfig.PermitTimeout)
log.WithFields(log.Fields{ logrus.WithFields(logrus.Fields{
"auto_messages": len(config.AutoMessages), "auto_messages": len(config.AutoMessages),
"rules": len(config.Rules), "rules": len(config.Rules),
"channels": len(config.Channels), "channels": len(config.Channels),
@ -145,11 +149,15 @@ func loadConfig(filename string) error {
} }
func parseConfigFromYAML(filename string, obj interface{}, strict bool) error { func parseConfigFromYAML(filename string, obj interface{}, strict bool) error {
f, err := os.Open(filename) f, err := os.Open(filename) //#nosec:G304 // This is intended to open a variable file
if err != nil { if err != nil {
return errors.Wrap(err, "open config file") return errors.Wrap(err, "open config file")
} }
defer f.Close() defer func() {
if err := f.Close(); err != nil {
logrus.WithError(err).Error("closing config file (leaked fd)")
}
}()
decoder := yaml.NewDecoder(f) decoder := yaml.NewDecoder(f)
decoder.KnownFields(strict) decoder.KnownFields(strict)
@ -205,10 +213,13 @@ func writeConfigToYAML(filename, authorName, authorEmail, summary string, obj *c
fmt.Fprintf(tmpFile, "# Automatically updated by %s using Config-Editor frontend, last update: %s\n", authorName, time.Now().Format(time.RFC3339)) fmt.Fprintf(tmpFile, "# Automatically updated by %s using Config-Editor frontend, last update: %s\n", authorName, time.Now().Format(time.RFC3339))
if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil { if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil {
tmpFile.Close() tmpFile.Close() //nolint:errcheck,gosec,revive
return errors.Wrap(err, "encoding config") return errors.Wrap(err, "encoding config")
} }
tmpFile.Close()
if err = tmpFile.Close(); err != nil {
return fmt.Errorf("closing temp config: %w", err)
}
if err = os.Rename(tmpFileName, filename); err != nil { if err = os.Rename(tmpFileName, filename); err != nil {
return errors.Wrap(err, "moving config to location") return errors.Wrap(err, "moving config to location")
@ -220,7 +231,7 @@ func writeConfigToYAML(filename, authorName, authorEmail, summary string, obj *c
git := newGitHelper(path.Dir(filename)) git := newGitHelper(path.Dir(filename))
if !git.HasRepo() { if !git.HasRepo() {
log.Error("Instructed to track changes using Git, but config not in repo") logrus.Error("Instructed to track changes using Git, but config not in repo")
return nil return nil
} }
@ -231,11 +242,15 @@ func writeConfigToYAML(filename, authorName, authorEmail, summary string, obj *c
} }
func writeDefaultConfigFile(filename string) error { func writeDefaultConfigFile(filename string) error {
f, err := os.Create(filename) f, err := os.Create(filename) //#nosec:G304 // This is intended to open a variable file
if err != nil { if err != nil {
return errors.Wrap(err, "creating config file") return errors.Wrap(err, "creating config file")
} }
defer f.Close() defer func() {
if err := f.Close(); err != nil {
logrus.WithError(err).Error("closing config file (leaked fd)")
}
}()
_, err = f.Write(defaultConfigurationYAML) _, err = f.Write(defaultConfigurationYAML)
return errors.Wrap(err, "writing default config") return errors.Wrap(err, "writing default config")
@ -276,11 +291,16 @@ func (c configAuthToken) validate(token string) error {
} }
} }
func (c *configFile) CloseRawMessageWriter() error { func (c *configFile) CloseRawMessageWriter() (err error) {
if c == nil || c.rawLogWriter == nil { if c == nil || c.rawLogWriter == nil {
return nil return nil
} }
return c.rawLogWriter.Close()
if err = c.rawLogWriter.Close(); err != nil {
return fmt.Errorf("closing raw-log writer: %w", err)
}
return nil
} }
func (c configFile) GetMatchingRules(m *irc.Message, event *string, eventData *plugins.FieldCollection) []*plugins.Rule { func (c configFile) GetMatchingRules(m *irc.Message, event *string, eventData *plugins.FieldCollection) []*plugins.Rule {
@ -319,14 +339,14 @@ func (configFile) fixedDuration(d time.Duration) time.Duration {
if d > time.Second { if d > time.Second {
return d return d
} }
return d * time.Second return d * time.Second //nolint:durationcheck // Error is handled before
} }
func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration { func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
if d == nil || *d >= time.Second { if d == nil || *d >= time.Second {
return d return d
} }
fd := *d * time.Second fd := *d * time.Second //nolint:durationcheck // Error is handled before
return &fd return &fd
} }
@ -368,11 +388,11 @@ func (c *configFile) fixTokenHashStorage() (err error) {
func (c *configFile) runLoadChecks() (err error) { func (c *configFile) runLoadChecks() (err error) {
if len(c.Channels) == 0 { if len(c.Channels) == 0 {
log.Warn("Loaded config with empty channel list") logrus.Warn("Loaded config with empty channel list")
} }
if len(c.Rules) == 0 { if len(c.Rules) == 0 {
log.Warn("Loaded config with empty ruleset") logrus.Warn("Loaded config with empty ruleset")
} }
var seen []string var seen []string
@ -397,7 +417,7 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
nam.lastMessageSent = time.Now() nam.lastMessageSent = time.Now()
if !nam.IsValid() { if !nam.IsValid() {
log.WithField("index", idx).Warn("Auto-Message configuration is invalid and therefore disabled") logrus.WithField("index", idx).Warn("Auto-Message configuration is invalid and therefore disabled")
} }
if old == nil { if old == nil {
@ -426,7 +446,7 @@ func (c configFile) validateRuleActions() error {
var hasError bool var hasError bool
for _, r := range c.Rules { for _, r := range c.Rules {
logger := log.WithField("rule", r.MatcherID()) logger := logrus.WithField("rule", r.MatcherID())
if err := r.Validate(validateTemplate); err != nil { if err := r.Validate(validateTemplate); err != nil {
logger.WithError(err).Error("Rule reported invalid config") logger.WithError(err).Error("Rule reported invalid config")

View file

@ -53,7 +53,7 @@ func registerEditorFrontend() {
return return
} }
io.Copy(w, f) io.Copy(w, f) //nolint:errcheck,gosec
}) })
router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/editor/vars.json", func(w http.ResponseWriter, r *http.Request) {

View file

@ -244,7 +244,7 @@ func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) {
} }
for i := range payload.BotEditors { for i := range payload.BotEditors {
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i]) usr, err := twitchClient.GetUserInformation(r.Context(), payload.BotEditors[i])
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
return return

View file

@ -143,7 +143,7 @@ func configEditorGlobalGetModules(w http.ResponseWriter, _ *http.Request) {
} }
func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) { func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) {
usr, err := twitchClient.GetUserInformation(r.FormValue("user")) usr, err := twitchClient.GetUserInformation(r.Context(), r.FormValue("user"))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -160,7 +160,7 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
log.WithError(err).Error("Unable to initialize websocket") log.WithError(err).Error("Unable to initialize websocket")
return return
} }
defer conn.Close() defer conn.Close() //nolint:errcheck
var ( var (
frontendNotify = make(chan string, 1) frontendNotify = make(chan string, 1)
@ -190,7 +190,6 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
log.WithError(err).Debug("Unable to send websocket ping") log.WithError(err).Debug("Unable to send websocket ping")
return return
} }
} }
} }
} }

View file

@ -90,7 +90,7 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
} }
if msg.SubscribeFrom != nil { if msg.SubscribeFrom != nil {
if _, err = msg.UpdateFromSubscription(); err != nil { if _, err = msg.UpdateFromSubscription(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"math/rand" "math/rand"
@ -24,7 +25,7 @@ func updateConfigFromRemote() {
for _, r := range cfg.Rules { for _, r := range cfg.Rules {
logger := log.WithField("rule", r.MatcherID()) logger := log.WithField("rule", r.MatcherID())
rhu, err := r.UpdateFromSubscription() rhu, err := r.UpdateFromSubscription(context.Background())
if err != nil { if err != nil {
logger.WithError(err).Error("updating rule") logger.WithError(err).Error("updating rule")
continue continue

View file

@ -502,7 +502,7 @@ Scans for links in the message and adds the "links" field to the event data
```yaml ```yaml
- type: linkdetector - type: linkdetector
attributes: attributes:
# Enable heuristic scans to find links with spaces or other means of obfuscation in them # Enable heuristic scans to find links with spaces or other means of obfuscation in them (quite slow and will detect MANY false-positive links, only use for blacklisting links!)
# Optional: true # Optional: true
# Type: bool # Type: bool
heuristic: false heuristic: false

View file

@ -441,7 +441,7 @@ Example:
``` ```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% # Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 73% < Your int this hour: 66%
``` ```
### `streamUptime` ### `streamUptime`

View file

@ -40,3 +40,25 @@ Here you can see the Debug Overlay configured with:
As those parameters are configured through the URL hash (`#...`) they are never sent to the server, therefore are not logged in any access-logs and exist only in the local URL. So with a custom overlay you would put `https://your-bot.example.com/overlays/myoverlay.html#token=55cdb1e4-c776-4467-8560-a47a4abc55de` into your OBS browser source and your overlay would be able to communicate with the bot. As those parameters are configured through the URL hash (`#...`) they are never sent to the server, therefore are not logged in any access-logs and exist only in the local URL. So with a custom overlay you would put `https://your-bot.example.com/overlays/myoverlay.html#token=55cdb1e4-c776-4467-8560-a47a4abc55de` into your OBS browser source and your overlay would be able to communicate with the bot.
The debug-overlay can be used to view all events received within the bot you can react on in overlays and bot rules. The debug-overlay can be used to view all events received within the bot you can react on in overlays and bot rules.
## Remote editing Overlays with local Editor
In order to enable you to edit the overlays remotely when hosting the bot on a server the bot exposes a WebDAV interface you can locally mount and work on using your favorite editor. To mount the WebDAV I recommend [rclone](https://rclone.org/). You will need the URL your bot is available at and a token with `overlays` permission:
```
# rclone obscure 55cdb1e4-c776-4467-8560-a47a4abc55de
MqO0FLdbg3txom2IpUMsVVIqnHwYDefms4EKRqoV1MGhCFkBmWnhvVRdqTyCSFtmvP-AYg
# cat /tmp/rclone.conf
[bot]
type = webdav
url = https://your-bot.example.com/overlays/dav/
user = dav
pass = MqO0FLdbg3txom2IpUMsVVIqnHwYDefms4EKRqoV1MGhCFkBmWnhvVRdqTyCSFtmvP-AYg
# rclone --config /tmp/rclone.conf mount bot:/ /tmp/bot-overlays
# code /tmp/bot-overlays
```
What I've done here is to obscure the token (`rclone` wants the token to be in an obscured format), create a config containing the WebDAV remote, mount the WebDAV remote to a local directory and open it with VSCode to edit the overlays. When saving the files locally `rclone` will upload them to the bot and refreshing the overlay in your browser / OBS will give you the new version.

View file

@ -17,6 +17,9 @@ weight: 10000
<dt><a href="#Options">Options</a> : <code>Object</code></dt> <dt><a href="#Options">Options</a> : <code>Object</code></dt>
<dd><p>Options to pass to the EventClient constructor</p> <dd><p>Options to pass to the EventClient constructor</p>
</dd> </dd>
<dt><a href="#SocketMessage">SocketMessage</a> : <code>Object</code></dt>
<dd><p>SocketMessage received for every event and passed to the new <code>(eventObj) =&gt; { ... }</code> handlers</p>
</dd>
</dl> </dl>
<a name="EventClient"></a> <a name="EventClient"></a>
@ -31,6 +34,7 @@ EventClient abstracts the connection to the bot websocket for events
* [.apiBase()](#EventClient+apiBase) ⇒ <code>string</code> * [.apiBase()](#EventClient+apiBase) ⇒ <code>string</code>
* [.paramOptionFallback(key, [fallback])](#EventClient+paramOptionFallback) ⇒ <code>\*</code> * [.paramOptionFallback(key, [fallback])](#EventClient+paramOptionFallback) ⇒ <code>\*</code>
* [.renderTemplate(template)](#EventClient+renderTemplate) ⇒ <code>Promise</code> * [.renderTemplate(template)](#EventClient+renderTemplate) ⇒ <code>Promise</code>
* [.replayEvent(eventId)](#EventClient+replayEvent) ⇒ <code>Promise</code>
<a name="new_EventClient_new"></a> <a name="new_EventClient_new"></a>
@ -74,6 +78,18 @@ Renders a given template using the bots msgformat API (supports all templating y
| --- | --- | --- | | --- | --- | --- |
| template | <code>string</code> | The template to render | | template | <code>string</code> | The template to render |
<a name="EventClient+replayEvent"></a>
### eventClient.replayEvent(eventId) ⇒ <code>Promise</code>
Triggers a replay of the given event to all overlays currently listening for events. This event will have the `is_live` flag set to `false`.
**Kind**: instance method of [<code>EventClient</code>](#EventClient)
**Returns**: <code>Promise</code> - Promise of the fetch request
| Param | Type | Description |
| --- | --- | --- |
| eventId | <code>Number</code> | The ID of the event received through the SocketMessage object |
<a name="Options"></a> <a name="Options"></a>
## Options : <code>Object</code> ## Options : <code>Object</code>
@ -84,9 +100,26 @@ Options to pass to the EventClient constructor
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| [channel] | <code>string</code> | | Filter for specific channel events (format: `#channel`) | | [channel] | <code>String</code> | | Filter for specific channel events (format: `#channel`) |
| [handlers] | <code>Object</code> | <code>{}</code> | Map event types to callback functions `(event, fields, time, live) => {...}` | | [handlers] | <code>Object</code> | <code>{}</code> | Map event types to callback functions `(eventObj) => { ... }` (new) or `(event, fields, time, live) => {...}` (old) |
| [maxReplayAge] | <code>number</code> | <code>-1</code> | Number of hours to replay the events for (-1 = infinite) | | [maxReplayAge] | <code>Number</code> | <code>-1</code> | Number of hours to replay the events for (-1 = infinite) |
| [replay] | <code>boolean</code> | <code>false</code> | Request a replay at connect (requires channel to be set to a channel name) | | [replay] | <code>Boolean</code> | <code>false</code> | Request a replay at connect (requires channel to be set to a channel name) |
| [token] | <code>string</code> | | API access token to use to connect to the WebSocket (if not set, must be provided through URL hash) | | [token] | <code>String</code> | | API access token to use to connect to the WebSocket (if not set, must be provided through URL hash) |
<a name="SocketMessage"></a>
## SocketMessage : <code>Object</code>
SocketMessage received for every event and passed to the new `(eventObj) => { ... }` handlers
**Kind**: global typedef
**Properties**
| Name | Type | Description |
| --- | --- | --- |
| [event_id] | <code>Number</code> | UID of the event used to re-trigger an event |
| [is_live] | <code>Boolean</code> | Whether the event was sent through a replay (false) or occurred live (true) |
| [reason] | <code>String</code> | Reason of this message (one of `bulk-replay`, `live-event`, `single-replay`) |
| [time] | <code>String</code> | RFC3339 timestamp of the event |
| [type] | <code>String</code> | Event type (i.e. `raid`, `sub`, ...) |
| [fields] | <code>Object</code> | string->any mapping of fields available for the event |

View file

@ -42,6 +42,7 @@ var (
eventTypeSubmysterygift = ptrStr("submysterygift") eventTypeSubmysterygift = ptrStr("submysterygift")
eventTypeSub = ptrStr("sub") eventTypeSub = ptrStr("sub")
eventTypeTimeout = ptrStr("timeout") eventTypeTimeout = ptrStr("timeout")
eventTypeWatchStreak = ptrStr("watch_streak")
eventTypeWhisper = ptrStr("whisper") eventTypeWhisper = ptrStr("whisper")
eventTypeTwitchCategoryUpdate = ptrStr("category_update") eventTypeTwitchCategoryUpdate = ptrStr("category_update")
@ -76,6 +77,7 @@ var (
eventTypeSubgift, eventTypeSubgift,
eventTypeSubmysterygift, eventTypeSubmysterygift,
eventTypeTimeout, eventTypeTimeout,
eventTypeWatchStreak,
eventTypeWhisper, eventTypeWhisper,
eventTypeTwitchCategoryUpdate, eventTypeTwitchCategoryUpdate,

View file

@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/Masterminds/sprig/v3" "github.com/Masterminds/sprig/v3"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
@ -78,7 +78,7 @@ func (t *templateFuncProvider) Register(name string, fg plugins.TemplateFuncGett
defer t.lock.Unlock() defer t.lock.Unlock()
if _, ok := t.funcs[name]; ok { if _, ok := t.funcs[name]; ok {
log.Fatalf("Duplicate registration of %q template function", name) //nolint:gocritic // Yeah, the unlock will not run but the process will end logrus.Fatalf("Duplicate registration of %q template function", name)
} }
t.funcs[name] = fg t.funcs[name] = fg
@ -108,7 +108,7 @@ func init() {
var parts []string var parts []string
for idx, div := range []time.Duration{time.Hour, time.Minute, time.Second} { for idx, div := range []time.Duration{time.Hour, time.Minute, time.Second} {
part := dLeft / div part := dLeft / div
dLeft -= part * div dLeft -= part * div //nolint:durationcheck // One is static, this is fine
if len(units) <= idx || units[idx] == "" { if len(units) <= idx || units[idx] == "" {
continue continue

2
git.go
View file

@ -56,6 +56,6 @@ func (g gitHelper) HasRepo() bool {
return err == nil return err == nil
} }
func (g gitHelper) getSignature(name, mail string) *object.Signature { func (gitHelper) getSignature(name, mail string) *object.Signature {
return &object.Signature{Name: name, Email: mail, When: time.Now()} return &object.Signature{Name: name, Email: mail, When: time.Now()}
} }

41
go.mod
View file

@ -3,20 +3,20 @@ module github.com/Luzifer/twitch-bot/v3
go 1.21 go 1.21
require ( require (
github.com/Luzifer/go-openssl/v4 v4.2.1 github.com/Luzifer/go-openssl/v4 v4.2.2
github.com/Luzifer/go_helpers/v2 v2.22.0 github.com/Luzifer/go_helpers/v2 v2.22.0
github.com/Luzifer/korvike/functions v0.11.0 github.com/Luzifer/korvike/functions v0.11.0
github.com/Luzifer/rconfig/v2 v2.4.0 github.com/Luzifer/rconfig/v2 v2.5.0
github.com/Masterminds/sprig/v3 v3.2.3 github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.26.0
github.com/glebarez/sqlite v1.10.0 github.com/glebarez/sqlite v1.10.0
github.com/go-git/go-git/v5 v5.10.1 github.com/go-git/go-git/v5 v5.11.0
github.com/go-sql-driver/mysql v1.7.1 github.com/go-sql-driver/mysql v1.7.1
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2 github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/itchyny/gojq v0.12.13 github.com/itchyny/gojq v0.12.14
github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/orandin/sentrus v1.0.0 github.com/orandin/sentrus v1.0.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@ -24,7 +24,8 @@ require (
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.16.0 golang.org/x/crypto v0.18.0
golang.org/x/net v0.20.0
gopkg.in/irc.v4 v4.0.0 gopkg.in/irc.v4 v4.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
@ -37,20 +38,20 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/cloudflare/circl v1.3.6 // indirect github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.14.1 // indirect github.com/fatih/color v1.14.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.4.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.4.0 // indirect github.com/hashicorp/go-hclog v1.4.0 // indirect
@ -61,13 +62,13 @@ require (
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.6 // indirect github.com/hashicorp/go-sockaddr v1.0.6 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/vault/api v1.10.0 // indirect github.com/hashicorp/vault/api v1.11.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect github.com/imdario/mergo v0.3.16 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.0 // indirect github.com/jackc/pgx/v5 v5.5.2 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
@ -86,21 +87,19 @@ require (
github.com/sergi/go-diff v1.3.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect
github.com/shopspring/decimal v1.3.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect
github.com/skeema/knownhosts v1.2.1 // indirect github.com/skeema/knownhosts v1.2.1 // indirect
github.com/spf13/cast v1.5.1 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/sync v0.6.0 // indirect
golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.16.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect golang.org/x/tools v0.17.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.40.7 // indirect
modernc.org/libc v1.34.11 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.27.0 // indirect modernc.org/sqlite v1.28.0 // indirect
) )

88
go.sum
View file

@ -2,14 +2,14 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Luzifer/go-openssl/v4 v4.2.1 h1:0+/gaQ5TcBhGmVqGrfyA21eujlbbaNwj0VlOA3nh4ts= github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc=
github.com/Luzifer/go-openssl/v4 v4.2.1/go.mod h1:CZZZWY0buCtkxrkqDPQYigC4Kn55UuO97TEoV+hwz2s= github.com/Luzifer/go-openssl/v4 v4.2.2/go.mod h1:+kAwI4NpyYXoWil85gKSCEJNoCQlMeFikEMn2f+5ffc=
github.com/Luzifer/go_helpers/v2 v2.22.0 h1:rJrZkJDzAiq4J0RUbwPI7kQ5rUy7BYQ/GUpo3fSM0y0= github.com/Luzifer/go_helpers/v2 v2.22.0 h1:rJrZkJDzAiq4J0RUbwPI7kQ5rUy7BYQ/GUpo3fSM0y0=
github.com/Luzifer/go_helpers/v2 v2.22.0/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA= github.com/Luzifer/go_helpers/v2 v2.22.0/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA=
github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q= github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q=
github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU= github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o= github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok=
github.com/Luzifer/rconfig/v2 v2.4.0/go.mod h1:hWF3ZVSusbYlg5bEvCwalEyUSY+0JPJWUiIu7rBmav8= github.com/Luzifer/rconfig/v2 v2.5.0/go.mod h1:eGWUPQeCPv/Pr/p0hjmwFgI20uqvwi/Szen69hUzGzU=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
@ -20,8 +20,8 @@ github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBa
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -34,8 +34,8 @@ github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTx
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -52,12 +52,12 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= github.com/getsentry/sentry-go v0.26.0 h1:IX3++sF6/4B5JcevhdZfdKIHfyvMmAq/UnqcyT2H6mA=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/getsentry/sentry-go v0.26.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc= github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA= github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
@ -70,8 +70,8 @@ github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.10.1 h1:tu8/D8i+TWxgKpzQ3Vc43e+kkhXqtsZCKI/egajKnxk= github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
github.com/go-git/go-git/v5 v5.10.1/go.mod h1:uEuHjxkHap8kAl//V5F/nNWwqIYtP/402ddd05mp0wg= github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
@ -99,8 +99,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
@ -143,8 +143,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
github.com/hashicorp/vault/api v1.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p2tOJQ= github.com/hashicorp/vault/api v1.11.0 h1:AChWByeHf4/P9sX3Y1B7vFsQhZO2BgQiCMQ2SA1P1UY=
github.com/hashicorp/vault/api v1.10.0/go.mod h1:jo5Y/ET+hNyz+JnKDt8XLAdKs+AM0G5W0Vp1IrFI8N8= github.com/hashicorp/vault/api v1.11.0/go.mod h1:si+lJCYO7oGkIoNPAN8j3azBLTn9SjMGS+jFaHd1Cck=
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
@ -154,16 +154,16 @@ github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU= github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4= github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA=
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -248,8 +248,8 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -277,8 +277,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -299,8 +299,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -309,8 +309,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -334,15 +334,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -365,8 +365,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@ -404,11 +404,11 @@ gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.34.11 h1:hQDcIUlSG4QAOkXCIQKkaAOV5ptXvkOx4ddbXzgW2JU= modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg=
modernc.org/libc v1.34.11/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/libc v1.40.7/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=

View file

@ -1,6 +1,9 @@
// Package announce contains a chat essage handler to create
// announcements from the bot
package announce package announce
import ( import (
"context"
"regexp" "regexp"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -16,6 +19,7 @@ var (
announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`) announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -32,7 +36,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("announce message does not match required format") return errors.New("announce message does not match required format")
} }
if err := botTwitchClient.SendChatAnnouncement(channel, matches[1], matches[2]); err != nil { if err := botTwitchClient.SendChatAnnouncement(context.Background(), channel, matches[1], matches[2]); err != nil {
return errors.Wrap(err, "sending announcement") return errors.Wrap(err, "sending announcement")
} }

View file

@ -1,6 +1,9 @@
// Package ban contains actors to ban/unban users in a channel
package ban package ban
import ( import (
"context"
"fmt"
"net/http" "net/http"
"regexp" "regexp"
@ -21,7 +24,8 @@ var (
banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`) banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`)
) )
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -45,7 +49,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Executes a ban of an user in the specified channel", Description: "Executes a ban of an user in the specified channel",
HandlerFunc: handleAPIBan, HandlerFunc: handleAPIBan,
Method: http.MethodPost, Method: http.MethodPost,
@ -72,7 +76,9 @@ func Register(args plugins.RegistrationArguments) error {
Name: "user", Name: "user",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterMessageModFunc("/ban", handleChatCommand) args.RegisterMessageModFunc("/ban", handleChatCommand)
@ -81,7 +87,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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 }("") ptrStringEmpty := func(v string) *string { return &v }("")
reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData) reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData)
@ -91,6 +97,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.BanUser( botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData), plugins.DeriveUser(m, eventData),
0, 0,
@ -100,10 +107,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
reasonTemplate, err := attrs.String("reason") reasonTemplate, err := attrs.String("reason")
if err != nil || reasonTemplate == "" { if err != nil || reasonTemplate == "" {
return errors.New("reason must be non-empty string") return errors.New("reason must be non-empty string")
@ -124,7 +131,7 @@ func handleAPIBan(w http.ResponseWriter, r *http.Request) {
reason = r.FormValue("reason") reason = r.FormValue("reason")
) )
if err := botTwitchClient.BanUser(channel, user, 0, reason); err != nil { if err := botTwitchClient.BanUser(r.Context(), channel, user, 0, reason); err != nil {
http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError)
return return
} }
@ -140,7 +147,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("ban message does not match required format") return errors.New("ban message does not match required format")
} }
if err := botTwitchClient.BanUser(channel, matches[1], 0, matches[2]); err != nil { if err := botTwitchClient.BanUser(context.Background(), channel, matches[1], 0, matches[2]); err != nil {
return errors.Wrap(err, "executing ban") return errors.Wrap(err, "executing ban")
} }

View file

@ -1,3 +1,5 @@
// Package clip contains an actor to create clips on behalf of a
// channels owner
package clip package clip
import ( import (
@ -22,6 +24,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
hasPerm = args.HasPermissionForChannel hasPerm = args.HasPermissionForChannel

View file

@ -1,3 +1,5 @@
// Package clipdetector contains an actor to detect clip links in a
// message and populate a template variable
package clipdetector package clipdetector
import ( import (
@ -19,6 +21,7 @@ var (
clipIDScanner = regexp.MustCompile(`(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/([A-Za-z0-9_-]+)`) clipIDScanner = regexp.MustCompile(`(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/([A-Za-z0-9_-]+)`)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -33,8 +36,10 @@ func Register(args plugins.RegistrationArguments) error {
return nil return nil
} }
// Actor implements the actor interface
type Actor struct{} type Actor struct{}
// Execute implements the actor interface
func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
if eventData.HasAll("clips") { if eventData.HasAll("clips") {
// We already detected clips, lets not do it again // We already detected clips, lets not do it again
@ -70,8 +75,11 @@ func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
return false, nil return false, nil
} }
// IsAsync implements the actor interface
func (Actor) IsAsync() bool { return false } func (Actor) IsAsync() bool { return false }
// Name implements the actor interface
func (Actor) Name() string { return actorName } func (Actor) Name() string { return actorName }
// Validate implements the actor interface
func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil } func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil }

View file

@ -1,3 +1,4 @@
// Package commercial contains an actor to run commercials in a channel
package commercial package commercial
import ( import (
@ -27,6 +28,7 @@ var (
commercialChatcommandRegex = regexp.MustCompile(`^/commercial ([0-9]+)$`) commercialChatcommandRegex = regexp.MustCompile(`^/commercial ([0-9]+)$`)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
permCheckFn = args.HasPermissionForChannel permCheckFn = args.HasPermissionForChannel
@ -70,10 +72,10 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
return false, startCommercial(strings.TrimLeft(plugins.DeriveChannel(m, eventData), "#"), durationStr) return false, startCommercial(strings.TrimLeft(plugins.DeriveChannel(m, eventData), "#"), durationStr)
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
durationTemplate, err := attrs.String("duration") durationTemplate, err := attrs.String("duration")
if err != nil || durationTemplate == "" { if err != nil || durationTemplate == "" {
return errors.New("duration must be non-empty string") return errors.New("duration must be non-empty string")

View file

@ -1,3 +1,5 @@
// Package counter contains actors and template functions to work with
// database stored counters
package counter package counter
import ( import (
@ -22,20 +24,22 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
//
//nolint:funlen // This function is a few lines too long but only contains definitions //nolint:funlen // This function is a few lines too long but only contains definitions
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&Counter{}); err != nil { if err = db.DB().AutoMigrate(&counter{}); err != nil {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("counter", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("counter", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &Counter{}) return database.CopyObjects(src, target, &counter{}) //nolint:wrapcheck // internal helper
}) })
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor("counter", func() plugins.Actor { return &ActorCounter{} }) args.RegisterActor("counter", func() plugins.Actor { return &actorCounter{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{ args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Update counter values", Description: "Update counter values",
@ -73,7 +77,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Returns the (formatted) value as a plain string", Description: "Returns the (formatted) value as a plain string",
HandlerFunc: routeActorCounterGetValue, HandlerFunc: routeActorCounterGetValue,
Method: http.MethodGet, Method: http.MethodGet,
@ -95,9 +99,11 @@ func Register(args plugins.RegistrationArguments) error {
Name: "name", Name: "name",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Updates the value of the counter", Description: "Updates the value of the counter",
HandlerFunc: routeActorCounterSetValue, HandlerFunc: routeActorCounterSetValue,
Method: http.MethodPatch, Method: http.MethodPatch,
@ -125,7 +131,9 @@ func Register(args plugins.RegistrationArguments) error {
Name: "name", Name: "name",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterTemplateFunction("channelCounter", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} { args.RegisterTemplateFunction("channelCounter", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} {
return func(name string) (string, error) { return func(name string) (string, error) {
@ -157,7 +165,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterTemplateFunction("counterTopList", plugins.GenericTemplateFunctionGetter(func(prefix string, n int) ([]Counter, error) { args.RegisterTemplateFunction("counterTopList", plugins.GenericTemplateFunctionGetter(func(prefix string, n int) ([]counter, error) {
return getCounterTopList(db, prefix, n) return getCounterTopList(db, prefix, n)
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the top n counters for the given prefix as objects with Name and Value fields", Description: "Returns the top n counters for the given prefix as objects with Name and Value fields",
@ -169,7 +177,7 @@ func Register(args plugins.RegistrationArguments) error {
}) })
args.RegisterTemplateFunction("counterValue", plugins.GenericTemplateFunctionGetter(func(name string, _ ...string) (int64, error) { args.RegisterTemplateFunction("counterValue", plugins.GenericTemplateFunctionGetter(func(name string, _ ...string) (int64, error) {
return GetCounterValue(db, name) return getCounterValue(db, name)
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the current value of the counter which identifier was supplied", Description: "Returns the current value of the counter which identifier was supplied",
Syntax: "counterValue <counter name>", Syntax: "counterValue <counter name>",
@ -185,11 +193,11 @@ func Register(args plugins.RegistrationArguments) error {
mod = val[0] mod = val[0]
} }
if err := UpdateCounter(db, name, mod, false); err != nil { if err := updateCounter(db, name, mod, false); err != nil {
return 0, errors.Wrap(err, "updating counter") return 0, errors.Wrap(err, "updating counter")
} }
return GetCounterValue(db, name) return getCounterValue(db, name)
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Adds the given value (or 1 if no value) to the counter and returns its new value", Description: "Adds the given value (or 1 if no value) to the counter and returns its new value",
Syntax: "counterValueAdd <counter name> [increase=1]", Syntax: "counterValueAdd <counter name> [increase=1]",
@ -202,9 +210,9 @@ func Register(args plugins.RegistrationArguments) error {
return nil return nil
} }
type ActorCounter struct{} type actorCounter struct{}
func (a ActorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorCounter) Execute(_ *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) counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing response") return false, errors.Wrap(err, "preparing response")
@ -222,7 +230,7 @@ func (a ActorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, ev
} }
return false, errors.Wrap( return false, errors.Wrap(
UpdateCounter(db, counterName, counterValue, true), updateCounter(db, counterName, counterValue, true),
"set counter", "set counter",
) )
} }
@ -241,15 +249,15 @@ func (a ActorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, ev
} }
return false, errors.Wrap( return false, errors.Wrap(
UpdateCounter(db, counterName, counterStep, false), updateCounter(db, counterName, counterStep, false),
"update counter", "update counter",
) )
} }
func (a ActorCounter) IsAsync() bool { return false } func (actorCounter) IsAsync() bool { return false }
func (a ActorCounter) Name() string { return "counter" } func (actorCounter) Name() string { return "counter" }
func (a ActorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if cn, err := attrs.String("counter"); err != nil || cn == "" { if cn, err := attrs.String("counter"); err != nil || cn == "" {
return errors.New("counter name must be non-empty string") return errors.New("counter name must be non-empty string")
} }
@ -269,7 +277,7 @@ func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
template = "%d" template = "%d"
} }
cv, err := GetCounterValue(db, mux.Vars(r)["name"]) cv, err := getCounterValue(db, mux.Vars(r)["name"])
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return return
@ -291,7 +299,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
return return
} }
if err = UpdateCounter(db, mux.Vars(r)["name"], value, absolute); err != nil { if err = updateCounter(db, mux.Vars(r)["name"], value, absolute); err != nil {
http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError)
return return
} }

View file

@ -10,14 +10,14 @@ import (
) )
type ( type (
Counter struct { counter struct {
Name string `gorm:"primaryKey"` Name string `gorm:"primaryKey"`
Value int64 Value int64
} }
) )
func GetCounterValue(db database.Connector, counterName string) (int64, error) { func getCounterValue(db database.Connector, counterName string) (int64, error) {
var c Counter var c counter
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
err := db.DB().First(&c, "name = ?", counterName).Error err := db.DB().First(&c, "name = ?", counterName).Error
@ -31,9 +31,10 @@ func GetCounterValue(db database.Connector, counterName string) (int64, error) {
return c.Value, errors.Wrap(err, "querying counter") return c.Value, errors.Wrap(err, "querying counter")
} }
func UpdateCounter(db database.Connector, counterName string, value int64, absolute bool) error { //revive:disable-next-line:flag-parameter
func updateCounter(db database.Connector, counterName string, value int64, absolute bool) error {
if !absolute { if !absolute {
cv, err := GetCounterValue(db, counterName) cv, err := getCounterValue(db, counterName)
if err != nil { if err != nil {
return errors.Wrap(err, "getting previous value") return errors.Wrap(err, "getting previous value")
} }
@ -46,14 +47,14 @@ func UpdateCounter(db database.Connector, counterName string, value int64, absol
return tx.Clauses(clause.OnConflict{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}}, Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}), DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(Counter{Name: counterName, Value: value}).Error }).Create(counter{Name: counterName, Value: value}).Error
}), }),
"storing counter value", "storing counter value",
) )
} }
func getCounterRank(db database.Connector, prefix, name string) (rank, count int64, err error) { func getCounterRank(db database.Connector, prefix, name string) (rank, count int64, err error) {
var cc []Counter var cc []counter
if err = helpers.Retry(func() error { if err = helpers.Retry(func() error {
return db.DB(). return db.DB().
@ -74,8 +75,8 @@ func getCounterRank(db database.Connector, prefix, name string) (rank, count int
return rank, count, nil return rank, count, nil
} }
func getCounterTopList(db database.Connector, prefix string, n int) ([]Counter, error) { func getCounterTopList(db database.Connector, prefix string, n int) ([]counter, error) {
var cc []Counter var cc []counter
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
return db.DB(). return db.DB().

View file

@ -12,34 +12,34 @@ import (
func TestCounterStoreLoop(t *testing.T) { func TestCounterStoreLoop(t *testing.T) {
dbc := database.GetTestDatabase(t) dbc := database.GetTestDatabase(t)
dbc.DB().AutoMigrate(&Counter{}) require.NoError(t, dbc.DB().AutoMigrate(&counter{}))
counterName := "mytestcounter" counterName := "mytestcounter"
v, err := GetCounterValue(dbc, counterName) v, err := getCounterValue(dbc, counterName)
assert.NoError(t, err, "reading non-existent counter") assert.NoError(t, err, "reading non-existent counter")
assert.Equal(t, int64(0), v, "expecting 0 counter value on non-existent counter") assert.Equal(t, int64(0), v, "expecting 0 counter value on non-existent counter")
err = UpdateCounter(dbc, counterName, 5, true) err = updateCounter(dbc, counterName, 5, true)
assert.NoError(t, err, "inserting counter") assert.NoError(t, err, "inserting counter")
err = UpdateCounter(dbc, counterName, 1, false) err = updateCounter(dbc, counterName, 1, false)
assert.NoError(t, err, "updating counter") assert.NoError(t, err, "updating counter")
v, err = GetCounterValue(dbc, counterName) v, err = getCounterValue(dbc, counterName)
assert.NoError(t, err, "reading existent counter") assert.NoError(t, err, "reading existent counter")
assert.Equal(t, int64(6), v, "expecting counter value on existing counter") assert.Equal(t, int64(6), v, "expecting counter value on existing counter")
} }
func TestCounterTopListAndRank(t *testing.T) { func TestCounterTopListAndRank(t *testing.T) {
dbc := database.GetTestDatabase(t) dbc := database.GetTestDatabase(t)
dbc.DB().AutoMigrate(&Counter{}) require.NoError(t, dbc.DB().AutoMigrate(&counter{}))
counterTemplate := `#example:test:%v` counterTemplate := `#example:test:%v`
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
require.NoError( require.NoError(
t, t,
UpdateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true), updateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true),
"inserting counter %d", i, "inserting counter %d", i,
) )
} }
@ -48,7 +48,7 @@ func TestCounterTopListAndRank(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, cc, 3) assert.Len(t, cc, 3)
assert.Equal(t, []Counter{ assert.Equal(t, []counter{
{Name: "#example:test:5", Value: 5}, {Name: "#example:test:5", Value: 5},
{Name: "#example:test:4", Value: 4}, {Name: "#example:test:4", Value: 4},
{Name: "#example:test:3", Value: 3}, {Name: "#example:test:3", Value: 3},

View file

@ -1,3 +1,4 @@
// Package delay contains an actor to delay rule execution
package delay package delay
import ( import (
@ -11,6 +12,7 @@ import (
const actorName = "delay" const actorName = "delay"
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -46,7 +48,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
var ( var (
ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0) ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0)
delay = attrs.MustDuration("delay", ptrZeroDuration) delay = attrs.MustDuration("delay", ptrZeroDuration)
@ -66,9 +68,9 @@ func (a actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *plugin
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) { func (actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) {
return nil return nil
} }

View file

@ -1,6 +1,9 @@
// Package deleteactor contains an actor to delete messages
package deleteactor package deleteactor
import ( import (
"context"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
@ -12,6 +15,7 @@ const actorName = "delete"
var botTwitchClient *twitch.Client var botTwitchClient *twitch.Client
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -28,7 +32,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, _ *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, _ *plugins.FieldCollection) (preventCooldown bool, err error) {
msgID, ok := m.Tags["id"] msgID, ok := m.Tags["id"]
if !ok || msgID == "" { if !ok || msgID == "" {
return false, nil return false, nil
@ -36,6 +40,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.DeleteMessage( botTwitchClient.DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
), ),
@ -43,9 +48,9 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) { func (actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) {
return nil return nil
} }

View file

@ -1,3 +1,5 @@
// Package eventmod contains an actor to modify event data during rule
// execution by adding fields (template variables)
package eventmod package eventmod
import ( import (
@ -13,6 +15,7 @@ const actorName = "eventmod"
var formatMessage plugins.MsgFormatter var formatMessage plugins.MsgFormatter
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -41,7 +44,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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 }("") ptrStringEmpty := func(v string) *string { return &v }("")
fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData) fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData)
@ -63,10 +66,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
fieldsTemplate, err := attrs.String("fields") fieldsTemplate, err := attrs.String("fields")
if err != nil || fieldsTemplate == "" { if err != nil || fieldsTemplate == "" {
return errors.New("fields must be non-empty string") return errors.New("fields must be non-empty string")

View file

@ -1,3 +1,5 @@
// Package filesay contains an actor to paste a remote URL as chat
// commands i.e. for bulk banning users
package filesay package filesay
import ( import (
@ -8,6 +10,7 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -24,6 +27,7 @@ var (
send plugins.SendMessageFunc send plugins.SendMessageFunc
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage send = args.SendMessage
@ -53,7 +57,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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 }("") ptrStringEmpty := func(v string) *string { return &v }("")
source, err := formatMessage(attrs.MustString("source", ptrStringEmpty), m, r, eventData) source, err := formatMessage(attrs.MustString("source", ptrStringEmpty), m, r, eventData)
@ -81,7 +85,11 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing HTTP request") return false, errors.Wrap(err, "executing HTTP request")
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return false, errors.Errorf("http status %d", resp.StatusCode) return false, errors.Errorf("http status %d", resp.StatusCode)
@ -103,10 +111,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return true } func (actor) IsAsync() bool { return true }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error {
sourceTpl, err := attrs.String("source") sourceTpl, err := attrs.String("source")
if err != nil || sourceTpl == "" { if err != nil || sourceTpl == "" {
return errors.New("source is expected to be non-empty string") return errors.New("source is expected to be non-empty string")

View file

@ -1,3 +1,5 @@
// Package linkdetector contains an actor to detect links in a message
// and add them to a variable
package linkdetector package linkdetector
import ( import (
@ -11,6 +13,7 @@ const actorName = "linkdetector"
var ptrFalse = func(v bool) *bool { return &v }(false) var ptrFalse = func(v bool) *bool { return &v }(false)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -35,8 +38,10 @@ func Register(args plugins.RegistrationArguments) error {
return nil return nil
} }
// Actor implements the actor interface
type Actor struct{} type Actor struct{}
// Execute implements the actor interface
func (Actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (Actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
if eventData.HasAll("links") { if eventData.HasAll("links") {
// We already detected links, lets not do it again // We already detected links, lets not do it again
@ -52,8 +57,11 @@ func (Actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *
return false, nil return false, nil
} }
// IsAsync implements the actor interface
func (Actor) IsAsync() bool { return false } func (Actor) IsAsync() bool { return false }
// Name implements the actor interface
func (Actor) Name() string { return actorName } func (Actor) Name() string { return actorName }
// Validate implements the actor interface
func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil } func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil }

View file

@ -1,6 +1,9 @@
// Package linkprotect contains an actor to prevent chatters from
// posting certain links
package linkprotect package linkprotect
import ( import (
"context"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -22,6 +25,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -163,6 +167,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
switch lt := attrs.MustString("action", ptrStringEmpty); lt { switch lt := attrs.MustString("action", ptrStringEmpty); lt {
case "ban": case "ban":
if err = botTwitchClient.BanUser( if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
0, 0,
@ -178,6 +183,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
} }
if err = botTwitchClient.DeleteMessage( if err = botTwitchClient.DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
); err != nil { ); err != nil {
@ -191,6 +197,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
} }
if err = botTwitchClient.BanUser( if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
to, to,
@ -291,6 +298,7 @@ func (actor) checkClipChannelDenied(denyList []string, clips []twitch.ClipInfo)
return verdictAllFine return verdictAllFine
} }
//revive:disable-next-line:flag-parameter
func (actor) checkAllLinksAllowed(allowList, links []string, autoAllowClipLinks bool) verdict { func (actor) checkAllLinksAllowed(allowList, links []string, autoAllowClipLinks bool) verdict {
if len(allowList) == 0 { if len(allowList) == 0 {
// We're not explicitly allowing links, this method is a no-op // We're not explicitly allowing links, this method is a no-op
@ -322,6 +330,7 @@ func (actor) checkAllLinksAllowed(allowList, links []string, autoAllowClipLinks
return verdictMisbehave return verdictMisbehave
} }
//revive:disable-next-line:flag-parameter
func (actor) checkLinkDenied(denyList, links []string, ignoreClipLinks bool) verdict { func (actor) checkLinkDenied(denyList, links []string, ignoreClipLinks bool) verdict {
for _, link := range links { for _, link := range links {
if ignoreClipLinks && clipLink.MatchString(link) { if ignoreClipLinks && clipLink.MatchString(link) {

View file

@ -1,3 +1,4 @@
// Package log contains an actor to write bot-log entries from a rule
package log package log
import ( import (
@ -14,6 +15,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -42,7 +44,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
message, err := formatMessage(attrs.MustString("message", ptrStringEmpty), m, r, eventData) message, err := formatMessage(attrs.MustString("message", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing message template") return false, errors.Wrap(err, "executing message template")
@ -56,10 +58,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return true } func (actor) IsAsync() bool { return true }
func (a actor) Name() string { return "log" } func (actor) Name() string { return "log" }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string") return errors.New("message must be non-empty string")
} }

View file

@ -1,3 +1,5 @@
// Package messagehook contains actors to send discord / slack webhook
// requests
package messagehook package messagehook
import ( import (
@ -25,6 +27,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -55,7 +58,11 @@ func sendPayload(hookURL string, payload any, expRespCode int) (preventCooldown
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing request") return false, errors.Wrap(err, "executing request")
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
if resp.StatusCode != expRespCode { if resp.StatusCode != expRespCode {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

View file

@ -78,23 +78,24 @@ func (discordActor) Name() string { return "discordhook" }
func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil { if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil {
return err return err //nolint:wrapcheck
} }
if err = d.ValidateRequireValidTemplate(tplValidator, attrs, "content"); err != nil { if err = d.ValidateRequireValidTemplate(tplValidator, attrs, "content"); err != nil {
return err return err //nolint:wrapcheck
} }
if err = d.ValidateRequireValidTemplateIfSet(tplValidator, attrs, "avatar_url", "username"); err != nil { if err = d.ValidateRequireValidTemplateIfSet(tplValidator, attrs, "avatar_url", "username"); err != nil {
return err return err //nolint:wrapcheck
} }
if !attrs.MustBool("add_embed", ptrBoolFalse) { if !attrs.MustBool("add_embed", ptrBoolFalse) {
// We're not validating the rest if embeds are disabled but in // We're not validating the rest if embeds are disabled but in
// this case the content is mandatory // this case the content is mandatory
return d.ValidateRequireNonEmpty(attrs, "content") return d.ValidateRequireNonEmpty(attrs, "content") //nolint:wrapcheck
} }
//nolint:wrapcheck
return d.ValidateRequireValidTemplateIfSet( return d.ValidateRequireValidTemplateIfSet(
tplValidator, attrs, tplValidator, attrs,
"embed_title", "embed_title",

View file

@ -35,9 +35,10 @@ func (slackCompatibleActor) Name() string { return "slackhook" }
func (s slackCompatibleActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (s slackCompatibleActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil { if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil {
return err return err //nolint:wrapcheck
} }
//nolint:wrapcheck
return s.ValidateRequireValidTemplate(tplValidator, attrs, "text") return s.ValidateRequireValidTemplate(tplValidator, attrs, "text")
} }

View file

@ -1,3 +1,5 @@
// Package modchannel contains an actor to modify title / category of
// a channel
package modchannel package modchannel
import ( import (
@ -20,6 +22,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
tcGetter = args.GetTwitchClientForChannel tcGetter = args.GetTwitchClientForChannel
@ -67,7 +70,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
var ( var (
game = attrs.MustString("game", ptrStringEmpty) game = attrs.MustString("game", ptrStringEmpty)
title = attrs.MustString("title", ptrStringEmpty) title = attrs.MustString("title", ptrStringEmpty)
@ -113,10 +116,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("channel"); err != nil || v == "" { if v, err := attrs.String("channel"); err != nil || v == "" {
return errors.New("channel must be non-empty string") return errors.New("channel must be non-empty string")
} }

View file

@ -1,6 +1,7 @@
package nuke package nuke
import ( import (
"context"
"fmt" "fmt"
"time" "time"
@ -14,6 +15,7 @@ type (
func actionBan(channel, match, _, user string) error { func actionBan(channel, match, _, user string) error {
return errors.Wrap( return errors.Wrap(
botTwitchClient.BanUser( botTwitchClient.BanUser(
context.Background(),
channel, channel,
user, user,
0, 0,
@ -26,6 +28,7 @@ func actionBan(channel, match, _, user string) error {
func actionDelete(channel, _, msgid, _ string) (err error) { func actionDelete(channel, _, msgid, _ string) (err error) {
return errors.Wrap( return errors.Wrap(
botTwitchClient.DeleteMessage( botTwitchClient.DeleteMessage(
context.Background(),
channel, channel,
msgid, msgid,
), ),
@ -37,6 +40,7 @@ func getActionTimeout(duration time.Duration) actionFn {
return func(channel, match, msgid, user string) error { return func(channel, match, msgid, user string) error {
return errors.Wrap( return errors.Wrap(
botTwitchClient.BanUser( botTwitchClient.BanUser(
context.Background(),
channel, channel,
user, user,
duration, duration,

View file

@ -1,3 +1,6 @@
// Package nuke contains a hateraid protection actor recording messages
// in all channels for a certain period of time being able to "nuke"
// their authors by regular expression based on past messages
package nuke package nuke
import ( import (
@ -32,6 +35,7 @@ var (
ptrString10m = func(v string) *string { return &v }("10m") ptrString10m = func(v string) *string { return &v }("10m")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -146,7 +150,7 @@ type (
} }
) )
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
rawMatch, err := formatMessage(attrs.MustString("match", nil), m, r, eventData) rawMatch, err := formatMessage(attrs.MustString("match", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting match") return false, errors.Wrap(err, "formatting match")
@ -228,10 +232,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("match"); err != nil || v == "" { if v, err := attrs.String("match"); err != nil || v == "" {
return errors.New("match must be non-empty string") return errors.New("match must be non-empty string")
} }

View file

@ -1,6 +1,9 @@
// Package punish contains an actor to punish behaviour in a channel
// with rising punishments
package punish package punish
import ( import (
"context"
"math" "math"
"strings" "strings"
"time" "time"
@ -29,6 +32,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&punishLevel{}); err != nil { if err := db.DB().AutoMigrate(&punishLevel{}); err != nil {
@ -36,7 +40,7 @@ func Register(args plugins.RegistrationArguments) error {
} }
args.RegisterCopyDatabaseFunc("punish", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("punish", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &punishLevel{}) return database.CopyObjects(src, target, &punishLevel{}) //nolint:wrapcheck // internal helper
}) })
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -142,7 +146,7 @@ type (
// Punish // Punish
func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
var ( var (
cooldown = attrs.MustDuration("cooldown", ptrDefaultCooldown) cooldown = attrs.MustDuration("cooldown", ptrDefaultCooldown)
reason = attrs.MustString("reason", ptrStringEmpty) reason = attrs.MustString("reason", ptrStringEmpty)
@ -168,6 +172,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
switch lt := levels[nLvl]; lt { switch lt := levels[nLvl]; lt {
case "ban": case "ban":
if err = botTwitchClient.BanUser( if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"), strings.TrimLeft(user, "@"),
0, 0,
@ -183,6 +188,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
} }
if err = botTwitchClient.DeleteMessage( if err = botTwitchClient.DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
); err != nil { ); err != nil {
@ -196,6 +202,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
} }
if err = botTwitchClient.BanUser( if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"), strings.TrimLeft(user, "@"),
to, to,
@ -215,10 +222,10 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
) )
} }
func (a actorPunish) IsAsync() bool { return false } func (actorPunish) IsAsync() bool { return false }
func (a actorPunish) Name() string { return actorNamePunish } func (actorPunish) Name() string { return actorNamePunish }
func (a actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if v, err := attrs.String("user"); err != nil || v == "" {
return errors.New("user must be non-empty string") return errors.New("user must be non-empty string")
} }
@ -236,7 +243,7 @@ func (a actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs
// Reset // Reset
func (a actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
var ( var (
user = attrs.MustString("user", nil) user = attrs.MustString("user", nil)
uuid = attrs.MustString("uuid", ptrStringEmpty) uuid = attrs.MustString("uuid", ptrStringEmpty)
@ -252,10 +259,10 @@ func (a actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule
) )
} }
func (a actorResetPunish) IsAsync() bool { return false } func (actorResetPunish) IsAsync() bool { return false }
func (a actorResetPunish) Name() string { return actorNameResetPunish } func (actorResetPunish) Name() string { return actorNameResetPunish }
func (a actorResetPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorResetPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if v, err := attrs.String("user"); err != nil || v == "" {
return errors.New("user must be non-empty string") return errors.New("user must be non-empty string")
} }

View file

@ -94,7 +94,7 @@ func getPunishment(db database.Connector, channel, user, uuid string) (*levelCon
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
err := db.DB().First(&p, "key = ?", getDBKey(channel, user, uuid)).Error err := db.DB().First(&p, "key = ?", getDBKey(channel, user, uuid)).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err) return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // we get our internal error
} }
return err return err
}) })

View file

@ -1,6 +1,9 @@
// Package quotedb contains a quote database and actor / api methods
// to manage it
package quotedb package quotedb
import ( import (
"fmt"
"strconv" "strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -25,14 +28,15 @@ var (
ptrStringZero = func(v string) *string { return &v }("0") ptrStringZero = func(v string) *string { return &v }("0")
) )
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&quote{}); err != nil { if err = db.DB().AutoMigrate(&quote{}); err != nil {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("quote", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("quote", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &quote{}) return database.CopyObjects(src, target, &quote{}) //nolint:wrapcheck // internal helper
}) })
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -85,11 +89,13 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
registerAPI(args.RegisterAPIRoute) if err = registerAPI(args.RegisterAPIRoute); err != nil {
return fmt.Errorf("registering API: %w", err)
}
args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} { args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} {
return func() (int, error) { return func() (int, error) {
return GetMaxQuoteIdx(db, plugins.DeriveChannel(m, nil)) return getMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
} }
}, plugins.TemplateFuncDocumentation{ }, plugins.TemplateFuncDocumentation{
Description: "Gets the last quote index in the quote database for the current channel", Description: "Gets the last quote index in the quote database for the current channel",
@ -107,7 +113,7 @@ type (
actor struct{} actor struct{}
) )
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
var ( var (
action = attrs.MustString("action", ptrStringEmpty) action = attrs.MustString("action", ptrStringEmpty)
indexStr = attrs.MustString("index", ptrStringZero) indexStr = attrs.MustString("index", ptrStringZero)
@ -135,18 +141,18 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
} }
return false, errors.Wrap( return false, errors.Wrap(
AddQuote(db, plugins.DeriveChannel(m, eventData), quote), addQuote(db, plugins.DeriveChannel(m, eventData), quote),
"adding quote", "adding quote",
) )
case "del": case "del":
return false, errors.Wrap( return false, errors.Wrap(
DelQuote(db, plugins.DeriveChannel(m, eventData), index), delQuote(db, plugins.DeriveChannel(m, eventData), index),
"storing quote database", "storing quote database",
) )
case "get": case "get":
idx, quote, err := GetQuote(db, plugins.DeriveChannel(m, eventData), index) idx, quote, err := getQuote(db, plugins.DeriveChannel(m, eventData), index)
if err != nil { if err != nil {
return false, errors.Wrap(err, "getting quote") return false, errors.Wrap(err, "getting quote")
} }
@ -181,10 +187,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
action := attrs.MustString("action", ptrStringEmpty) action := attrs.MustString("action", ptrStringEmpty)
switch action { switch action {

View file

@ -20,7 +20,7 @@ type (
} }
) )
func AddQuote(db database.Connector, channel, quoteStr string) error { func addQuote(db database.Connector, channel, quoteStr string) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Create(&quote{ return tx.Create(&quote{
@ -33,8 +33,8 @@ func AddQuote(db database.Connector, channel, quoteStr string) error {
) )
} }
func DelQuote(db database.Connector, channel string, quoteIdx int) error { func delQuote(db database.Connector, channel string, quoteIdx int) error {
_, createdAt, _, err := GetQuoteRaw(db, channel, quoteIdx) _, createdAt, _, err := getQuoteRaw(db, channel, quoteIdx)
if err != nil { if err != nil {
return errors.Wrap(err, "fetching specified quote") return errors.Wrap(err, "fetching specified quote")
} }
@ -47,7 +47,7 @@ func DelQuote(db database.Connector, channel string, quoteIdx int) error {
) )
} }
func GetChannelQuotes(db database.Connector, channel string) ([]string, error) { func getChannelQuotes(db database.Connector, channel string) ([]string, error) {
var qs []quote var qs []quote
if err := helpers.Retry(func() error { if err := helpers.Retry(func() error {
return db.DB().Where("channel = ?", channel).Order("created_at").Find(&qs).Error return db.DB().Where("channel = ?", channel).Order("created_at").Find(&qs).Error
@ -63,7 +63,7 @@ func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
return quotes, nil return quotes, nil
} }
func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) { func getMaxQuoteIdx(db database.Connector, channel string) (int, error) {
var count int64 var count int64
if err := helpers.Retry(func() error { if err := helpers.Retry(func() error {
return db.DB(). return db.DB().
@ -78,14 +78,14 @@ func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
return int(count), nil return int(count), nil
} }
func GetQuote(db database.Connector, channel string, quote int) (int, string, error) { func getQuote(db database.Connector, channel string, quote int) (int, string, error) {
quoteIdx, _, quoteText, err := GetQuoteRaw(db, channel, quote) quoteIdx, _, quoteText, err := getQuoteRaw(db, channel, quote)
return quoteIdx, quoteText, err return quoteIdx, quoteText, err
} }
func GetQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int64, string, error) { func getQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int64, string, error) {
if quoteIdx == 0 { if quoteIdx == 0 {
max, err := GetMaxQuoteIdx(db, channel) max, err := getMaxQuoteIdx(db, channel)
if err != nil { if err != nil {
return 0, 0, "", errors.Wrap(err, "getting max quote idx") return 0, 0, "", errors.Wrap(err, "getting max quote idx")
} }
@ -113,7 +113,7 @@ func GetQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int6
} }
} }
func SetQuotes(db database.Connector, channel string, quotes []string) error { func setQuotes(db database.Connector, channel string, quotes []string) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
if err := tx.Where("channel = ?", channel).Delete(&quote{}).Error; err != nil { if err := tx.Where("channel = ?", channel).Delete(&quote{}).Error; err != nil {
@ -139,8 +139,8 @@ func SetQuotes(db database.Connector, channel string, quotes []string) error {
) )
} }
func UpdateQuote(db database.Connector, channel string, idx int, quoteStr string) error { func updateQuote(db database.Connector, channel string, idx int, quoteStr string) error {
_, createdAt, _, err := GetQuoteRaw(db, channel, idx) _, createdAt, _, err := getQuoteRaw(db, channel, idx)
if err != nil { if err != nil {
return errors.Wrap(err, "fetching specified quote") return errors.Wrap(err, "fetching specified quote")
} }

View file

@ -24,37 +24,37 @@ func TestQuotesRoundtrip(t *testing.T) {
} }
) )
cq, err := GetChannelQuotes(dbc, channel) cq, err := getChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying empty database") assert.NoError(t, err, "querying empty database")
assert.Zero(t, cq, "expecting no quotes") assert.Zero(t, cq, "expecting no quotes")
for i, q := range quotes { for i, q := range quotes {
assert.NoError(t, AddQuote(dbc, channel, q), "adding quote %d", i) assert.NoError(t, addQuote(dbc, channel, q), "adding quote %d", i)
} }
cq, err = GetChannelQuotes(dbc, channel) cq, err = getChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database") assert.NoError(t, err, "querying database")
assert.Equal(t, quotes, cq, "checkin order and presence of quotes") assert.Equal(t, quotes, cq, "checkin order and presence of quotes")
assert.NoError(t, DelQuote(dbc, channel, 1), "removing one quote") assert.NoError(t, delQuote(dbc, channel, 1), "removing one quote")
assert.NoError(t, DelQuote(dbc, channel, 1), "removing one quote") assert.NoError(t, delQuote(dbc, channel, 1), "removing one quote")
cq, err = GetChannelQuotes(dbc, channel) cq, err = getChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database") assert.NoError(t, err, "querying database")
assert.Len(t, cq, len(quotes)-2, "expecting quotes in db") assert.Len(t, cq, len(quotes)-2, "expecting quotes in db")
assert.NoError(t, SetQuotes(dbc, channel, quotes), "replacing quotes") assert.NoError(t, setQuotes(dbc, channel, quotes), "replacing quotes")
cq, err = GetChannelQuotes(dbc, channel) cq, err = getChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database") assert.NoError(t, err, "querying database")
assert.Equal(t, quotes, cq, "checkin order and presence of quotes") assert.Equal(t, quotes, cq, "checkin order and presence of quotes")
idx, q, err := GetQuote(dbc, channel, 0) idx, q, err := getQuote(dbc, channel, 0)
assert.NoError(t, err, "getting random quote") assert.NoError(t, err, "getting random quote")
assert.NotZero(t, idx, "index must not be zero") assert.NotZero(t, idx, "index must not be zero")
assert.NotZero(t, q, "quote must not be zero") assert.NotZero(t, q, "quote must not be zero")
idx, q, err = GetQuote(dbc, channel, 3) idx, q, err = getQuote(dbc, channel, 3)
assert.NoError(t, err, "getting specific quote") assert.NoError(t, err, "getting specific quote")
assert.Equal(t, 3, idx, "index must be 3") assert.Equal(t, 3, idx, "index must be 3")
assert.Equal(t, quotes[2], q, "quote must not the third") assert.Equal(t, quotes[2], q, "quote must not the third")

View file

@ -3,6 +3,7 @@ package quotedb
import ( import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -20,16 +21,19 @@ var (
listScript []byte listScript []byte
) )
func registerAPI(register plugins.HTTPRouteRegistrationFunc) { //nolint:funlen
register(plugins.HTTPRouteRegistrationArgs{ func registerAPI(register plugins.HTTPRouteRegistrationFunc) (err error) {
if err = register(plugins.HTTPRouteRegistrationArgs{
HandlerFunc: handleScript, HandlerFunc: handleScript,
Method: http.MethodGet, Method: http.MethodGet,
Module: "quotedb", Module: "quotedb",
Path: "/app.js", Path: "/app.js",
SkipDocumentation: true, SkipDocumentation: true,
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
register(plugins.HTTPRouteRegistrationArgs{ if err = register(plugins.HTTPRouteRegistrationArgs{
Description: "Add quotes for the given {channel}", Description: "Add quotes for the given {channel}",
HandlerFunc: handleAddQuotes, HandlerFunc: handleAddQuotes,
Method: http.MethodPost, Method: http.MethodPost,
@ -44,9 +48,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
register(plugins.HTTPRouteRegistrationArgs{ if err = register(plugins.HTTPRouteRegistrationArgs{
Description: "Deletes quote with given {idx} from {channel}", Description: "Deletes quote with given {idx} from {channel}",
HandlerFunc: handleDeleteQuote, HandlerFunc: handleDeleteQuote,
Method: http.MethodDelete, Method: http.MethodDelete,
@ -65,9 +71,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "idx", Name: "idx",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
register(plugins.HTTPRouteRegistrationArgs{ if err = register(plugins.HTTPRouteRegistrationArgs{
Accept: []string{"application/json", "text/html"}, Accept: []string{"application/json", "text/html"},
Description: "Lists all quotes for the given {channel}", Description: "Lists all quotes for the given {channel}",
HandlerFunc: handleListQuotes, HandlerFunc: handleListQuotes,
@ -82,9 +90,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
register(plugins.HTTPRouteRegistrationArgs{ if err = register(plugins.HTTPRouteRegistrationArgs{
Description: "Set quotes for the given {channel} (will overwrite ALL quotes!)", Description: "Set quotes for the given {channel} (will overwrite ALL quotes!)",
HandlerFunc: handleReplaceQuotes, HandlerFunc: handleReplaceQuotes,
Method: http.MethodPut, Method: http.MethodPut,
@ -99,9 +109,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
register(plugins.HTTPRouteRegistrationArgs{ if err = register(plugins.HTTPRouteRegistrationArgs{
Description: "Updates quote with given {idx} from {channel}", Description: "Updates quote with given {idx} from {channel}",
HandlerFunc: handleUpdateQuote, HandlerFunc: handleUpdateQuote,
Method: http.MethodPut, Method: http.MethodPut,
@ -120,7 +132,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "idx", Name: "idx",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil
} }
func handleAddQuotes(w http.ResponseWriter, r *http.Request) { func handleAddQuotes(w http.ResponseWriter, r *http.Request) {
@ -133,7 +149,7 @@ func handleAddQuotes(w http.ResponseWriter, r *http.Request) {
} }
for _, q := range quotes { for _, q := range quotes {
if err := AddQuote(db, channel, q); err != nil { if err := addQuote(db, channel, q); err != nil {
http.Error(w, errors.Wrap(err, "adding quote").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "adding quote").Error(), http.StatusInternalServerError)
return return
} }
@ -154,7 +170,7 @@ func handleDeleteQuote(w http.ResponseWriter, r *http.Request) {
return return
} }
if err = DelQuote(db, channel, idx); err != nil { if err = delQuote(db, channel, idx); err != nil {
http.Error(w, errors.Wrap(err, "deleting quote").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "deleting quote").Error(), http.StatusInternalServerError)
return return
} }
@ -165,13 +181,13 @@ func handleDeleteQuote(w http.ResponseWriter, r *http.Request) {
func handleListQuotes(w http.ResponseWriter, r *http.Request) { func handleListQuotes(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Accept"), "text/html") { if strings.HasPrefix(r.Header.Get("Accept"), "text/html") {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write(listFrontend) w.Write(listFrontend) //nolint:errcheck,gosec,revive
return return
} }
channel := "#" + strings.TrimLeft(mux.Vars(r)["channel"], "#") channel := "#" + strings.TrimLeft(mux.Vars(r)["channel"], "#")
quotes, err := GetChannelQuotes(db, channel) quotes, err := getChannelQuotes(db, channel)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting quotes").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting quotes").Error(), http.StatusInternalServerError)
return return
@ -192,7 +208,7 @@ func handleReplaceQuotes(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := SetQuotes(db, channel, quotes); err != nil { if err := setQuotes(db, channel, quotes); err != nil {
http.Error(w, errors.Wrap(err, "replacing quotes").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "replacing quotes").Error(), http.StatusInternalServerError)
return return
} }
@ -202,7 +218,7 @@ func handleReplaceQuotes(w http.ResponseWriter, r *http.Request) {
func handleScript(w http.ResponseWriter, _ *http.Request) { func handleScript(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/javascript") w.Header().Set("Content-Type", "text/javascript")
w.Write(listScript) w.Write(listScript) //nolint:errcheck,gosec,revive
} }
func handleUpdateQuote(w http.ResponseWriter, r *http.Request) { func handleUpdateQuote(w http.ResponseWriter, r *http.Request) {
@ -228,7 +244,7 @@ func handleUpdateQuote(w http.ResponseWriter, r *http.Request) {
return return
} }
if err = UpdateQuote(db, channel, idx, quotes[0]); err != nil { if err = updateQuote(db, channel, idx, quotes[0]); err != nil {
http.Error(w, errors.Wrap(err, "updating quote").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "updating quote").Error(), http.StatusInternalServerError)
return return
} }

View file

@ -1,3 +1,4 @@
// Package raw contains an actor to send raw IRC messages
package raw package raw
import ( import (
@ -16,6 +17,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage send = args.SendMessage
@ -45,7 +47,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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) rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing raw message") return false, errors.Wrap(err, "preparing raw message")
@ -62,10 +64,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string") return errors.New("message must be non-empty string")
} }

View file

@ -1,3 +1,4 @@
// Package respond contains an actor to send a message
package respond package respond
import ( import (
@ -24,7 +25,8 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage send = args.SendMessage
@ -76,7 +78,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Send a message on behalf of the bot (send JSON object with `message` key)", Description: "Send a message on behalf of the bot (send JSON object with `message` key)",
HandlerFunc: handleAPISend, HandlerFunc: handleAPISend,
Method: http.MethodPost, Method: http.MethodPost,
@ -91,14 +93,16 @@ func Register(args plugins.RegistrationArguments) error {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil return nil
} }
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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) msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
if err != nil { if err != nil {
if !attrs.CanString("fallback") || attrs.MustString("fallback", nil) == "" { if !attrs.CanString("fallback") || attrs.MustString("fallback", nil) == "" {
@ -139,10 +143,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string") return errors.New("message must be non-empty string")
} }

View file

@ -1,3 +1,5 @@
// Package shield contains an actor to update the shield-mode for a
// given channel
package shield package shield
import ( import (
@ -14,6 +16,7 @@ const actorName = "shield"
var botTwitchClient *twitch.Client var botTwitchClient *twitch.Client
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
@ -42,7 +45,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
ptrBoolFalse := func(v bool) *bool { return &v }(false) ptrBoolFalse := func(v bool) *bool { return &v }(false)
return false, errors.Wrap( return false, errors.Wrap(
@ -55,10 +58,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if _, err = attrs.Bool("enable"); err != nil { if _, err = attrs.Bool("enable"); err != nil {
return errors.New("enable must be boolean") return errors.New("enable must be boolean")
} }

View file

@ -1,6 +1,9 @@
// Package shoutout contains an actor to create a Twitch native
// shoutout
package shoutout package shoutout
import ( import (
"context"
"regexp" "regexp"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -20,6 +23,7 @@ var (
shoutoutChatcommandRegex = regexp.MustCompile(`^/shoutout +([^\s]+)$`) shoutoutChatcommandRegex = regexp.MustCompile(`^/shoutout +([^\s]+)$`)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -51,7 +55,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
user, err := formatMessage(attrs.MustString("user", ptrStringEmpty), m, r, eventData) user, err := formatMessage(attrs.MustString("user", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing user template") return false, errors.Wrap(err, "executing user template")
@ -59,6 +63,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.SendShoutout( botTwitchClient.SendShoutout(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
user, user,
), ),
@ -66,10 +71,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if v, err := attrs.String("user"); err != nil || v == "" {
return errors.New("user must be non-empty string") return errors.New("user must be non-empty string")
} }
@ -89,7 +94,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("shoutout message does not match required format") return errors.New("shoutout message does not match required format")
} }
if err := botTwitchClient.SendShoutout(channel, matches[1]); err != nil { if err := botTwitchClient.SendShoutout(context.Background(), channel, matches[1]); err != nil {
return errors.Wrap(err, "executing shoutout") return errors.Wrap(err, "executing shoutout")
} }

View file

@ -1,3 +1,5 @@
// Package stopexec contains an actor to stop the rule execution on
// template condition
package stopexec package stopexec
import ( import (
@ -11,6 +13,7 @@ const actorName = "stopexec"
var formatMessage plugins.MsgFormatter var formatMessage plugins.MsgFormatter
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -39,7 +42,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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 }("") ptrStringEmpty := func(v string) *string { return &v }("")
when, err := formatMessage(attrs.MustString("when", ptrStringEmpty), m, r, eventData) when, err := formatMessage(attrs.MustString("when", ptrStringEmpty), m, r, eventData)
@ -54,10 +57,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, nil return false, nil
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
whenTemplate, err := attrs.String("when") whenTemplate, err := attrs.String("when")
if err != nil || whenTemplate == "" { if err != nil || whenTemplate == "" {
return errors.New("when must be non-empty string") return errors.New("when must be non-empty string")

View file

@ -1,6 +1,8 @@
// Package timeout contains an actor to timeout users
package timeout package timeout
import ( import (
"context"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
@ -22,6 +24,7 @@ var (
timeoutChatcommandRegex = regexp.MustCompile(`^/timeout +([^\s]+) +([0-9]+) +(.+)$`) timeoutChatcommandRegex = regexp.MustCompile(`^/timeout +([^\s]+) +([0-9]+) +(.+)$`)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -62,7 +65,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData) reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing reason template") return false, errors.Wrap(err, "executing reason template")
@ -70,6 +73,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.BanUser( botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData), plugins.DeriveUser(m, eventData),
attrs.MustDuration("duration", nil), attrs.MustDuration("duration", nil),
@ -79,10 +83,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.Duration("duration"); err != nil || v < time.Second { 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 errors.New("duration must be of type duration greater or equal one second")
} }
@ -111,7 +115,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.Wrap(err, "parsing timeout duration") return errors.Wrap(err, "parsing timeout duration")
} }
if err = botTwitchClient.BanUser(channel, matches[1], time.Duration(duration)*time.Second, matches[3]); err != nil { if err = botTwitchClient.BanUser(context.Background(), channel, matches[1], time.Duration(duration)*time.Second, matches[3]); err != nil {
return errors.Wrap(err, "executing timeout") return errors.Wrap(err, "executing timeout")
} }

View file

@ -1,3 +1,5 @@
// Package variables contains an actor and database client to store
// handle variables
package variables package variables
import ( import (
@ -21,20 +23,22 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
//
//nolint:funlen // Function contains only documentation registration //nolint:funlen // Function contains only documentation registration
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&variable{}); err != nil { if err = db.DB().AutoMigrate(&variable{}); err != nil {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("variable", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("variable", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &variable{}) return database.CopyObjects(src, target, &variable{}) //nolint:wrapcheck // internal helper
}) })
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor("setvariable", func() plugins.Actor { return &ActorSetVariable{} }) args.RegisterActor("setvariable", func() plugins.Actor { return &actorSetVariable{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{ args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Modify variable contents", Description: "Modify variable contents",
@ -72,7 +76,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Returns the value as a plain string", Description: "Returns the value as a plain string",
HandlerFunc: routeActorSetVarGetValue, HandlerFunc: routeActorSetVarGetValue,
Method: http.MethodGet, Method: http.MethodGet,
@ -86,9 +90,11 @@ func Register(args plugins.RegistrationArguments) error {
Name: "name", Name: "name",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Updates the value of the variable", Description: "Updates the value of the variable",
HandlerFunc: routeActorSetVarSetValue, HandlerFunc: routeActorSetVarSetValue,
Method: http.MethodPatch, Method: http.MethodPatch,
@ -110,10 +116,12 @@ func Register(args plugins.RegistrationArguments) error {
Name: "name", Name: "name",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterTemplateFunction("variable", plugins.GenericTemplateFunctionGetter(func(name string, defVal ...string) (string, error) { args.RegisterTemplateFunction("variable", plugins.GenericTemplateFunctionGetter(func(name string, defVal ...string) (string, error) {
value, err := GetVariable(db, name) value, err := getVariable(db, name)
if err != nil { if err != nil {
return "", errors.Wrap(err, "getting variable") return "", errors.Wrap(err, "getting variable")
} }
@ -134,9 +142,9 @@ func Register(args plugins.RegistrationArguments) error {
return nil return nil
} }
type ActorSetVariable struct{} type actorSetVariable struct{}
func (a ActorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorSetVariable) Execute(_ *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) varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing variable name") return false, errors.Wrap(err, "preparing variable name")
@ -144,7 +152,7 @@ func (a ActorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule
if attrs.MustBool("clear", ptrBoolFalse) { if attrs.MustBool("clear", ptrBoolFalse) {
return false, errors.Wrap( return false, errors.Wrap(
RemoveVariable(db, varName), removeVariable(db, varName),
"removing variable", "removing variable",
) )
} }
@ -155,15 +163,15 @@ func (a ActorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule
} }
return false, errors.Wrap( return false, errors.Wrap(
SetVariable(db, varName, value), setVariable(db, varName, value),
"setting variable", "setting variable",
) )
} }
func (a ActorSetVariable) IsAsync() bool { return false } func (actorSetVariable) IsAsync() bool { return false }
func (a ActorSetVariable) Name() string { return "setvariable" } func (actorSetVariable) Name() string { return "setvariable" }
func (a ActorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("variable"); err != nil || v == "" { if v, err := attrs.String("variable"); err != nil || v == "" {
return errors.New("variable name must be non-empty string") return errors.New("variable name must be non-empty string")
} }
@ -178,7 +186,7 @@ func (a ActorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, a
} }
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) { func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
vc, err := GetVariable(db, mux.Vars(r)["name"]) vc, err := getVariable(db, mux.Vars(r)["name"])
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return return
@ -189,7 +197,7 @@ func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
} }
func routeActorSetVarSetValue(w http.ResponseWriter, r *http.Request) { func routeActorSetVarSetValue(w http.ResponseWriter, r *http.Request) {
if err := SetVariable(db, mux.Vars(r)["name"], r.FormValue("value")); err != nil { if err := setVariable(db, mux.Vars(r)["name"], r.FormValue("value")); err != nil {
http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError)
return return
} }

View file

@ -17,12 +17,12 @@ type (
} }
) )
func GetVariable(db database.Connector, key string) (string, error) { func getVariable(db database.Connector, key string) (string, error) {
var v variable var v variable
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
err := db.DB().First(&v, "name = ?", key).Error err := db.DB().First(&v, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err) return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // we get our internal error
} }
return err return err
}) })
@ -38,7 +38,7 @@ func GetVariable(db database.Connector, key string) (string, error) {
} }
} }
func SetVariable(db database.Connector, key, value string) error { func setVariable(db database.Connector, key, value string) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{ return tx.Clauses(clause.OnConflict{
@ -50,7 +50,7 @@ func SetVariable(db database.Connector, key, value string) error {
) )
} }
func RemoveVariable(db database.Connector, key string) error { func removeVariable(db database.Connector, key string) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&variable{}, "name = ?", key).Error return tx.Delete(&variable{}, "name = ?", key).Error

View file

@ -18,19 +18,19 @@ func TestVariableRoundtrip(t *testing.T) {
testValue = "ee5e4be5-f292-48aa-a177-cb9fd6f4e171" testValue = "ee5e4be5-f292-48aa-a177-cb9fd6f4e171"
) )
v, err := GetVariable(dbc, name) v, err := getVariable(dbc, name)
assert.NoError(t, err, "getting unset variable") assert.NoError(t, err, "getting unset variable")
assert.Zero(t, v, "checking zero state on unset variable") assert.Zero(t, v, "checking zero state on unset variable")
assert.NoError(t, SetVariable(dbc, name, testValue), "setting variable") assert.NoError(t, setVariable(dbc, name, testValue), "setting variable")
v, err = GetVariable(dbc, name) v, err = getVariable(dbc, name)
assert.NoError(t, err, "getting set variable") assert.NoError(t, err, "getting set variable")
assert.NotZero(t, v, "checking non-zero state on set variable") assert.NotZero(t, v, "checking non-zero state on set variable")
assert.NoError(t, RemoveVariable(dbc, name), "removing variable") assert.NoError(t, removeVariable(dbc, name), "removing variable")
v, err = GetVariable(dbc, name) v, err = getVariable(dbc, name)
assert.NoError(t, err, "getting removed variable") assert.NoError(t, err, "getting removed variable")
assert.Zero(t, v, "checking zero state on removed variable") assert.Zero(t, v, "checking zero state on removed variable")
} }

View file

@ -1,3 +1,4 @@
// Package vip contains actors to modify VIPs of a channel
package vip package vip
import ( import (
@ -19,6 +20,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
permCheckFn = args.HasPermissionForChannel permCheckFn = args.HasPermissionForChannel
@ -96,7 +98,7 @@ type (
) )
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
for _, field := range []string{"channel", "user"} { for _, field := range []string{"channel", "user"} {
if v, err := attrs.String(field); err != nil || v == "" { if v, err := attrs.String(field); err != nil || v == "" {
return errors.Errorf("%s must be non-empty string", field) return errors.Errorf("%s must be non-empty string", field)
@ -110,7 +112,7 @@ func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugi
return nil return nil
} }
func (a actor) getParams(m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (channel, user string, err error) { func (actor) getParams(m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (channel, user string, err error) {
if channel, err = formatMessage(attrs.MustString("channel", nil), m, r, eventData); err != nil { if channel, err = formatMessage(attrs.MustString("channel", nil), m, r, eventData); err != nil {
return "", "", errors.Wrap(err, "parsing channel") return "", "", errors.Wrap(err, "parsing channel")
} }
@ -129,7 +131,9 @@ func (u unvipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, even
} }
return false, errors.Wrap( return false, errors.Wrap(
executeModVIP(channel, func(tc *twitch.Client) error { return tc.RemoveChannelVIP(context.Background(), channel, user) }), executeModVIP(channel, func(tc *twitch.Client) error {
return errors.Wrap(tc.RemoveChannelVIP(context.Background(), channel, user), "removing VIP")
}),
"removing VIP", "removing VIP",
) )
} }
@ -143,7 +147,9 @@ func (v vipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventD
} }
return false, errors.Wrap( return false, errors.Wrap(
executeModVIP(channel, func(tc *twitch.Client) error { return tc.AddChannelVIP(context.Background(), channel, user) }), executeModVIP(channel, func(tc *twitch.Client) error {
return errors.Wrap(tc.AddChannelVIP(context.Background(), channel, user), "adding VIP")
}),
"adding VIP", "adding VIP",
) )
} }

View file

@ -1,6 +1,9 @@
// Package whisper contains an actor to send whispers
package whisper package whisper
import ( import (
"context"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
@ -17,6 +20,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
@ -55,7 +59,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *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) to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing whisper receiver") return false, errors.Wrap(err, "preparing whisper receiver")
@ -67,15 +71,15 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.SendWhisper(to, msg), botTwitchClient.SendWhisper(context.Background(), to, msg),
"sending whisper", "sending whisper",
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("to"); err != nil || v == "" { if v, err := attrs.String("to"); err != nil || v == "" {
return errors.New("to must be non-empty string") return errors.New("to must be non-empty string")
} }

View file

@ -11,7 +11,7 @@ import (
type actor struct{} type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData) fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing fields template") return false, errors.Wrap(err, "executing fields template")
@ -32,10 +32,10 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
) )
} }
func (a actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("fields"); err != nil || v == "" { if v, err := attrs.String("fields"); err != nil || v == "" {
return errors.New("fields is expected to be non-empty string") return errors.New("fields is expected to be non-empty string")
} }

View file

@ -1,3 +1,5 @@
// Package customevent contains an actor and database modules to create
// custom (timed) events
package customevent package customevent
import ( import (
@ -27,14 +29,15 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("") ptrStringEmpty = func(s string) *string { return &s }("")
) )
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&storedCustomEvent{}); err != nil { if err = db.DB().AutoMigrate(&storedCustomEvent{}); err != nil {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("custom_event", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("custom_event", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &storedCustomEvent{}) return database.CopyObjects(src, target, &storedCustomEvent{}) //nolint:wrapcheck // internal helper
}) })
mc = &memoryCache{dbc: db} mc = &memoryCache{dbc: db}
@ -71,7 +74,7 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Creates an `custom` event containing the fields provided in the request body", Description: "Creates an `custom` event containing the fields provided in the request body",
HandlerFunc: handleCreateEvent, HandlerFunc: handleCreateEvent,
Method: http.MethodPost, Method: http.MethodPost,
@ -94,7 +97,9 @@ func Register(args plugins.RegistrationArguments) error {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
for schedule, fn := range map[string]func(){ for schedule, fn := range map[string]func(){
fmt.Sprintf("@every %s", cleanupTimeout): scheduleCleanup, fmt.Sprintf("@every %s", cleanupTimeout): scheduleCleanup,

View file

@ -57,6 +57,7 @@ func (m *memoryCache) Refresh() (err error) {
return m.refresh() return m.refresh()
} }
//revive:disable-next-line:confusing-naming
func (m *memoryCache) refresh() (err error) { func (m *memoryCache) refresh() (err error) {
if m.events, err = getFutureEvents(m.dbc); err != nil { if m.events, err = getFutureEvents(m.dbc); err != nil {
return errors.Wrap(err, "fetching events from database") return errors.Wrap(err, "fetching events from database")

View file

@ -1,3 +1,5 @@
// Package msgformat contains an API route to utilize the internal
// message formatter to format strings
package msgformat package msgformat
import ( import (
@ -11,10 +13,11 @@ import (
var formatMessage plugins.MsgFormatter var formatMessage plugins.MsgFormatter
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Takes the given template and renders it using the same renderer as messages in the channel are", Description: "Takes the given template and renders it using the same renderer as messages in the channel are",
HandlerFunc: handleFormattedMessage, HandlerFunc: handleFormattedMessage,
Method: http.MethodGet, Method: http.MethodGet,
@ -31,7 +34,9 @@ func Register(args plugins.RegistrationArguments) error {
}, },
RequiresWriteAuth: true, // This module can potentially be used to harvest data / read internal variables so it is handled as a write-module RequiresWriteAuth: true, // This module can potentially be used to harvest data / read internal variables so it is handled as a write-module
ResponseType: plugins.HTTPRouteResponseTypeTextPlain, ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil return nil
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -24,26 +25,29 @@ type (
} }
) )
func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) error { func addChannelEvent(db database.Connector, channel string, evt socketMessage) (evtID uint64, err error) {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(evt.Fields); err != nil { if err := json.NewEncoder(buf).Encode(evt.Fields); err != nil {
return errors.Wrap(err, "encoding fields") return 0, errors.Wrap(err, "encoding fields")
} }
return errors.Wrap( storEvt := &overlaysEvent{
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { Channel: channel,
return tx.Create(&overlaysEvent{ CreatedAt: evt.Time.UTC(),
Channel: channel, EventType: evt.Type,
CreatedAt: evt.Time.UTC(), Fields: strings.TrimSpace(buf.String()),
EventType: evt.Type, }
Fields: strings.TrimSpace(buf.String()),
}).Error if err = helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
}), return tx.Create(storEvt).Error
"storing event to database", }); err != nil {
) return 0, errors.Wrap(err, "storing event to database")
}
return storEvt.ID, nil
} }
func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, error) { func getChannelEvents(db database.Connector, channel string) ([]socketMessage, error) {
var evts []overlaysEvent var evts []overlaysEvent
if err := helpers.Retry(func() error { if err := helpers.Retry(func() error {
@ -52,20 +56,46 @@ func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, e
return nil, errors.Wrap(err, "querying channel events") return nil, errors.Wrap(err, "querying channel events")
} }
var out []SocketMessage var out []socketMessage
for _, e := range evts { for _, e := range evts {
fields := new(plugins.FieldCollection) sm, err := e.ToSocketMessage()
if err := json.NewDecoder(strings.NewReader(e.Fields)).Decode(fields); err != nil { if err != nil {
return nil, errors.Wrap(err, "decoding fields") return nil, errors.Wrap(err, "transforming event")
} }
out = append(out, SocketMessage{ out = append(out, sm)
IsLive: false,
Time: e.CreatedAt,
Type: e.EventType,
Fields: fields,
})
} }
return out, nil return out, nil
} }
func getEventByID(db database.Connector, eventID uint64) (socketMessage, error) {
var evt overlaysEvent
if err := helpers.Retry(func() (err error) {
err = db.DB().Where("id = ?", eventID).First(&evt).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // we get our internal error
}
return err
}); err != nil {
return socketMessage{}, errors.Wrap(err, "fetching event")
}
return evt.ToSocketMessage()
}
func (o overlaysEvent) ToSocketMessage() (socketMessage, error) {
fields := new(plugins.FieldCollection)
if err := json.NewDecoder(strings.NewReader(o.Fields)).Decode(fields); err != nil {
return socketMessage{}, errors.Wrap(err, "decoding fields")
}
return socketMessage{
EventID: o.ID,
IsLive: false,
Time: o.CreatedAt,
Type: o.EventType,
Fields: fields,
}, nil
}

View file

@ -17,38 +17,55 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
var ( var (
channel = "#test" channel = "#test"
tEvent1 = time.Now() evtID uint64
tEvent1 = time.Now().UTC()
tEvent2 = tEvent1.Add(time.Second) tEvent2 = tEvent1.Add(time.Second)
) )
evts, err := GetChannelEvents(dbc, channel) evts, err := getChannelEvents(dbc, channel)
assert.NoError(t, err, "getting events on empty db") assert.NoError(t, err, "getting events on empty db")
assert.Zero(t, evts, "expect no events on empty db") assert.Zero(t, evts, "expect no events on empty db")
assert.NoError(t, AddChannelEvent(dbc, channel, SocketMessage{ evtID, err = addChannelEvent(dbc, channel, socketMessage{
IsLive: true, IsLive: true,
Time: tEvent2, Time: tEvent2,
Type: "event 2", Type: "event 2",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding second event") })
assert.Equal(t, uint64(1), evtID)
assert.NoError(t, err, "adding second event")
assert.NoError(t, AddChannelEvent(dbc, channel, SocketMessage{ evtID, err = addChannelEvent(dbc, channel, socketMessage{
IsLive: true, IsLive: true,
Time: tEvent1, Time: tEvent1,
Type: "event 1", Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding first event") })
assert.Equal(t, uint64(2), evtID)
assert.NoError(t, err, "adding first event")
assert.NoError(t, AddChannelEvent(dbc, "#otherchannel", SocketMessage{ evtID, err = addChannelEvent(dbc, "#otherchannel", socketMessage{
IsLive: true, IsLive: true,
Time: tEvent1, Time: tEvent1,
Type: "event", Type: "event",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding other channel event") })
assert.Equal(t, uint64(3), evtID)
assert.NoError(t, err, "adding other channel event")
evts, err = GetChannelEvents(dbc, channel) evts, err = getChannelEvents(dbc, channel)
assert.NoError(t, err, "getting events") assert.NoError(t, err, "getting events")
assert.Len(t, evts, 2, "expect 2 events") assert.Len(t, evts, 2, "expect 2 events")
assert.Less(t, evts[0].Time, evts[1].Time, "expect sorting") assert.Less(t, evts[0].Time, evts[1].Time, "expect sorting")
evt, err := getEventByID(dbc, 2)
assert.NoError(t, err)
assert.Equal(t, socketMessage{
EventID: 2,
IsLive: false,
Time: tEvent1,
Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}, evt)
} }

View file

@ -0,0 +1,29 @@
package overlays
import (
"net/http"
"os"
"github.com/sirupsen/logrus"
"golang.org/x/net/webdav"
)
func getDAVHandler() http.HandlerFunc {
overlaysDir := os.Getenv("OVERLAYS_DIR")
if ds, err := os.Stat(overlaysDir); err != nil || overlaysDir == "" || !ds.IsDir() {
return http.NotFound
}
return (&webdav.Handler{
Prefix: "/overlays/dav",
FileSystem: webdav.Dir(overlaysDir),
LockSystem: webdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
logger := logrus.WithField("module", "overlays-dav")
if err != nil {
logger = logger.WithError(err)
}
logger.Debugf("%s %s", r.Method, r.URL)
},
}).ServeHTTP
}

View file

@ -30,9 +30,10 @@
<div id="app" v-cloak> <div id="app" v-cloak>
<table> <table>
<tr><th>Time</th><th>Event</th><th>Fields</th></tr> <tr><th>Time</th><th>Reason</th><th>Event</th><th>Fields</th></tr>
<tr v-for="event in events"> <tr v-for="event in events">
<td>{{ moment(event.time).format('YYYY-MM-DD HH:mm:ss') }}</td> <td>{{ moment(event.time).format('YYYY-MM-DD HH:mm:ss') }}</td>
<td>{{ event.reason }}</td>
<td>{{ event.event }}</td> <td>{{ event.event }}</td>
<td> <td>
<span <span
@ -73,13 +74,13 @@
mounted() { mounted() {
window.botClient = new EventClient({ window.botClient = new EventClient({
handlers: { handlers: {
_: (evt, data, time, live) => { _: ({ fields, reason, time, type }) => {
if (window.botClient.paramOptionFallback('hide', '').split(',').includes(evt)) { if (window.botClient.paramOptionFallback('hide', '').split(',').includes(type)) {
return return
} }
this.events = [ this.events = [
{ event: evt, fields: data, time }, { event: type, fields, reason, time },
...this.events, ...this.events,
] ]
}, },

View file

@ -1,11 +1,22 @@
/** /**
* Options to pass to the EventClient constructor * Options to pass to the EventClient constructor
* @typedef {Object} Options * @typedef {Object} Options
* @prop {string} [channel] - Filter for specific channel events (format: `#channel`) * @prop {String} [channel] - Filter for specific channel events (format: `#channel`)
* @prop {Object} [handlers={}] - Map event types to callback functions `(event, fields, time, live) => {...}` * @prop {Object} [handlers={}] - Map event types to callback functions `(eventObj) => { ... }` (new) or `(event, fields, time, live) => {...}` (old)
* @prop {number} [maxReplayAge=-1] - Number of hours to replay the events for (-1 = infinite) * @prop {Number} [maxReplayAge=-1] - Number of hours to replay the events for (-1 = infinite)
* @prop {boolean} [replay=false] - Request a replay at connect (requires channel to be set to a channel name) * @prop {Boolean} [replay=false] - Request a replay at connect (requires channel to be set to a channel name)
* @prop {string} [token] - API access token to use to connect to the WebSocket (if not set, must be provided through URL hash) * @prop {String} [token] - API access token to use to connect to the WebSocket (if not set, must be provided through URL hash)
*/
/**
* SocketMessage received for every event and passed to the new `(eventObj) => { ... }` handlers
* @typedef {Object} SocketMessage
* @prop {Number} [event_id] - UID of the event used to re-trigger an event
* @prop {Boolean} [is_live] - Whether the event was sent through a replay (false) or occurred live (true)
* @prop {String} [reason] - Reason of this message (one of `bulk-replay`, `live-event`, `single-replay`)
* @prop {String} [time] - RFC3339 timestamp of the event
* @prop {String} [type] - Event type (i.e. `raid`, `sub`, ...)
* @prop {Object} [fields] - string->any mapping of fields available for the event
*/ */
const HOUR = 3600 * 1000 const HOUR = 3600 * 1000
@ -24,7 +35,7 @@ class EventClient {
* @param {Options} opts Options for the EventClient * @param {Options} opts Options for the EventClient
*/ */
constructor(opts) { constructor(opts) {
this.params = new URLSearchParams(window.location.hash.substr(1)) this.params = new URLSearchParams(window.location.hash.substring(1))
this.handlers = { ...opts.handlers || {} } this.handlers = { ...opts.handlers || {} }
this.options = { ...opts } this.options = { ...opts }
@ -52,7 +63,7 @@ class EventClient {
* @returns {string} API base URL * @returns {string} API base URL
*/ */
apiBase() { apiBase() {
return window.location.href.substr(0, window.location.href.indexOf('/overlays/')) return window.location.href.substring(0, window.location.href.indexOf('/overlays/'))
} }
/** /**
@ -88,7 +99,7 @@ class EventClient {
} }
for (const fn of [this.handlers[data.type], this.handlers._].filter(fn => fn)) { for (const fn of [this.handlers[data.type], this.handlers._].filter(fn => fn)) {
fn(data.type, data.fields, new Date(data.time), data.is_live) fn.length === 1 ? fn({ ...data, time: new Date(data.time) }) : fn(data.type, data.fields, new Date(data.time), data.is_live)
} }
} }
@ -125,7 +136,7 @@ class EventClient {
for (const msg of data) { for (const msg of data) {
for (const fn of [this.handlers[msg.type], this.handlers._].filter(fn => fn)) { for (const fn of [this.handlers[msg.type], this.handlers._].filter(fn => fn)) {
handlers.push(fn(msg.type, msg.fields, new Date(msg.time), msg.is_live)) handlers.push(fn.length === 1 ? fn({ ...msg, time: new Date(msg.time) }) : fn(msg.type, msg.fields, new Date(msg.time), msg.is_live))
} }
} }
@ -159,6 +170,21 @@ class EventClient {
.then(resp => resp.text()) .then(resp => resp.text())
} }
/**
* Triggers a replay of the given event to all overlays currently listening for events. This event will have the `is_live` flag set to `false`.
*
* @param {Number} eventId The ID of the event received through the SocketMessage object
* @returns {Promise} Promise of the fetch request
*/
replayEvent(eventId) {
return fetch(`${this.apiBase()}/overlays/event/${eventId}/replay`, {
headers: {
authorization: this.paramOptionFallback('token'),
},
method: 'PUT',
})
}
/** /**
* Modifies the overlay address to the websocket address the bot listens to * Modifies the overlay address to the websocket address the bot listens to
* *

View file

@ -8,11 +8,8 @@ new Vue({
new EventClient({ new EventClient({
handlers: { handlers: {
custom: (evt, data, time, live) => this.handleCustom(evt, data, time, live), custom: ({ fields }) => this.handleCustom(fields),
}, },
maxReplayAge: 720,
replay: true,
}) })
}, },
@ -64,14 +61,9 @@ new Vue({
source.connect(preGainNode) source.connect(preGainNode)
}, },
handleCustom(evt, data, time, live) { handleCustom(data) {
switch (data.type) { switch (data.type) {
case 'soundalert': case 'soundalert':
if (!live) {
// Not a live event, do not issue alerts
return
}
this.queueAlert({ this.queueAlert({
soundUrl: data.soundUrl, soundUrl: data.soundUrl,
}) })

View file

@ -12,8 +12,8 @@ var _ http.FileSystem = httpFSStack{}
type httpFSStack []http.FileSystem type httpFSStack []http.FileSystem
func (h httpFSStack) Open(name string) (http.File, error) { func (h httpFSStack) Open(name string) (http.File, error) {
for _, fs := range h { for _, stackedFS := range h {
if f, err := fs.Open(name); err == nil { if f, err := stackedFS.Open(name); err == nil {
return f, nil return f, nil
} }
} }
@ -34,5 +34,5 @@ func newPrefixedFS(prefix string, originFS http.FileSystem) *prefixedFS {
} }
func (p prefixedFS) Open(name string) (http.File, error) { func (p prefixedFS) Open(name string) (http.File, error) {
return p.originFS.Open(path.Join(p.prefix, name)) return p.originFS.Open(path.Join(p.prefix, name)) //nolint:wrapcheck
} }

View file

@ -1,11 +1,15 @@
// Package overlays contains a server to host overlays and interact
// with the bot using sockets and a pre-defined Javascript client
package overlays package overlays
import ( import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -31,14 +35,28 @@ const (
) )
type ( type (
SocketMessage struct { // sendReason contains an enum of reasons why the message is
IsLive bool `json:"is_live"` // transmitted to the listening overlay sockets
Time time.Time `json:"time"` sendReason string
Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"` // socketMessage represents the message overlay sockets will receive
socketMessage struct {
EventID uint64 `json:"event_id"`
IsLive bool `json:"is_live"`
Reason sendReason `json:"reason"`
Time time.Time `json:"time"`
Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"`
} }
) )
// Collection of SendReason entries
const (
sendReasonLive sendReason = "live-event"
sendReasonBulkReplay sendReason = "bulk-replay"
sendReasonSingleReplay sendReason = "single-replay"
)
var ( var (
//go:embed default/** //go:embed default/**
embeddedOverlays embed.FS embeddedOverlays embed.FS
@ -53,7 +71,7 @@ var (
"join", "part", // Those make no sense for replay "join", "part", // Those make no sense for replay
} }
subscribers = map[string]func(event string, eventData *plugins.FieldCollection){} subscribers = map[string]func(socketMessage){}
subscribersLock sync.RWMutex subscribersLock sync.RWMutex
upgrader = websocket.Upgrader{ upgrader = websocket.Upgrader{
@ -64,19 +82,40 @@ var (
validateToken plugins.ValidateTokenFunc validateToken plugins.ValidateTokenFunc
) )
func Register(args plugins.RegistrationArguments) error { // Register provides the plugins.RegisterFunc
//
//nolint:funlen
func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&overlaysEvent{}); err != nil { if err = db.DB().AutoMigrate(&overlaysEvent{}); err != nil {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("overlay_events", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("overlay_events", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &overlaysEvent{}) return database.CopyObjects(src, target, &overlaysEvent{}) //nolint:wrapcheck // internal helper
}) })
validateToken = args.ValidateToken validateToken = args.ValidateToken
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Trigger a re-distribution of an event to all subscribed overlays",
HandlerFunc: handleSingleEventReplay,
Method: http.MethodPut,
Module: "overlays",
Name: "Replay Single Event",
Path: "/event/{event_id}/replay",
ResponseType: plugins.HTTPRouteResponseTypeNo200,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "Event ID to replay (unique ID in database)",
Name: "event_id",
},
},
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Websocket subscriber for bot events", Description: "Websocket subscriber for bot events",
HandlerFunc: handleSocketSubscription, HandlerFunc: handleSocketSubscription,
Method: http.MethodGet, Method: http.MethodGet,
@ -84,9 +123,11 @@ func Register(args plugins.RegistrationArguments) error {
Name: "Websocket", Name: "Websocket",
Path: "/events.sock", Path: "/events.sock",
ResponseType: plugins.HTTPRouteResponseTypeMultiple, ResponseType: plugins.HTTPRouteResponseTypeMultiple,
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Fetch past events for the given channel", Description: "Fetch past events for the given channel",
HandlerFunc: handleEventsReplay, HandlerFunc: handleEventsReplay,
Method: http.MethodGet, Method: http.MethodGet,
@ -109,9 +150,25 @@ func Register(args plugins.RegistrationArguments) error {
Name: "channel", Name: "channel",
}, },
}, },
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
Description: "Shares the overlays folder as WebDAV filesystem",
HandlerFunc: getDAVHandler(),
IsPrefix: true,
Module: "overlays",
Name: "WebDAV Overlays",
Path: "/dav/",
RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeMultiple,
SkipDocumentation: true,
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
if err = args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{
HandlerFunc: handleServeOverlayAsset, HandlerFunc: handleServeOverlayAsset,
IsPrefix: true, IsPrefix: true,
Method: http.MethodGet, Method: http.MethodGet,
@ -119,30 +176,43 @@ func Register(args plugins.RegistrationArguments) error {
Path: "/", Path: "/",
ResponseType: plugins.HTTPRouteResponseTypeMultiple, ResponseType: plugins.HTTPRouteResponseTypeMultiple,
SkipDocumentation: true, SkipDocumentation: true,
}) }); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterEventHandler(func(event string, eventData *plugins.FieldCollection) error { if err = args.RegisterEventHandler(func(event string, eventData *plugins.FieldCollection) (err error) {
subscribersLock.RLock() subscribersLock.RLock()
defer subscribersLock.RUnlock() defer subscribersLock.RUnlock()
msg := socketMessage{
IsLive: true,
Reason: sendReasonLive,
Time: time.Now(),
Type: event,
Fields: eventData,
}
if msg.EventID, err = addChannelEvent(db, plugins.DeriveChannel(nil, eventData), socketMessage{
IsLive: false,
Time: time.Now(),
Type: event,
Fields: eventData,
}); err != nil {
return errors.Wrap(err, "storing event")
}
for _, fn := range subscribers { for _, fn := range subscribers {
fn(event, eventData) fn(msg)
} }
if str.StringInSlice(event, storeExemption) { if str.StringInSlice(event, storeExemption) {
return nil return nil
} }
return errors.Wrap( return nil
AddChannelEvent(db, plugins.DeriveChannel(nil, eventData), SocketMessage{ }); err != nil {
IsLive: false, return fmt.Errorf("registering event handler: %w", err)
Time: time.Now(), }
Type: event,
Fields: eventData,
}),
"storing event",
)
})
fsStack = httpFSStack{ fsStack = httpFSStack{
newPrefixedFS("default", http.FS(embeddedOverlays)), newPrefixedFS("default", http.FS(embeddedOverlays)),
@ -161,7 +231,7 @@ func Register(args plugins.RegistrationArguments) error {
func handleEventsReplay(w http.ResponseWriter, r *http.Request) { func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
var ( var (
channel = mux.Vars(r)["channel"] channel = mux.Vars(r)["channel"]
msgs []SocketMessage msgs []socketMessage
since = time.Time{} since = time.Time{}
) )
@ -169,7 +239,7 @@ func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
since = s since = s
} }
events, err := GetChannelEvents(db, "#"+strings.TrimLeft(channel, "#")) events, err := getChannelEvents(db, "#"+strings.TrimLeft(channel, "#"))
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting channel events").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting channel events").Error(), http.StatusInternalServerError)
return return
@ -180,6 +250,7 @@ func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
continue continue
} }
msg.Reason = sendReasonBulkReplay
msgs = append(msgs, msg) msgs = append(msgs, msg)
} }
@ -195,6 +266,29 @@ func handleServeOverlayAsset(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/overlays", http.FileServer(fsStack)).ServeHTTP(w, r) http.StripPrefix("/overlays", http.FileServer(fsStack)).ServeHTTP(w, r)
} }
func handleSingleEventReplay(w http.ResponseWriter, r *http.Request) {
eventID, err := strconv.ParseUint(mux.Vars(r)["event_id"], 10, 64)
if err != nil {
http.Error(w, errors.Wrap(err, "parsing event_id").Error(), http.StatusBadRequest)
return
}
evt, err := getEventByID(db, eventID)
if err != nil {
http.Error(w, errors.Wrap(err, "fetching event").Error(), http.StatusInternalServerError)
return
}
evt.Reason = sendReasonSingleReplay
subscribersLock.RLock()
defer subscribersLock.RUnlock()
for _, fn := range subscribers {
fn(evt)
}
}
//nolint:funlen,gocognit,gocyclo // Not split in order to keep the socket logic in one place //nolint:funlen,gocognit,gocyclo // Not split in order to keep the socket logic in one place
func handleSocketSubscription(w http.ResponseWriter, r *http.Request) { func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
var ( var (
@ -208,25 +302,18 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
logger.WithError(err).Error("Unable to upgrade socket") logger.WithError(err).Error("Unable to upgrade socket")
return return
} }
defer conn.Close() defer conn.Close() //nolint:errcheck // We don't really care about this
var ( var (
authTimeout = time.NewTimer(authTimeout) authTimeout = time.NewTimer(authTimeout)
connLock = new(sync.Mutex) connLock = new(sync.Mutex)
errC = make(chan error, 1) errC = make(chan error, 1)
isAuthorized bool isAuthorized bool
sendMsgC = make(chan SocketMessage, 1) sendMsgC = make(chan socketMessage, 1)
) )
// Register listener // Register listener
unsub := subscribeSocket(func(event string, eventData *plugins.FieldCollection) { unsub := subscribeSocket(func(msg socketMessage) { sendMsgC <- msg })
sendMsgC <- SocketMessage{
IsLive: true,
Time: time.Now(),
Type: event,
Fields: eventData,
}
})
defer unsub() defer unsub()
keepAlive := time.NewTicker(socketKeepAlive) keepAlive := time.NewTicker(socketKeepAlive)
@ -238,7 +325,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
logger.WithError(err).Error("Unable to send ping message") logger.WithError(err).Error("Unable to send ping message")
connLock.Unlock() connLock.Unlock()
conn.Close() conn.Close() //nolint:errcheck,gosec
return return
} }
@ -274,7 +361,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
continue continue
} }
var recvMsg SocketMessage var recvMsg socketMessage
if err = json.Unmarshal(p, &recvMsg); err != nil { if err = json.Unmarshal(p, &recvMsg); err != nil {
errC <- errors.Wrap(err, "decoding message") errC <- errors.Wrap(err, "decoding message")
return return
@ -295,7 +382,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
authTimeout.Stop() authTimeout.Stop()
isAuthorized = true isAuthorized = true
sendMsgC <- SocketMessage{ sendMsgC <- socketMessage{
IsLive: true, IsLive: true,
Time: time.Now(), Time: time.Now(),
Type: msgTypeRequestAuth, Type: msgTypeRequestAuth,
@ -315,10 +402,18 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
return return
case err := <-errC: case err := <-errC:
if err != nil { var cErr *websocket.CloseError
logger.WithError(err).Error("Message processing caused error") switch {
case err == nil:
// We use nil-error to close the connection
case errors.As(err, &cErr) && websocket.IsCloseError(cErr, websocket.CloseNormalClosure, websocket.CloseGoingAway):
// We don't need to log when the remote closes the websocket gracefully
default:
logger.WithError(err).Error("message processing caused error")
} }
return // We use nil-error to close the connection return // All errors need to quit this function
case msg := <-sendMsgC: case msg := <-sendMsgC:
if !isAuthorized { if !isAuthorized {
@ -330,14 +425,14 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
if err := conn.WriteJSON(msg); err != nil { if err := conn.WriteJSON(msg); err != nil {
logger.WithError(err).Error("Unable to send socket message") logger.WithError(err).Error("Unable to send socket message")
connLock.Unlock() connLock.Unlock()
conn.Close() conn.Close() //nolint:errcheck,gosec
} }
connLock.Unlock() connLock.Unlock()
} }
} }
} }
func subscribeSocket(fn func(event string, eventData *plugins.FieldCollection)) func() { func subscribeSocket(fn func(socketMessage)) func() {
id := uuid.Must(uuid.NewV4()).String() id := uuid.Must(uuid.NewV4()).String()
subscribersLock.Lock() subscribersLock.Lock()

View file

@ -17,7 +17,7 @@ var ptrStrEmpty = ptrStr("")
func ptrStr(v string) *string { return &v } func ptrStr(v string) *string { return &v }
func (a enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, evtData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, evtData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
if m != nil || evtData.MustString("reward_id", ptrStrEmpty) == "" { if m != nil || evtData.MustString("reward_id", ptrStrEmpty) == "" {
return false, errors.New("enter-raffle actor is only supposed to act on channelpoint redeems") return false, errors.New("enter-raffle actor is only supposed to act on channelpoint redeems")
} }
@ -67,10 +67,10 @@ func (a enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule
) )
} }
func (a enterRaffleActor) IsAsync() bool { return false } func (enterRaffleActor) IsAsync() bool { return false }
func (a enterRaffleActor) Name() string { return "enter-raffle" } func (enterRaffleActor) Name() string { return "enter-raffle" }
func (a enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
keyword, err := attrs.String("keyword") keyword, err := attrs.String("keyword")
if err != nil || keyword == "" { if err != nil || keyword == "" {
return errors.New("keyword must be non-empty string") return errors.New("keyword must be non-empty string")

View file

@ -1,6 +1,7 @@
package raffle package raffle
import ( import (
"context"
"strings" "strings"
"time" "time"
@ -70,7 +71,7 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
return errors.Wrap(err, "getting twitch client for raffle") return errors.Wrap(err, "getting twitch client for raffle")
} }
since, err := raffleChan.GetFollowDate(user, strings.TrimLeft(channel, "#")) since, err := raffleChan.GetFollowDate(context.Background(), user, strings.TrimLeft(channel, "#"))
switch { switch {
case err == nil: case err == nil:
doesFollow = since.Before(time.Now().Add(-r.MinFollowAge)) doesFollow = since.Before(time.Now().Add(-r.MinFollowAge))

View file

@ -53,7 +53,9 @@ func pickWinnerFromRaffle(r raffle) (winner raffleEntry, err error) {
func (cryptRandSrc) Int63() int64 { func (cryptRandSrc) Int63() int64 {
var b [8]byte var b [8]byte
rand.Read(b[:]) if _, err := rand.Read(b[:]); err != nil {
return -1
}
// mask off sign bit to ensure positive number // mask off sign bit to ensure positive number
return int64(binary.LittleEndian.Uint64(b[:]) & (1<<63 - 1)) return int64(binary.LittleEndian.Uint64(b[:]) & (1<<63 - 1))
} }

View file

@ -45,9 +45,12 @@ func testGenerateRaffe() raffle {
func BenchmarkPickWinnerFromRaffle(b *testing.B) { func BenchmarkPickWinnerFromRaffle(b *testing.B) {
tData := testGenerateRaffe() tData := testGenerateRaffe()
var err error
b.Run("pick", func(b *testing.B) { b.Run("pick", func(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
pickWinnerFromRaffle(tData) _, err = pickWinnerFromRaffle(tData)
require.NoError(b, err)
} }
}) })
} }

View file

@ -21,6 +21,7 @@ var (
tcGetter func(string) (*twitch.Client, error) tcGetter func(string) (*twitch.Client, error)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) { func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector() db = args.GetDatabaseConnector()
if err := db.DB().AutoMigrate(&raffle{}, &raffleEntry{}); err != nil { if err := db.DB().AutoMigrate(&raffle{}, &raffleEntry{}); err != nil {
@ -28,7 +29,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
} }
args.RegisterCopyDatabaseFunc("raffle", func(src, target *gorm.DB) error { args.RegisterCopyDatabaseFunc("raffle", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &raffle{}, &raffleEntry{}) return database.CopyObjects(src, target, &raffle{}, &raffleEntry{}) //nolint:wrapcheck // internal helper
}) })
dbc = newDBClient(db) dbc = newDBClient(db)

View file

@ -12,6 +12,7 @@ const (
// Retry contains a standard set of configuration parameters for an // Retry contains a standard set of configuration parameters for an
// exponential backoff to be used throughout the bot // exponential backoff to be used throughout the bot
func Retry(fn func() error) error { func Retry(fn func() error) error {
//nolint:wrapcheck
return backoff.NewBackoff(). return backoff.NewBackoff().
WithMaxIterations(maxRetries). WithMaxIterations(maxRetries).
Retry(fn) Retry(fn)
@ -21,5 +22,7 @@ func Retry(fn func() error) error {
// the database. The function will be run in a transaction on the // the database. The function will be run in a transaction on the
// database and will be retried as if executed using Retry // database and will be retried as if executed using Retry
func RetryTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error { func RetryTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error {
return Retry(func() error { return db.Transaction(fn) }) return Retry(func() error {
return db.Transaction(fn) //nolint:wrapcheck
})
} }

View file

@ -1,3 +1,5 @@
// Package linkcheck implements a helper library to search for links
// in a message text and validate them by trying to call them
package linkcheck package linkcheck
import ( import (
@ -52,7 +54,7 @@ func (c Checker) ScanForLinks(message string) (links []string) {
return c.scan(message, c.scanPlainNoObfuscate) return c.scan(message, c.scanPlainNoObfuscate)
} }
func (c Checker) scan(message string, scanFns ...func(string) []string) (links []string) { func (Checker) scan(message string, scanFns ...func(string) []string) (links []string) {
for _, scanner := range scanFns { for _, scanner := range scanFns {
if links = scanner(message); links != nil { if links = scanner(message); links != nil {
return links return links
@ -87,7 +89,6 @@ func (c Checker) scanPartsConnected(parts []string, connector string) (links []s
for ptJoin := 2; ptJoin < len(parts); ptJoin++ { for ptJoin := 2; ptJoin < len(parts); ptJoin++ {
for i := 0; i <= len(parts)-ptJoin; i++ { for i := 0; i <= len(parts)-ptJoin; i++ {
wg.Add(1)
c.res.Resolve(resolverQueueEntry{ c.res.Resolve(resolverQueueEntry{
Link: strings.Join(parts[i:i+ptJoin], connector), Link: strings.Join(parts[i:i+ptJoin], connector),
Callback: func(link string) { links = str.AppendIfMissing(links, link) }, Callback: func(link string) { links = str.AppendIfMissing(links, link) },
@ -108,7 +109,6 @@ func (c Checker) scanPlainNoObfuscate(message string) (links []string) {
) )
for _, part := range parts { for _, part := range parts {
wg.Add(1)
c.res.Resolve(resolverQueueEntry{ c.res.Resolve(resolverQueueEntry{
Link: part, Link: part,
Callback: func(link string) { links = str.AppendIfMissing(links, link) }, Callback: func(link string) { links = str.AppendIfMissing(links, link) },

View file

@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -59,9 +60,10 @@ func TestScanForLinks(t *testing.T) {
c := New() c := New()
for _, testCase := range []struct { for _, testCase := range []struct {
Heuristic bool Heuristic bool
Message string Message string
ExpectedLinks []string ExpectedLinks []string
ExpectedContains bool
}{ }{
// Case: full URL is present in the message // Case: full URL is present in the message
{ {
@ -102,6 +104,7 @@ func TestScanForLinks(t *testing.T) {
ExpectedLinks: []string{ ExpectedLinks: []string{
"http://example.com", "http://example.com",
}, },
ExpectedContains: true,
}, },
// Case: link is obfuscated using space and braces // Case: link is obfuscated using space and braces
{ {
@ -110,6 +113,7 @@ func TestScanForLinks(t *testing.T) {
ExpectedLinks: []string{ ExpectedLinks: []string{
"http://example.com", "http://example.com",
}, },
ExpectedContains: true,
}, },
// Case: multiple links in one message // Case: multiple links in one message
{ {
@ -162,9 +166,10 @@ func TestScanForLinks(t *testing.T) {
}, },
// Case: Multiple spaces in the link // Case: Multiple spaces in the link
{ {
Heuristic: true, Heuristic: true,
Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!", Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!",
ExpectedLinks: []string{"http://example.com"}, ExpectedLinks: []string{"http://example.com"},
ExpectedContains: true,
}, },
// Case: Dot in the end of the link with space // Case: Dot in the end of the link with space
{ {
@ -187,7 +192,21 @@ func TestScanForLinks(t *testing.T) {
} }
sort.Strings(linksFound) sort.Strings(linksFound)
assert.Equal(t, testCase.ExpectedLinks, linksFound, "links from message %q", testCase.Message) if testCase.ExpectedContains {
for _, expLnk := range testCase.ExpectedLinks {
assert.Contains(t, linksFound, expLnk)
}
var extraLinks []string
for _, link := range linksFound {
if !str.StringInSlice(link, testCase.ExpectedLinks) {
extraLinks = append(extraLinks, link)
}
}
t.Logf("extra links found: %v", extraLinks)
} else {
assert.Equal(t, testCase.ExpectedLinks, linksFound)
}
}) })
} }
} }

View file

@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/sirupsen/logrus"
) )
const ( const (
@ -74,6 +75,7 @@ func withSkipVerify() func(*resolver) {
} }
func (r resolver) Resolve(qe resolverQueueEntry) { func (r resolver) Resolve(qe resolverQueueEntry) {
qe.WaitGroup.Add(1)
r.resolverC <- qe r.resolverC <- qe
} }
@ -84,6 +86,8 @@ func (resolver) getJar() *cookiejar.Jar {
// resolveFinal takes a link and looks up the final destination of // resolveFinal takes a link and looks up the final destination of
// that link after all redirects were followed // that link after all redirects were followed
//
//nolint:gocyclo
func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack []string, userAgent string) string { func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack []string, userAgent string) string {
if !linkTest.MatchString(link) && !r.skipValidation { if !linkTest.MatchString(link) && !r.skipValidation {
return "" return ""
@ -138,7 +142,11 @@ func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack
if err != nil { if err != nil {
return "" return ""
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
if resp.StatusCode > 299 && resp.StatusCode < 400 { if resp.StatusCode > 299 && resp.StatusCode < 400 {
// We got a redirect // We got a redirect

View file

@ -1,6 +1,8 @@
// Package access contains a service to manage Twitch tokens and scopes
package access package access
import ( import (
"context"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -21,6 +23,8 @@ const (
) )
type ( type (
// ClientConfig contains a configuration to derive new Twitch clients
// from
ClientConfig struct { ClientConfig struct {
TwitchClient string TwitchClient string
TwitchClientSecret string TwitchClientSecret string
@ -37,11 +41,15 @@ type (
Scopes string Scopes string
} }
// Service manages the permission database
Service struct{ db database.Connector } Service struct{ db database.Connector }
) )
// ErrChannelNotAuthorized denotes there is no valid authoriztion for
// the given channel
var ErrChannelNotAuthorized = errors.New("channel is not authorized") var ErrChannelNotAuthorized = errors.New("channel is not authorized")
// New creates a new Service on the given database
func New(db database.Connector) (*Service, error) { func New(db database.Connector) (*Service, error) {
return &Service{db}, errors.Wrap( return &Service{db}, errors.Wrap(
db.DB().AutoMigrate(&extendedPermission{}), db.DB().AutoMigrate(&extendedPermission{}),
@ -49,15 +57,18 @@ func New(db database.Connector) (*Service, error) {
) )
} }
func (s *Service) CopyDatabase(src, target *gorm.DB) error { // CopyDatabase enables the bot to migrate the access database
return database.CopyObjects(src, target, &extendedPermission{}) func (*Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &extendedPermission{}) //nolint:wrapcheck // Internal helper
} }
// GetBotUsername gets the cached bot username
func (s Service) GetBotUsername() (botUsername string, err error) { func (s Service) GetBotUsername() (botUsername string, err error) {
err = s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername) err = s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername)
return botUsername, errors.Wrap(err, "reading bot username") return botUsername, errors.Wrap(err, "reading bot username")
} }
// GetChannelPermissions returns the scopes granted for the given channel
func (s Service) GetChannelPermissions(channel string) ([]string, error) { func (s Service) GetChannelPermissions(channel string) ([]string, error) {
var ( var (
err error err error
@ -78,6 +89,8 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
return strings.Split(perm.Scopes, " "), nil return strings.Split(perm.Scopes, " "), nil
} }
// GetBotTwitchClient returns a twitch.Client configured to act as the
// bot user
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) { func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
botUsername, err := s.GetBotUsername() botUsername, err := s.GetBotUsername()
switch { switch {
@ -118,7 +131,7 @@ func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
// can determine who the bot is. That means we can set the username // can determine who the bot is. That means we can set the username
// for later reference and afterwards delete the duplicated tokens. // for later reference and afterwards delete the duplicated tokens.
_, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, botAccessToken, botRefreshToken).GetAuthorizedUser() _, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, botAccessToken, botRefreshToken).GetAuthorizedUser(context.Background())
if err != nil { if err != nil {
return nil, errors.Wrap(err, "validating stored access token") return nil, errors.Wrap(err, "validating stored access token")
} }
@ -148,6 +161,8 @@ func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
return s.GetTwitchClientForChannel(botUser, cfg) return s.GetTwitchClientForChannel(botUser, cfg)
} }
// GetTwitchClientForChannel returns a twitch.Client configured to act
// as the owner of the given channel
func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*twitch.Client, error) { func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*twitch.Client, error) {
var ( var (
err error err error
@ -157,7 +172,7 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
if err = helpers.Retry(func() error { if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(ErrChannelNotAuthorized) return backoff.NewErrCannotRetry(ErrChannelNotAuthorized) //nolint:wrapcheck // We get our own error
} }
return errors.Wrap(err, "getting twitch credential from database") return errors.Wrap(err, "getting twitch credential from database")
}); err != nil { }); err != nil {
@ -189,6 +204,8 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
return tc, nil return tc, nil
} }
// HasAnyPermissionForChannel checks whether any of the given scopes
// are granted for the given channel
func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) { func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) {
storedScopes, err := s.GetChannelPermissions(channel) storedScopes, err := s.GetChannelPermissions(channel)
if err != nil { if err != nil {
@ -204,6 +221,8 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b
return false, nil return false, nil
} }
// HasPermissionsForChannel checks whether all of the given scopes
// are granted for the given channel
func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) { func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) {
storedScopes, err := s.GetChannelPermissions(channel) storedScopes, err := s.GetChannelPermissions(channel)
if err != nil { if err != nil {
@ -232,7 +251,7 @@ func (s Service) HasTokensForChannel(channel string) (bool, error) {
if err = helpers.Retry(func() error { if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(ErrChannelNotAuthorized) return backoff.NewErrCannotRetry(ErrChannelNotAuthorized) //nolint:wrapcheck // We'll get our own error
} }
return errors.Wrap(err, "getting twitch credential from database") return errors.Wrap(err, "getting twitch credential from database")
}); err != nil { }); err != nil {
@ -253,12 +272,14 @@ func (s Service) HasTokensForChannel(channel string) (bool, error) {
return perm.AccessToken != "" && perm.RefreshToken != "", nil return perm.AccessToken != "" && perm.RefreshToken != "", nil
} }
// ListPermittedChannels returns a list of all channels having a token
// for the channels owner
func (s Service) ListPermittedChannels() (out []string, err error) { func (s Service) ListPermittedChannels() (out []string, err error) {
var perms []extendedPermission var perms []extendedPermission
if err = helpers.Retry(func() error { if err = helpers.Retry(func() error {
return errors.Wrap(s.db.DB().Find(&perms).Error, "listing permissions") return errors.Wrap(s.db.DB().Find(&perms).Error, "listing permissions")
}); err != nil { }); err != nil {
return nil, err return nil, err //nolint:wrapcheck // is already wrapped on the inside
} }
for _, perm := range perms { for _, perm := range perms {
@ -268,6 +289,7 @@ func (s Service) ListPermittedChannels() (out []string, err error) {
return out, nil return out, nil
} }
// RemoveAllExtendedTwitchCredentials wipes the access database
func (s Service) RemoveAllExtendedTwitchCredentials() error { func (s Service) RemoveAllExtendedTwitchCredentials() error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
@ -277,6 +299,8 @@ func (s Service) RemoveAllExtendedTwitchCredentials() error {
) )
} }
// RemoveExendedTwitchCredentials wipes the access database for a given
// channel
func (s Service) RemoveExendedTwitchCredentials(channel string) error { func (s Service) RemoveExendedTwitchCredentials(channel string) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
@ -286,6 +310,7 @@ func (s Service) RemoveExendedTwitchCredentials(channel string) error {
) )
} }
// SetBotUsername stores the username of the bot
func (s Service) SetBotUsername(channel string) (err error) { func (s Service) SetBotUsername(channel string) (err error) {
return errors.Wrap( return errors.Wrap(
s.db.StoreCoreMeta(coreMetaKeyBotUsername, strings.TrimLeft(channel, "#")), s.db.StoreCoreMeta(coreMetaKeyBotUsername, strings.TrimLeft(channel, "#")),
@ -293,6 +318,8 @@ func (s Service) SetBotUsername(channel string) (err error) {
) )
} }
// SetExtendedTwitchCredentials stores tokens and scopes for the given
// channel into the access database
func (s Service) SetExtendedTwitchCredentials(channel, accessToken, refreshToken string, scope []string) (err error) { func (s Service) SetExtendedTwitchCredentials(channel, accessToken, refreshToken string, scope []string) (err error) {
if accessToken, err = s.db.EncryptField(accessToken); err != nil { if accessToken, err = s.db.EncryptField(accessToken); err != nil {
return errors.Wrap(err, "encrypting access token") return errors.Wrap(err, "encrypting access token")

View file

@ -13,15 +13,17 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
const NegativeCacheTime = 5 * time.Minute const negativeCacheTime = 5 * time.Minute
type ( type (
// Service manages the cached auth results
Service struct { Service struct {
backends []AuthFunc backends []AuthFunc
cache map[string]*CacheEntry cache map[string]*CacheEntry
lock sync.RWMutex lock sync.RWMutex
} }
// CacheEntry represents an entry in the cache Service
CacheEntry struct { CacheEntry struct {
AuthResult error // Allows for negative caching AuthResult error // Allows for negative caching
ExpiresAt time.Time ExpiresAt time.Time
@ -40,6 +42,8 @@ type (
// auth method and therefore is not an user // auth method and therefore is not an user
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")
// New creates a new Service with the given backend methods to
// authenticate users
func New(backends ...AuthFunc) *Service { func New(backends ...AuthFunc) *Service {
s := &Service{ s := &Service{
backends: backends, backends: backends,
@ -50,6 +54,8 @@ func New(backends ...AuthFunc) *Service {
return s return s
} }
// ValidateTokenFor checks backends whether the given token has access
// to the given modules and caches the result
func (s *Service) ValidateTokenFor(token string, modules ...string) error { func (s *Service) ValidateTokenFor(token string, modules ...string) error {
s.lock.RLock() s.lock.RLock()
cached := s.cache[s.cacheKey(token)] cached := s.cache[s.cacheKey(token)]
@ -84,7 +90,7 @@ backendLoop:
// user. Both should be cached. The error for a static time, the // user. Both should be cached. The error for a static time, the
// valid result for the time given by the backend. // valid result for the time given by the backend.
if errors.Is(ce.AuthResult, ErrUnauthorized) { if errors.Is(ce.AuthResult, ErrUnauthorized) {
ce.ExpiresAt = time.Now().Add(NegativeCacheTime) ce.ExpiresAt = time.Now().Add(negativeCacheTime)
} }
s.lock.Lock() s.lock.Lock()

View file

@ -1,3 +1,4 @@
// Package timer contains a service to store and manage timers in a database
package timer package timer
import ( import (
@ -19,6 +20,7 @@ import (
) )
type ( type (
// Service implements a timer service
Service struct { Service struct {
db database.Connector db database.Connector
permitTimeout time.Duration permitTimeout time.Duration
@ -32,6 +34,7 @@ type (
var _ plugins.TimerStore = (*Service)(nil) var _ plugins.TimerStore = (*Service)(nil)
// New creates a new Service
func New(db database.Connector, cronService *cron.Cron) (*Service, error) { func New(db database.Connector, cronService *cron.Cron) (*Service, error) {
s := &Service{ s := &Service{
db: db, db: db,
@ -46,20 +49,24 @@ func New(db database.Connector, cronService *cron.Cron) (*Service, error) {
return s, errors.Wrap(s.db.DB().AutoMigrate(&timer{}), "applying migrations") return s, errors.Wrap(s.db.DB().AutoMigrate(&timer{}), "applying migrations")
} }
func (s *Service) CopyDatabase(src, target *gorm.DB) error { // CopyDatabase enables the service to migrate to a new database
return database.CopyObjects(src, target, &timer{}) func (*Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &timer{}) //nolint:wrapcheck // Helper in own package
} }
// UpdatePermitTimeout sets a new permit timeout for future permits
func (s *Service) UpdatePermitTimeout(d time.Duration) { func (s *Service) UpdatePermitTimeout(d time.Duration) {
s.permitTimeout = d s.permitTimeout = d
} }
// Cooldown timer // Cooldown timer
// AddCooldown adds a new cooldown timer
func (s Service) AddCooldown(tt plugins.TimerType, limiter, ruleID string, expiry time.Time) error { func (s Service) AddCooldown(tt plugins.TimerType, limiter, ruleID string, expiry time.Time) error {
return s.SetTimer(s.getCooldownTimerKey(tt, limiter, ruleID), expiry) return s.SetTimer(s.getCooldownTimerKey(tt, limiter, ruleID), expiry)
} }
// InCooldown checks whether the cooldown has expired
func (s Service) InCooldown(tt plugins.TimerType, limiter, ruleID string) (bool, error) { func (s Service) InCooldown(tt plugins.TimerType, limiter, ruleID string) (bool, error) {
return s.HasTimer(s.getCooldownTimerKey(tt, limiter, ruleID)) return s.HasTimer(s.getCooldownTimerKey(tt, limiter, ruleID))
} }
@ -72,10 +79,12 @@ func (Service) getCooldownTimerKey(tt plugins.TimerType, limiter, ruleID string)
// Permit timer // Permit timer
// AddPermit adds a new permit timer
func (s Service) AddPermit(channel, username string) error { func (s Service) AddPermit(channel, username string) error {
return s.SetTimer(s.getPermitTimerKey(channel, username), time.Now().Add(s.permitTimeout)) return s.SetTimer(s.getPermitTimerKey(channel, username), time.Now().Add(s.permitTimeout))
} }
// HasPermit checks whether a valid permit is present
func (s Service) HasPermit(channel, username string) (bool, error) { func (s Service) HasPermit(channel, username string) (bool, error) {
return s.HasTimer(s.getPermitTimerKey(channel, username)) return s.HasTimer(s.getPermitTimerKey(channel, username))
} }
@ -88,12 +97,13 @@ func (Service) getPermitTimerKey(channel, username string) string {
// Generic timer // Generic timer
// HasTimer checks whether a timer with given ID is present
func (s Service) HasTimer(id string) (bool, error) { func (s Service) HasTimer(id string) (bool, error) {
var t timer var t timer
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
err := s.db.DB().First(&t, "id = ? AND expires_at >= ?", id, time.Now().UTC()).Error err := s.db.DB().First(&t, "id = ? AND expires_at >= ?", id, time.Now().UTC()).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err) return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // We'll get our own error
} }
return err return err
}) })
@ -109,6 +119,7 @@ func (s Service) HasTimer(id string) (bool, error) {
} }
} }
// SetTimer sets a timer with given ID and expiry
func (s Service) SetTimer(id string, expiry time.Time) error { func (s Service) SetTimer(id string, expiry time.Time) error {
return errors.Wrap( return errors.Wrap(
helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {

View file

@ -1,7 +1,9 @@
// Package api contains helpers to interact with remote APIs in templates
package api package api
import "github.com/Luzifer/twitch-bot/v3/plugins" import "github.com/Luzifer/twitch-bot/v3/plugins"
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("jsonAPI", plugins.GenericTemplateFunctionGetter(jsonAPI), plugins.TemplateFuncDocumentation{ args.RegisterTemplateFunction("jsonAPI", plugins.GenericTemplateFunctionGetter(jsonAPI), plugins.TemplateFuncDocumentation{
Description: "Fetches remote URL and applies jq-like query to it returning the result as string. (Remote API needs to return status 200 within 5 seconds.)", Description: "Fetches remote URL and applies jq-like query to it returning the result as string. (Remote API needs to return status 200 within 5 seconds.)",

View file

@ -10,6 +10,7 @@ import (
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
) )
const ( const (
@ -41,7 +42,11 @@ func jsonAPI(uri, path string, fallback ...string) (string, error) {
if err != nil { if err != nil {
return "", errors.Wrap(err, "executing request") return "", errors.Wrap(err, "executing request")
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusOK: case http.StatusOK:

View file

@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
) )
func textAPI(uri string, fallback ...string) (string, error) { func textAPI(uri string, fallback ...string) (string, error) {
@ -29,7 +30,11 @@ func textAPI(uri string, fallback ...string) (string, error) {
if err != nil { if err != nil {
return "", errors.Wrap(err, "executing request") return "", errors.Wrap(err, "executing request")
} }
defer resp.Body.Close() defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusOK: case http.StatusOK:

View file

@ -1,3 +1,4 @@
// Package numeric contains helpers for numeric manipulation
package numeric package numeric
import ( import (
@ -6,6 +7,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("pow", plugins.GenericTemplateFunctionGetter(math.Pow), plugins.TemplateFuncDocumentation{ args.RegisterTemplateFunction("pow", plugins.GenericTemplateFunctionGetter(math.Pow), plugins.TemplateFuncDocumentation{
Description: "Returns float from calculation: `float1 ** float2`", Description: "Returns float from calculation: `float1 ** float2`",

View file

@ -1,3 +1,4 @@
// Package random contains helpers to aid with randomness
package random package random
import ( import (
@ -11,6 +12,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("randomString", plugins.GenericTemplateFunctionGetter(randomString), plugins.TemplateFuncDocumentation{ args.RegisterTemplateFunction("randomString", plugins.GenericTemplateFunctionGetter(randomString), plugins.TemplateFuncDocumentation{
Description: "Randomly picks a string from a list of strings", Description: "Randomly picks a string from a list of strings",

View file

@ -1,3 +1,4 @@
// Package slice contains slice manipulation helpers
package slice package slice
import ( import (
@ -5,6 +6,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("inList", plugins.GenericTemplateFunctionGetter(func(search string, list ...string) bool { args.RegisterTemplateFunction("inList", plugins.GenericTemplateFunctionGetter(func(search string, list ...string) bool {
return str.StringInSlice(search, list) return str.StringInSlice(search, list)

View file

@ -1,3 +1,4 @@
// Package strings contains string manipulation helpers
package strings package strings
import ( import (
@ -8,6 +9,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("b64urlenc", plugins.GenericTemplateFunctionGetter(base64URLEncode), plugins.TemplateFuncDocumentation{ args.RegisterTemplateFunction("b64urlenc", plugins.GenericTemplateFunctionGetter(base64URLEncode), plugins.TemplateFuncDocumentation{
Description: "Encodes the input using base64 URL-encoding (like `b64enc` but using `URLEncoding` instead of `StdEncoding`)", Description: "Encodes the input using base64 URL-encoding (like `b64enc` but using `URLEncoding` instead of `StdEncoding`)",

View file

@ -1,3 +1,5 @@
// Package subscriber contains template functions to fetch sub-count
// and -points
package subscriber package subscriber
import ( import (
@ -15,6 +17,7 @@ var (
tcGetter func(string) (*twitch.Client, error) tcGetter func(string) (*twitch.Client, error)
) )
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
permCheckFn = args.HasPermissionForChannel permCheckFn = args.HasPermissionForChannel
tcGetter = args.GetTwitchClientForChannel tcGetter = args.GetTwitchClientForChannel

View file

@ -1,6 +1,7 @@
package twitch package twitch
import ( import (
"context"
"time" "time"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -38,7 +39,7 @@ func tplTwitchDoesFollowLongerThan(args plugins.RegistrationArguments) {
return false, errors.Errorf("unexpected input for duration %t", t) return false, errors.Errorf("unexpected input for duration %t", t)
} }
fd, err := args.GetTwitchClient().GetFollowDate(from, to) fd, err := args.GetTwitchClient().GetFollowDate(context.Background(), from, to)
switch { switch {
case err == nil: case err == nil:
return time.Since(fd) > age, nil return time.Since(fd) > age, nil
@ -61,7 +62,7 @@ func tplTwitchDoesFollowLongerThan(args plugins.RegistrationArguments) {
func tplTwitchDoesFollow(args plugins.RegistrationArguments) { func tplTwitchDoesFollow(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("doesFollow", plugins.GenericTemplateFunctionGetter(func(from, to string) (bool, error) { args.RegisterTemplateFunction("doesFollow", plugins.GenericTemplateFunctionGetter(func(from, to string) (bool, error) {
_, err := args.GetTwitchClient().GetFollowDate(from, to) _, err := args.GetTwitchClient().GetFollowDate(context.Background(), from, to)
switch { switch {
case err == nil: case err == nil:
return true, nil return true, nil
@ -84,7 +85,7 @@ func tplTwitchDoesFollow(args plugins.RegistrationArguments) {
func tplTwitchFollowAge(args plugins.RegistrationArguments) { func tplTwitchFollowAge(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("followAge", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Duration, error) { args.RegisterTemplateFunction("followAge", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Duration, error) {
since, err := args.GetTwitchClient().GetFollowDate(from, to) since, err := args.GetTwitchClient().GetFollowDate(context.Background(), from, to)
return time.Since(since), errors.Wrap(err, "getting follow date") return time.Since(since), errors.Wrap(err, "getting follow date")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to` and returns the duration between then and now (the bot must be moderator of `to` to read this)", Description: "Looks up when `from` followed `to` and returns the duration between then and now (the bot must be moderator of `to` to read this)",
@ -98,7 +99,8 @@ func tplTwitchFollowAge(args plugins.RegistrationArguments) {
func tplTwitchFollowDate(args plugins.RegistrationArguments) { func tplTwitchFollowDate(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("followDate", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Time, error) { args.RegisterTemplateFunction("followDate", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Time, error) {
return args.GetTwitchClient().GetFollowDate(from, to) fd, err := args.GetTwitchClient().GetFollowDate(context.Background(), from, to)
return fd, errors.Wrap(err, "getting follow date")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to` (the bot must be moderator of `to` to read this)", Description: "Looks up when `from` followed `to` (the bot must be moderator of `to` to read this)",
Syntax: "followDate <from> <to>", Syntax: "followDate <from> <to>",

View file

@ -1,10 +1,13 @@
package twitch package twitch
import ( import (
"context"
"fmt"
"strings" "strings"
"time" "time"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
) )
func init() { func init() {
@ -18,12 +21,12 @@ func init() {
func tplTwitchRecentGame(args plugins.RegistrationArguments) { func tplTwitchRecentGame(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("recentGame", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) { args.RegisterTemplateFunction("recentGame", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
game, _, err := args.GetTwitchClient().GetRecentStreamInfo(strings.TrimLeft(username, "#")) game, _, err := args.GetTwitchClient().GetRecentStreamInfo(context.Background(), strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || game == "") { if len(v) > 0 && (err != nil || game == "") {
return v[0], nil return v[0], nil //nolint:nilerr // This is a default fallback
} }
return game, err return game, errors.Wrap(err, "getting stream info")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent.", Description: "Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentGame <username> [fallback]", Syntax: "recentGame <username> [fallback]",
@ -36,12 +39,12 @@ func tplTwitchRecentGame(args plugins.RegistrationArguments) {
func tplTwitchRecentTitle(args plugins.RegistrationArguments) { func tplTwitchRecentTitle(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("recentTitle", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) { args.RegisterTemplateFunction("recentTitle", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
_, title, err := args.GetTwitchClient().GetRecentStreamInfo(strings.TrimLeft(username, "#")) _, title, err := args.GetTwitchClient().GetRecentStreamInfo(context.Background(), strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || title == "") { if len(v) > 0 && (err != nil || title == "") {
return v[0], nil return v[0], nil //nolint:nilerr // This is a default fallback
} }
return title, err return title, errors.Wrap(err, "getting stream info")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent.", Description: "Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentTitle <username> [fallback]", Syntax: "recentTitle <username> [fallback]",
@ -54,9 +57,9 @@ func tplTwitchRecentTitle(args plugins.RegistrationArguments) {
func tplTwitchStreamUptime(args plugins.RegistrationArguments) { func tplTwitchStreamUptime(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("streamUptime", plugins.GenericTemplateFunctionGetter(func(username string) (time.Duration, error) { args.RegisterTemplateFunction("streamUptime", plugins.GenericTemplateFunctionGetter(func(username string) (time.Duration, error) {
si, err := args.GetTwitchClient().GetCurrentStreamInfo(strings.TrimLeft(username, "#")) si, err := args.GetTwitchClient().GetCurrentStreamInfo(context.Background(), strings.TrimLeft(username, "#"))
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("getting stream info: %w", err)
} }
return time.Since(si.StartedAt), nil return time.Since(si.StartedAt), nil
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{

View file

@ -8,6 +8,7 @@ import (
var regFn []func(plugins.RegistrationArguments) var regFn []func(plugins.RegistrationArguments)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
for _, fn := range regFn { for _, fn := range regFn {
fn(args) fn(args)

View file

@ -20,12 +20,12 @@ func init() {
func tplTwitchDisplayName(args plugins.RegistrationArguments) { func tplTwitchDisplayName(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("displayName", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) { args.RegisterTemplateFunction("displayName", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
displayName, err := args.GetTwitchClient().GetDisplayNameForUser(strings.TrimLeft(username, "#")) displayName, err := args.GetTwitchClient().GetDisplayNameForUser(context.Background(), strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || displayName == "") { if len(v) > 0 && (err != nil || displayName == "") {
return v[0], nil //nolint:nilerr // Default value, no need to return error return v[0], nil //nolint:nilerr // Default value, no need to return error
} }
return displayName, err return displayName, errors.Wrap(err, "getting display name")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the display name the specified user set for themselves", Description: "Returns the display name the specified user set for themselves",
Syntax: "displayName <username> [fallback]", Syntax: "displayName <username> [fallback]",
@ -38,7 +38,8 @@ func tplTwitchDisplayName(args plugins.RegistrationArguments) {
func tplTwitchIDForUsername(args plugins.RegistrationArguments) { func tplTwitchIDForUsername(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("idForUsername", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) { args.RegisterTemplateFunction("idForUsername", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) {
return args.GetTwitchClient().GetIDForUsername(username) id, err := args.GetTwitchClient().GetIDForUsername(context.Background(), username)
return id, errors.Wrap(err, "getting ID for username")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the user-id for the given username", Description: "Returns the user-id for the given username",
Syntax: "idForUsername <username>", Syntax: "idForUsername <username>",
@ -51,7 +52,7 @@ func tplTwitchIDForUsername(args plugins.RegistrationArguments) {
func tplTwitchProfileImage(args plugins.RegistrationArguments) { func tplTwitchProfileImage(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("profileImage", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) { args.RegisterTemplateFunction("profileImage", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) {
user, err := args.GetTwitchClient().GetUserInformation(strings.TrimLeft(username, "#@")) user, err := args.GetTwitchClient().GetUserInformation(context.Background(), strings.TrimLeft(username, "#@"))
if err != nil { if err != nil {
return "", errors.Wrap(err, "getting user info") return "", errors.Wrap(err, "getting user info")
} }
@ -69,7 +70,8 @@ func tplTwitchProfileImage(args plugins.RegistrationArguments) {
func tplTwitchUsernameForID(args plugins.RegistrationArguments) { func tplTwitchUsernameForID(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("usernameForID", plugins.GenericTemplateFunctionGetter(func(id string) (string, error) { args.RegisterTemplateFunction("usernameForID", plugins.GenericTemplateFunctionGetter(func(id string) (string, error) {
return args.GetTwitchClient().GetUsernameForID(context.Background(), id) username, err := args.GetTwitchClient().GetUsernameForID(context.Background(), id)
return username, errors.Wrap(err, "getting username for ID")
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the current login name of an user-id", Description: "Returns the current login name of an user-id",
Syntax: "usernameForID <user-id>", Syntax: "usernameForID <user-id>",

View file

@ -10,6 +10,7 @@ import (
var userState = newTwitchUserStateStore() var userState = newTwitchUserStateStore()
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
if err := args.RegisterRawMessageHandler(rawMessageHandler); err != nil { if err := args.RegisterRawMessageHandler(rawMessageHandler); err != nil {
return errors.Wrap(err, "registering raw message handler") return errors.Wrap(err, "registering raw message handler")

101
irc.go
View file

@ -11,7 +11,7 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -48,7 +48,7 @@ func registerRawMessageHandler(fn plugins.RawMessageHandlerFunc) error {
type ircHandler struct { type ircHandler struct {
c *irc.Client c *irc.Client
conn *tls.Conn conn *tls.Conn
ctx context.Context ctx context.Context //nolint:containedctx
ctxCancelFn func() ctxCancelFn func()
user string user string
} }
@ -56,7 +56,7 @@ type ircHandler struct {
func newIRCHandler() (*ircHandler, error) { func newIRCHandler() (*ircHandler, error) {
h := new(ircHandler) h := new(ircHandler)
_, username, err := twitchClient.GetAuthorizedUser() _, username, err := twitchClient.GetAuthorizedUser(context.Background())
if err != nil { if err != nil {
return nil, errors.Wrap(err, "fetching username") return nil, errors.Wrap(err, "fetching username")
} }
@ -68,7 +68,7 @@ func newIRCHandler() (*ircHandler, error) {
return nil, errors.Wrap(err, "connect to IRC server") return nil, errors.Wrap(err, "connect to IRC server")
} }
token, err := twitchClient.GetToken() token, err := twitchClient.GetToken(context.Background())
if err != nil { if err != nil {
return nil, errors.Wrap(err, "getting auth token") return nil, errors.Wrap(err, "getting auth token")
} }
@ -98,11 +98,13 @@ func (i ircHandler) Close() error {
func (i ircHandler) ExecuteJoins(channels []string) { func (i ircHandler) ExecuteJoins(channels []string) {
for _, ch := range channels { for _, ch := range channels {
//nolint:errcheck,gosec
i.c.Write(fmt.Sprintf("JOIN #%s", strings.TrimLeft(ch, "#"))) i.c.Write(fmt.Sprintf("JOIN #%s", strings.TrimLeft(ch, "#")))
} }
} }
func (i ircHandler) ExecutePart(channel string) { func (i ircHandler) ExecutePart(channel string) {
//nolint:errcheck,gosec
i.c.Write(fmt.Sprintf("PART #%s", strings.TrimLeft(channel, "#"))) i.c.Write(fmt.Sprintf("PART #%s", strings.TrimLeft(channel, "#")))
} }
@ -115,13 +117,14 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
defer configLock.RUnlock() defer configLock.RUnlock()
if err := config.LogRawMessage(m); err != nil { if err := config.LogRawMessage(m); err != nil {
log.WithError(err).Error("Unable to log raw message") logrus.WithError(err).Error("Unable to log raw message")
} }
}(m) }(m)
switch m.Command { switch m.Command {
case "001": case "001":
// 001 is a welcome event, so we join channels there // 001 is a welcome event, so we join channels there
//nolint:errcheck,gosec
c.WriteMessage(&irc.Message{ c.WriteMessage(&irc.Message{
Command: "CAP", Command: "CAP",
Params: []string{ Params: []string{
@ -173,8 +176,10 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
case "RECONNECT": case "RECONNECT":
// RECONNECT (Twitch Commands) // RECONNECT (Twitch Commands)
// In this case, reconnect and rejoin channels that were on the connection, as you would normally. // In this case, reconnect and rejoin channels that were on the connection, as you would normally.
log.Warn("We were asked to reconnect, closing connection") logrus.Warn("We were asked to reconnect, closing connection")
i.Close() if err := i.Close(); err != nil {
logrus.WithError(err).Error("closing IRC connection after reconnect")
}
case "USERNOTICE": case "USERNOTICE":
// USERNOTICE (Twitch Commands) // USERNOTICE (Twitch Commands)
@ -187,7 +192,7 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
i.handleTwitchWhisper(m) i.handleTwitchWhisper(m)
default: default:
log.WithFields(log.Fields{ logrus.WithFields(logrus.Fields{
"command": m.Command, "command": m.Command,
"tags": m.Tags, "tags": m.Tags,
"trailing": m.Trailing(), "trailing": m.Trailing(),
@ -196,13 +201,18 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
} }
if err := notifyRawMessageHandlers(m); err != nil { if err := notifyRawMessageHandlers(m); err != nil {
log.WithError(err).Error("Unable to notify raw message handlers") logrus.WithError(err).Error("Unable to notify raw message handlers")
} }
} }
func (i ircHandler) Run() error { return errors.Wrap(i.c.RunContext(i.ctx), "running IRC client") } func (i ircHandler) Run() error { return errors.Wrap(i.c.RunContext(i.ctx), "running IRC client") }
func (i ircHandler) SendMessage(m *irc.Message) error { return i.c.WriteMessage(m) } func (i ircHandler) SendMessage(m *irc.Message) (err error) {
if err = i.c.WriteMessage(m); err != nil {
return fmt.Errorf("writing message: %w", err)
}
return nil
}
func (ircHandler) getChannel(m *irc.Message) string { func (ircHandler) getChannel(m *irc.Message) string {
if len(m.Params) > 0 { if len(m.Params) > 0 {
@ -230,19 +240,19 @@ func (i ircHandler) handleClearChat(m *irc.Message) {
fields.Set("seconds", seconds) fields.Set("seconds", seconds)
fields.Set("target_id", targetUserID) fields.Set("target_id", targetUserID)
fields.Set("target_name", m.Trailing()) fields.Set("target_name", m.Trailing())
log.WithFields(log.Fields(fields.Data())).Info("User was timed out") logrus.WithFields(logrus.Fields(fields.Data())).Info("User was timed out")
case hasTargetUserID: case hasTargetUserID:
// User w/o Duration = Ban // User w/o Duration = Ban
evt = eventTypeBan evt = eventTypeBan
fields.Set("target_id", targetUserID) fields.Set("target_id", targetUserID)
fields.Set("target_name", m.Trailing()) fields.Set("target_name", m.Trailing())
log.WithFields(log.Fields(fields.Data())).Info("User was banned") logrus.WithFields(logrus.Fields(fields.Data())).Info("User was banned")
default: default:
// No User = /clear // No User = /clear
evt = eventTypeClearChat evt = eventTypeClearChat
log.WithFields(log.Fields(fields.Data())).Info("Chat was cleared") logrus.WithFields(logrus.Fields(fields.Data())).Info("Chat was cleared")
} }
go handleMessage(i.c, m, evt, fields) go handleMessage(i.c, m, evt, fields)
@ -254,7 +264,7 @@ func (i ircHandler) handleClearMessage(m *irc.Message) {
"message_id": m.Tags["target-msg-id"], "message_id": m.Tags["target-msg-id"],
"target_name": m.Tags["login"], "target_name": m.Tags["login"],
}) })
log.WithFields(log.Fields(fields.Data())). logrus.WithFields(logrus.Fields(fields.Data())).
WithField("message", m.Trailing()). WithField("message", m.Trailing()).
Info("Message was deleted") Info("Message was deleted")
go handleMessage(i.c, m, eventTypeDelete, fields) go handleMessage(i.c, m, eventTypeDelete, fields)
@ -297,14 +307,16 @@ func (i ircHandler) handlePermit(m *irc.Message) {
"to": username, "to": username,
}) })
log.WithFields(fields.Data()).Debug("Added permit") logrus.WithFields(fields.Data()).Debug("Added permit")
timerService.AddPermit(m.Params[0], username) if err := timerService.AddPermit(m.Params[0], username); err != nil {
logrus.WithError(err).Error("adding permit")
}
go handleMessage(i.c, m, eventTypePermit, fields) go handleMessage(i.c, m, eventTypePermit, fields)
} }
func (i ircHandler) handleTwitchNotice(m *irc.Message) { func (i ircHandler) handleTwitchNotice(m *irc.Message) {
log.WithFields(log.Fields{ logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m), eventFieldChannel: i.getChannel(m),
"tags": m.Tags, "tags": m.Tags,
"trailing": m.Trailing(), "trailing": m.Trailing(),
@ -313,15 +325,15 @@ func (i ircHandler) handleTwitchNotice(m *irc.Message) {
switch m.Tags["msg-id"] { switch m.Tags["msg-id"] {
case "": case "":
// Notices SHOULD have msg-id tags... // Notices SHOULD have msg-id tags...
log.WithField("msg", m).Warn("Received notice without msg-id") logrus.WithField("msg", m).Warn("Received notice without msg-id")
default: default:
log.WithField("id", m.Tags["msg-id"]).Debug("unhandled notice received") logrus.WithField("id", m.Tags["msg-id"]).Debug("unhandled notice received")
} }
} }
func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) { func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
log.WithFields(log.Fields{ logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m), eventFieldChannel: i.getChannel(m),
"name": m.Name, "name": m.Name,
eventFieldUserName: m.User, eventFieldUserName: m.User,
@ -353,7 +365,7 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
eventFieldUserID: m.Tags["user-id"], eventFieldUserID: m.Tags["user-id"],
}) })
log.WithFields(log.Fields(fields.Data())).Info("User spent bits in chat message") logrus.WithFields(logrus.Fields(fields.Data())).Info("User spent bits in chat message")
go handleMessage(i.c, m, eventTypeBits, fields) go handleMessage(i.c, m, eventTypeBits, fields)
} }
@ -370,7 +382,7 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
"message": m.Trailing(), "message": m.Trailing(),
}) })
log.WithFields(log.Fields(fields.Data())).Info("User used hype-chat message") logrus.WithFields(logrus.Fields(fields.Data())).Info("User used hype-chat message")
go handleMessage(i.c, m, eventTypeHypeChat, fields) go handleMessage(i.c, m, eventTypeHypeChat, fields)
} }
@ -378,8 +390,9 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
go handleMessage(i.c, m, nil, nil) go handleMessage(i.c, m, nil, nil)
} }
//nolint:funlen
func (i ircHandler) handleTwitchUsernotice(m *irc.Message) { func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
log.WithFields(log.Fields{ logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m), eventFieldChannel: i.getChannel(m),
"tags": m.Tags, "tags": m.Tags,
"trailing": m.Trailing(), "trailing": m.Trailing(),
@ -391,17 +404,23 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
eventFieldUserID: m.Tags["user-id"], eventFieldUserID: m.Tags["user-id"],
}) })
message := m.Trailing()
if message == i.getChannel(m) {
// If no message is given, Trailing yields the channel name
message = ""
}
switch m.Tags["msg-id"] { switch m.Tags["msg-id"] {
case "": case "":
// Notices SHOULD have msg-id tags... // Notices SHOULD have msg-id tags...
log.WithField("msg", m).Warn("Received usernotice without msg-id") logrus.WithField("msg", m).Warn("Received usernotice without msg-id")
case "announcement": case "announcement":
evtData.SetFromData(map[string]any{ evtData.SetFromData(map[string]any{
"color": m.Tags["msg-param-color"], "color": m.Tags["msg-param-color"],
"message": m.Trailing(), "message": m.Trailing(),
}) })
log.WithFields(log.Fields(evtData.Data())).Info("Announcement was made") logrus.WithFields(logrus.Fields(evtData.Data())).Info("Announcement was made")
go handleMessage(i.c, m, eventTypeAnnouncement, evtData) go handleMessage(i.c, m, eventTypeAnnouncement, evtData)
@ -409,7 +428,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
evtData.SetFromData(map[string]interface{}{ evtData.SetFromData(map[string]interface{}{
"gifter": m.Tags["msg-param-sender-login"], "gifter": m.Tags["msg-param-sender-login"],
}) })
log.WithFields(log.Fields(evtData.Data())).Info("User upgraded to paid sub") logrus.WithFields(logrus.Fields(evtData.Data())).Info("User upgraded to paid sub")
go handleMessage(i.c, m, eventTypeGiftPaidUpgrade, evtData) go handleMessage(i.c, m, eventTypeGiftPaidUpgrade, evtData)
@ -418,17 +437,11 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"from": m.Tags["login"], "from": m.Tags["login"],
"viewercount": i.tagToNumeric(m, "msg-param-viewerCount", 0), "viewercount": i.tagToNumeric(m, "msg-param-viewerCount", 0),
}) })
log.WithFields(log.Fields(evtData.Data())).Info("Incoming raid") logrus.WithFields(logrus.Fields(evtData.Data())).Info("Incoming raid")
go handleMessage(i.c, m, eventTypeRaid, evtData) go handleMessage(i.c, m, eventTypeRaid, evtData)
case "resub": case "resub":
message := m.Trailing()
if message == i.getChannel(m) {
// If no message is given, Trailing yields the channel name
message = ""
}
evtData.SetFromData(map[string]interface{}{ evtData.SetFromData(map[string]interface{}{
"from": m.Tags["login"], "from": m.Tags["login"],
"message": message, "message": message,
@ -436,7 +449,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"subscribed_months": i.tagToNumeric(m, "msg-param-cumulative-months", 0), "subscribed_months": i.tagToNumeric(m, "msg-param-cumulative-months", 0),
"plan": m.Tags["msg-param-sub-plan"], "plan": m.Tags["msg-param-sub-plan"],
}) })
log.WithFields(log.Fields(evtData.Data())).Info("User re-subscribed") logrus.WithFields(logrus.Fields(evtData.Data())).Info("User re-subscribed")
go handleMessage(i.c, m, eventTypeResub, evtData) go handleMessage(i.c, m, eventTypeResub, evtData)
@ -446,7 +459,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"multi_month": i.tagToNumeric(m, "msg-param-multimonth-duration", 0), "multi_month": i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
"plan": m.Tags["msg-param-sub-plan"], "plan": m.Tags["msg-param-sub-plan"],
}) })
log.WithFields(log.Fields(evtData.Data())).Info("User subscribed") logrus.WithFields(logrus.Fields(evtData.Data())).Info("User subscribed")
go handleMessage(i.c, m, eventTypeSub, evtData) go handleMessage(i.c, m, eventTypeSub, evtData)
@ -461,7 +474,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"to": m.Tags["msg-param-recipient-user-name"], "to": m.Tags["msg-param-recipient-user-name"],
"total_gifted": i.tagToNumeric(m, "msg-param-sender-count", 0), "total_gifted": i.tagToNumeric(m, "msg-param-sender-count", 0),
}) })
log.WithFields(log.Fields(evtData.Data())).Info("User gifted a sub") logrus.WithFields(logrus.Fields(evtData.Data())).Info("User gifted a sub")
go handleMessage(i.c, m, eventTypeSubgift, evtData) go handleMessage(i.c, m, eventTypeSubgift, evtData)
@ -474,10 +487,24 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"plan": m.Tags["msg-param-sub-plan"], "plan": m.Tags["msg-param-sub-plan"],
"total_gifted": i.tagToNumeric(m, "msg-param-sender-count", 0), "total_gifted": i.tagToNumeric(m, "msg-param-sender-count", 0),
}) })
log.WithFields(log.Fields(evtData.Data())).Info("User gifted subs to the community") logrus.WithFields(logrus.Fields(evtData.Data())).Info("User gifted subs to the community")
go handleMessage(i.c, m, eventTypeSubmysterygift, evtData) go handleMessage(i.c, m, eventTypeSubmysterygift, evtData)
case "viewermilestone":
switch m.Tags["msg-param-category"] {
case "watch-streak":
evtData.SetFromData(map[string]any{
"message": message,
"streak": i.tagToNumeric(m, "msg-param-value", 0),
})
logrus.WithFields(logrus.Fields(evtData.Data())).Info("User shared a watch-streak")
go handleMessage(i.c, m, eventTypeWatchStreak, evtData)
default:
logrus.WithField("category", m.Tags["msg-param-category"]).Debug("found unhandled viewermilestone category")
}
} }
} }

Some files were not shown because too many files have changed in this diff Show more