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
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
max-issues-per-linter: 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:
disable-all: true
enable:
- 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]
- 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]
- 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]
- forbidigo # Forbids identifiers [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]
- 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]
- 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]
- 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]
- 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]
- 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]
- 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]
- 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
* Bugfixes

View file

@ -56,7 +56,7 @@ trivy:
# -- Documentation Site --
docs: actor_docs template_docs
docs: actor_docs eventclient_docs template_docs
actor_docs:
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{}
// 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) {
command, err := attrs.StringSlice("command")
if err != nil {
@ -121,9 +123,13 @@ func (ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, event
return preventCooldown, nil
}
// IsAsync implements actor interface
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) {
cmd, err := attrs.StringSlice("command")
if err != nil || len(cmd) == 0 {

20
auth.go
View file

@ -9,7 +9,7 @@ import (
"github.com/gofrs/uuid/v3"
"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/plugins"
@ -39,7 +39,7 @@ func init() {
},
} {
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)
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
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
@ -79,7 +83,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
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 {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
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)
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
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
@ -137,7 +145,7 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
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 {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return

View file

@ -31,7 +31,7 @@ func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time
var httpError twitch.HTTPError
id, user, err := tc.GetAuthorizedUser()
id, user, err := tc.GetAuthorizedUser(context.Background())
switch {
case err == nil:
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
_, pass, hasBasicAuth := r.BasicAuth()
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)
return
}

View file

@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"strings"
"sync"
@ -71,7 +72,7 @@ func (a *autoMessage) CanSend() bool {
}
if a.OnlyOnLive {
streamLive, err := twitchClient.HasLiveStream(strings.TrimLeft(a.Channel, "#"))
streamLive, err := twitchClient.HasLiveStream(context.Background(), strings.TrimLeft(a.Channel, "#"))
if err != nil {
log.WithError(err).Error("Unable to determine channel live status")
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, "")
_, user, err := tc.GetAuthorizedUser()
_, user, err := tc.GetAuthorizedUser(r.Context())
return user, tc, errors.Wrap(err, "getting authorized user")
}

View file

@ -13,7 +13,7 @@ import (
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"gopkg.in/irc.v4"
@ -23,7 +23,11 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const expectedMinConfigVersion = 2
const (
expectedMinConfigVersion = 2
rawLogDirPerm = 0o755
rawLogFilePerm = 0o644
)
var (
//go:embed default_config.yaml
@ -121,10 +125,10 @@ func loadConfig(filename string) error {
if err = config.CloseRawMessageWriter(); err != nil {
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")
}
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")
}
}
@ -132,7 +136,7 @@ func loadConfig(filename string) error {
config = tmpConfig
timerService.UpdatePermitTimeout(tmpConfig.PermitTimeout)
log.WithFields(log.Fields{
logrus.WithFields(logrus.Fields{
"auto_messages": len(config.AutoMessages),
"rules": len(config.Rules),
"channels": len(config.Channels),
@ -145,11 +149,15 @@ func loadConfig(filename string) 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 {
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.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))
if err = yaml.NewEncoder(tmpFile).Encode(obj); err != nil {
tmpFile.Close()
tmpFile.Close() //nolint:errcheck,gosec,revive
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 {
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))
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
}
@ -231,11 +242,15 @@ func writeConfigToYAML(filename, authorName, authorEmail, summary string, obj *c
}
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 {
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)
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 {
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 {
@ -319,14 +339,14 @@ func (configFile) fixedDuration(d time.Duration) time.Duration {
if d > time.Second {
return d
}
return d * time.Second
return d * time.Second //nolint:durationcheck // Error is handled before
}
func (configFile) fixedDurationPtr(d *time.Duration) *time.Duration {
if d == nil || *d >= time.Second {
return d
}
fd := *d * time.Second
fd := *d * time.Second //nolint:durationcheck // Error is handled before
return &fd
}
@ -368,11 +388,11 @@ func (c *configFile) fixTokenHashStorage() (err error) {
func (c *configFile) runLoadChecks() (err error) {
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 {
log.Warn("Loaded config with empty ruleset")
logrus.Warn("Loaded config with empty ruleset")
}
var seen []string
@ -397,7 +417,7 @@ func (c *configFile) updateAutoMessagesFromConfig(old *configFile) {
nam.lastMessageSent = time.Now()
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 {
@ -426,7 +446,7 @@ func (c configFile) validateRuleActions() error {
var hasError bool
for _, r := range c.Rules {
logger := log.WithField("rule", r.MatcherID())
logger := logrus.WithField("rule", r.MatcherID())
if err := r.Validate(validateTemplate); err != nil {
logger.WithError(err).Error("Rule reported invalid config")

View file

@ -53,7 +53,7 @@ func registerEditorFrontend() {
return
}
io.Copy(w, f)
io.Copy(w, f) //nolint:errcheck,gosec
})
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 {
usr, err := twitchClient.GetUserInformation(payload.BotEditors[i])
usr, err := twitchClient.GetUserInformation(r.Context(), payload.BotEditors[i])
if err != nil {
http.Error(w, errors.Wrap(err, "getting bot editor profile").Error(), http.StatusInternalServerError)
return

View file

@ -143,7 +143,7 @@ func configEditorGlobalGetModules(w http.ResponseWriter, _ *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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -160,7 +160,7 @@ func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) {
log.WithError(err).Error("Unable to initialize websocket")
return
}
defer conn.Close()
defer conn.Close() //nolint:errcheck
var (
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")
return
}
}
}
}

View file

@ -90,7 +90,7 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
}
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)
return
}

View file

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

View file

@ -502,7 +502,7 @@ Scans for links in the message and adds the "links" field to the event data
```yaml
- type: linkdetector
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
# Type: bool
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: 73%
< Your int this hour: 66%
```
### `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.
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>
<dd><p>Options to pass to the EventClient constructor</p>
</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>
<a name="EventClient"></a>
@ -31,6 +34,7 @@ EventClient abstracts the connection to the bot websocket for events
* [.apiBase()](#EventClient+apiBase) ⇒ <code>string</code>
* [.paramOptionFallback(key, [fallback])](#EventClient+paramOptionFallback) ⇒ <code>\*</code>
* [.renderTemplate(template)](#EventClient+renderTemplate) ⇒ <code>Promise</code>
* [.replayEvent(eventId)](#EventClient+replayEvent) ⇒ <code>Promise</code>
<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 |
<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>
## Options : <code>Object</code>
@ -84,9 +100,26 @@ Options to pass to the EventClient constructor
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [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) => {...}` |
| [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) |
| [token] | <code>string</code> | | API access token to use to connect to the WebSocket (if not set, must be provided through URL hash) |
| [channel] | <code>String</code> | | Filter for specific channel events (format: `#channel`) |
| [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) |
| [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) |
<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")
eventTypeSub = ptrStr("sub")
eventTypeTimeout = ptrStr("timeout")
eventTypeWatchStreak = ptrStr("watch_streak")
eventTypeWhisper = ptrStr("whisper")
eventTypeTwitchCategoryUpdate = ptrStr("category_update")
@ -76,6 +77,7 @@ var (
eventTypeSubgift,
eventTypeSubmysterygift,
eventTypeTimeout,
eventTypeWatchStreak,
eventTypeWhisper,
eventTypeTwitchCategoryUpdate,

View file

@ -8,7 +8,7 @@ import (
"time"
"github.com/Masterminds/sprig/v3"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str"
@ -78,7 +78,7 @@ func (t *templateFuncProvider) Register(name string, fg plugins.TemplateFuncGett
defer t.lock.Unlock()
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
@ -108,7 +108,7 @@ func init() {
var parts []string
for idx, div := range []time.Duration{time.Hour, time.Minute, time.Second} {
part := dLeft / div
dLeft -= part * div
dLeft -= part * div //nolint:durationcheck // One is static, this is fine
if len(units) <= idx || units[idx] == "" {
continue

2
git.go
View file

@ -56,6 +56,6 @@ func (g gitHelper) HasRepo() bool {
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()}
}

41
go.mod
View file

@ -3,20 +3,20 @@ module github.com/Luzifer/twitch-bot/v3
go 1.21
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/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/getsentry/sentry-go v0.25.0
github.com/getsentry/sentry-go v0.26.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/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.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/orandin/sentrus v1.0.0
github.com/pkg/errors v0.9.1
@ -24,7 +24,8 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
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/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2
@ -37,20 +38,20 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.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/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/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.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/go-billy/v5 v5.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // 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/go-cleanhttp v0.5.2 // 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-sockaddr v1.0.6 // 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/imdario/mergo v0.3.16 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.2 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // 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/shopspring/decimal v1.3.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/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.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/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.34.11 // indirect
modernc.org/libc v1.40.7 // indirect
modernc.org/mathutil v1.6.0 // 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/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
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.1/go.mod h1:CZZZWY0buCtkxrkqDPQYigC4Kn55UuO97TEoV+hwz2s=
github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc=
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/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/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o=
github.com/Luzifer/rconfig/v2 v2.4.0/go.mod h1:hWF3ZVSusbYlg5bEvCwalEyUSY+0JPJWUiIu7rBmav8=
github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok=
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/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
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.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
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 v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
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/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
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/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.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
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/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
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/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
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.4/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.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/getsentry/sentry-go v0.26.0 h1:IX3++sF6/4B5JcevhdZfdKIHfyvMmAq/UnqcyT2H6mA=
github.com/getsentry/sentry-go v0.26.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
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/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
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-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/v5 v5.10.1 h1:tu8/D8i+TWxgKpzQ3Vc43e+kkhXqtsZCKI/egajKnxk=
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 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
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/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
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/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
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.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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/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.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p2tOJQ=
github.com/hashicorp/vault/api v1.10.0/go.mod h1:jo5Y/ET+hNyz+JnKDt8XLAdKs+AM0G5W0Vp1IrFI8N8=
github.com/hashicorp/vault/api v1.11.0 h1:AChWByeHf4/P9sX3Y1B7vFsQhZO2BgQiCMQ2SA1P1UY=
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/yamux v0.0.0-20180604194846-3520598351bb/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.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
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.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
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/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA=
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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
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.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.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.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
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/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=
@ -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.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.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
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/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=
@ -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-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.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
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-20180830151530-49385e6e1522/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.5.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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
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.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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.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.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
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=
modernc.org/libc v1.34.11 h1:hQDcIUlSG4QAOkXCIQKkaAOV5ptXvkOx4ddbXzgW2JU=
modernc.org/libc v1.34.11/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg=
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/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
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.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
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
import (
"context"
"regexp"
"github.com/pkg/errors"
@ -16,6 +19,7 @@ var (
announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
@ -32,7 +36,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -1,6 +1,9 @@
// Package ban contains actors to ban/unban users in a channel
package ban
import (
"context"
"fmt"
"net/http"
"regexp"
@ -21,7 +24,8 @@ var (
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()
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",
HandlerFunc: handleAPIBan,
Method: http.MethodPost,
@ -72,7 +76,9 @@ func Register(args plugins.RegistrationArguments) error {
Name: "user",
},
},
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
args.RegisterMessageModFunc("/ban", handleChatCommand)
@ -81,7 +87,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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(
botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData),
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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")
if err != nil || reasonTemplate == "" {
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")
)
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)
return
}
@ -140,7 +147,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -1,3 +1,5 @@
// Package clip contains an actor to create clips on behalf of a
// channels owner
package clip
import (
@ -22,6 +24,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
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
import (
@ -19,6 +21,7 @@ var (
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 {
botTwitchClient = args.GetTwitchClient()
@ -33,8 +36,10 @@ func Register(args plugins.RegistrationArguments) error {
return nil
}
// Actor implements the actor interface
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) {
if eventData.HasAll("clips") {
// 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
}
// IsAsync implements the actor interface
func (Actor) IsAsync() bool { return false }
// Name implements the actor interface
func (Actor) Name() string { return actorName }
// Validate implements the actor interface
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
import (
@ -27,6 +28,7 @@ var (
commercialChatcommandRegex = regexp.MustCompile(`^/commercial ([0-9]+)$`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
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)
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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")
if err != nil || durationTemplate == "" {
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
import (
@ -22,20 +24,22 @@ var (
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
func Register(args plugins.RegistrationArguments) error {
func Register(args plugins.RegistrationArguments) (err error) {
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")
}
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
args.RegisterActor("counter", func() plugins.Actor { return &ActorCounter{} })
args.RegisterActor("counter", func() plugins.Actor { return &actorCounter{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{
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",
HandlerFunc: routeActorCounterGetValue,
Method: http.MethodGet,
@ -95,9 +99,11 @@ func Register(args plugins.RegistrationArguments) error {
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",
HandlerFunc: routeActorCounterSetValue,
Method: http.MethodPatch,
@ -125,7 +131,9 @@ func Register(args plugins.RegistrationArguments) error {
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{} {
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)
}), plugins.TemplateFuncDocumentation{
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) {
return GetCounterValue(db, name)
return getCounterValue(db, name)
}), plugins.TemplateFuncDocumentation{
Description: "Returns the current value of the counter which identifier was supplied",
Syntax: "counterValue <counter name>",
@ -185,11 +193,11 @@ func Register(args plugins.RegistrationArguments) error {
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 GetCounterValue(db, name)
return getCounterValue(db, name)
}), plugins.TemplateFuncDocumentation{
Description: "Adds the given value (or 1 if no value) to the counter and returns its new value",
Syntax: "counterValueAdd <counter name> [increase=1]",
@ -202,9 +210,9 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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(
UpdateCounter(db, counterName, counterValue, true),
updateCounter(db, counterName, counterValue, true),
"set counter",
)
}
@ -241,15 +249,15 @@ func (a ActorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, ev
}
return false, errors.Wrap(
UpdateCounter(db, counterName, counterStep, false),
updateCounter(db, counterName, counterStep, false),
"update counter",
)
}
func (a ActorCounter) IsAsync() bool { return false }
func (a ActorCounter) Name() string { return "counter" }
func (actorCounter) IsAsync() bool { return false }
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 == "" {
return errors.New("counter name must be non-empty string")
}
@ -269,7 +277,7 @@ func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
template = "%d"
}
cv, err := GetCounterValue(db, mux.Vars(r)["name"])
cv, err := getCounterValue(db, mux.Vars(r)["name"])
if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return
@ -291,7 +299,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
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)
return
}

View file

@ -10,14 +10,14 @@ import (
)
type (
Counter struct {
counter struct {
Name string `gorm:"primaryKey"`
Value int64
}
)
func GetCounterValue(db database.Connector, counterName string) (int64, error) {
var c Counter
func getCounterValue(db database.Connector, counterName string) (int64, error) {
var c counter
err := helpers.Retry(func() 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")
}
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 {
cv, err := GetCounterValue(db, counterName)
cv, err := getCounterValue(db, counterName)
if err != nil {
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{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(Counter{Name: counterName, Value: value}).Error
}).Create(counter{Name: counterName, Value: value}).Error
}),
"storing counter value",
)
}
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 {
return db.DB().
@ -74,8 +75,8 @@ func getCounterRank(db database.Connector, prefix, name string) (rank, count int
return rank, count, nil
}
func getCounterTopList(db database.Connector, prefix string, n int) ([]Counter, error) {
var cc []Counter
func getCounterTopList(db database.Connector, prefix string, n int) ([]counter, error) {
var cc []counter
err := helpers.Retry(func() error {
return db.DB().

View file

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

View file

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

View file

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

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
import (
@ -13,6 +15,7 @@ const actorName = "eventmod"
var formatMessage plugins.MsgFormatter
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
@ -41,7 +44,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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")
if err != nil || fieldsTemplate == "" {
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
import (
@ -8,6 +10,7 @@ import (
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -24,6 +27,7 @@ var (
send plugins.SendMessageFunc
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
send = args.SendMessage
@ -53,7 +57,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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 {
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 {
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
}
func (a actor) IsAsync() bool { return true }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return true }
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")
if err != nil || sourceTpl == "" {
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
import (
@ -11,6 +13,7 @@ const actorName = "linkdetector"
var ptrFalse = func(v bool) *bool { return &v }(false)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -35,8 +38,10 @@ func Register(args plugins.RegistrationArguments) error {
return nil
}
// Actor implements the actor interface
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) {
if eventData.HasAll("links") {
// 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
}
// IsAsync implements the actor interface
func (Actor) IsAsync() bool { return false }
// Name implements the actor interface
func (Actor) Name() string { return actorName }
// Validate implements the actor interface
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
import (
"context"
"regexp"
"strings"
"time"
@ -22,6 +25,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
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 {
case "ban":
if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
0,
@ -178,6 +183,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
if err = botTwitchClient.DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData),
msgID,
); err != nil {
@ -191,6 +197,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
to,
@ -291,6 +298,7 @@ func (actor) checkClipChannelDenied(denyList []string, clips []twitch.ClipInfo)
return verdictAllFine
}
//revive:disable-next-line:flag-parameter
func (actor) checkAllLinksAllowed(allowList, links []string, autoAllowClipLinks bool) verdict {
if len(allowList) == 0 {
// 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
}
//revive:disable-next-line:flag-parameter
func (actor) checkLinkDenied(denyList, links []string, ignoreClipLinks bool) verdict {
for _, link := range links {
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
import (
@ -14,6 +15,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
@ -42,7 +44,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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
}
func (a actor) IsAsync() bool { return true }
func (a actor) Name() string { return "log" }
func (actor) IsAsync() bool { return true }
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 == "" {
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
import (
@ -25,6 +27,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
@ -55,7 +58,11 @@ func sendPayload(hookURL string, payload any, expRespCode int) (preventCooldown
if err != nil {
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 {
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) {
if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil {
return err
return err //nolint:wrapcheck
}
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 {
return err
return err //nolint:wrapcheck
}
if !attrs.MustBool("add_embed", ptrBoolFalse) {
// We're not validating the rest if embeds are disabled but in
// this case the content is mandatory
return d.ValidateRequireNonEmpty(attrs, "content")
return d.ValidateRequireNonEmpty(attrs, "content") //nolint:wrapcheck
}
//nolint:wrapcheck
return d.ValidateRequireValidTemplateIfSet(
tplValidator, attrs,
"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) {
if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil {
return err
return err //nolint:wrapcheck
}
//nolint:wrapcheck
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
import (
@ -20,6 +22,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
tcGetter = args.GetTwitchClientForChannel
@ -67,7 +70,7 @@ func Register(args plugins.RegistrationArguments) error {
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 (
game = attrs.MustString("game", 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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
return errors.New("channel must be non-empty string")
}

View file

@ -1,6 +1,7 @@
package nuke
import (
"context"
"fmt"
"time"
@ -14,6 +15,7 @@ type (
func actionBan(channel, match, _, user string) error {
return errors.Wrap(
botTwitchClient.BanUser(
context.Background(),
channel,
user,
0,
@ -26,6 +28,7 @@ func actionBan(channel, match, _, user string) error {
func actionDelete(channel, _, msgid, _ string) (err error) {
return errors.Wrap(
botTwitchClient.DeleteMessage(
context.Background(),
channel,
msgid,
),
@ -37,6 +40,7 @@ func getActionTimeout(duration time.Duration) actionFn {
return func(channel, match, msgid, user string) error {
return errors.Wrap(
botTwitchClient.BanUser(
context.Background(),
channel,
user,
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
import (
@ -32,6 +35,7 @@ var (
ptrString10m = func(v string) *string { return &v }("10m")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
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)
if err != nil {
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
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
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
import (
"context"
"math"
"strings"
"time"
@ -29,6 +32,7 @@ var (
ptrStringEmpty = func(v string) *string { return &v }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
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 {
return database.CopyObjects(src, target, &punishLevel{})
return database.CopyObjects(src, target, &punishLevel{}) //nolint:wrapcheck // internal helper
})
botTwitchClient = args.GetTwitchClient()
@ -142,7 +146,7 @@ type (
// 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 (
cooldown = attrs.MustDuration("cooldown", ptrDefaultCooldown)
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 {
case "ban":
if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"),
0,
@ -183,6 +188,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
}
if err = botTwitchClient.DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData),
msgID,
); err != nil {
@ -196,6 +202,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
}
if err = botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"),
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 (a actorPunish) Name() string { return actorNamePunish }
func (actorPunish) IsAsync() bool { return false }
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 == "" {
return errors.New("user must be non-empty string")
}
@ -236,7 +243,7 @@ func (a actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs
// 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 (
user = attrs.MustString("user", nil)
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 (a actorResetPunish) Name() string { return actorNameResetPunish }
func (actorResetPunish) IsAsync() bool { return false }
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 == "" {
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 := db.DB().First(&p, "key = ?", getDBKey(channel, user, uuid)).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err)
return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // we get our internal error
}
return err
})

View file

@ -1,6 +1,9 @@
// Package quotedb contains a quote database and actor / api methods
// to manage it
package quotedb
import (
"fmt"
"strconv"
"github.com/pkg/errors"
@ -25,14 +28,15 @@ var (
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()
if err := db.DB().AutoMigrate(&quote{}); err != nil {
if err = db.DB().AutoMigrate(&quote{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
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
@ -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{} {
return func() (int, error) {
return GetMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
return getMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
}
}, plugins.TemplateFuncDocumentation{
Description: "Gets the last quote index in the quote database for the current channel",
@ -107,7 +113,7 @@ 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 (
action = attrs.MustString("action", ptrStringEmpty)
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(
AddQuote(db, plugins.DeriveChannel(m, eventData), quote),
addQuote(db, plugins.DeriveChannel(m, eventData), quote),
"adding quote",
)
case "del":
return false, errors.Wrap(
DelQuote(db, plugins.DeriveChannel(m, eventData), index),
delQuote(db, plugins.DeriveChannel(m, eventData), index),
"storing quote database",
)
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 {
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
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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)
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(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
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 {
_, createdAt, _, err := GetQuoteRaw(db, channel, quoteIdx)
func delQuote(db database.Connector, channel string, quoteIdx int) error {
_, createdAt, _, err := getQuoteRaw(db, channel, quoteIdx)
if err != nil {
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
if err := helpers.Retry(func() 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
}
func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
func getMaxQuoteIdx(db database.Connector, channel string) (int, error) {
var count int64
if err := helpers.Retry(func() error {
return db.DB().
@ -78,14 +78,14 @@ func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
return int(count), nil
}
func GetQuote(db database.Connector, channel string, quote int) (int, string, error) {
quoteIdx, _, quoteText, err := GetQuoteRaw(db, channel, quote)
func getQuote(db database.Connector, channel string, quote int) (int, string, error) {
quoteIdx, _, quoteText, err := getQuoteRaw(db, channel, quote)
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 {
max, err := GetMaxQuoteIdx(db, channel)
max, err := getMaxQuoteIdx(db, channel)
if err != nil {
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(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
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 {
_, createdAt, _, err := GetQuoteRaw(db, channel, idx)
func updateQuote(db database.Connector, channel string, idx int, quoteStr string) error {
_, createdAt, _, err := getQuoteRaw(db, channel, idx)
if err != nil {
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.Zero(t, cq, "expecting no 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.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.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.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.NotZero(t, idx, "index 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.Equal(t, 3, idx, "index must be 3")
assert.Equal(t, quotes[2], q, "quote must not the third")

View file

@ -3,6 +3,7 @@ package quotedb
import (
_ "embed"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
@ -20,16 +21,19 @@ var (
listScript []byte
)
func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
register(plugins.HTTPRouteRegistrationArgs{
//nolint:funlen
func registerAPI(register plugins.HTTPRouteRegistrationFunc) (err error) {
if err = register(plugins.HTTPRouteRegistrationArgs{
HandlerFunc: handleScript,
Method: http.MethodGet,
Module: "quotedb",
Path: "/app.js",
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}",
HandlerFunc: handleAddQuotes,
Method: http.MethodPost,
@ -44,9 +48,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
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}",
HandlerFunc: handleDeleteQuote,
Method: http.MethodDelete,
@ -65,9 +71,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
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"},
Description: "Lists all quotes for the given {channel}",
HandlerFunc: handleListQuotes,
@ -82,9 +90,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
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!)",
HandlerFunc: handleReplaceQuotes,
Method: http.MethodPut,
@ -99,9 +109,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
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}",
HandlerFunc: handleUpdateQuote,
Method: http.MethodPut,
@ -120,7 +132,11 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
Name: "idx",
},
},
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil
}
func handleAddQuotes(w http.ResponseWriter, r *http.Request) {
@ -133,7 +149,7 @@ func handleAddQuotes(w http.ResponseWriter, r *http.Request) {
}
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)
return
}
@ -154,7 +170,7 @@ func handleDeleteQuote(w http.ResponseWriter, r *http.Request) {
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)
return
}
@ -165,13 +181,13 @@ func handleDeleteQuote(w http.ResponseWriter, r *http.Request) {
func handleListQuotes(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Accept"), "text/html") {
w.Header().Set("Content-Type", "text/html")
w.Write(listFrontend)
w.Write(listFrontend) //nolint:errcheck,gosec,revive
return
}
channel := "#" + strings.TrimLeft(mux.Vars(r)["channel"], "#")
quotes, err := GetChannelQuotes(db, channel)
quotes, err := getChannelQuotes(db, channel)
if err != nil {
http.Error(w, errors.Wrap(err, "getting quotes").Error(), http.StatusInternalServerError)
return
@ -192,7 +208,7 @@ func handleReplaceQuotes(w http.ResponseWriter, r *http.Request) {
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)
return
}
@ -202,7 +218,7 @@ func handleReplaceQuotes(w http.ResponseWriter, r *http.Request) {
func handleScript(w http.ResponseWriter, _ *http.Request) {
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) {
@ -228,7 +244,7 @@ func handleUpdateQuote(w http.ResponseWriter, r *http.Request) {
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)
return
}

View file

@ -1,3 +1,4 @@
// Package raw contains an actor to send raw IRC messages
package raw
import (
@ -16,6 +17,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
send = args.SendMessage
@ -45,7 +47,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
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
import (
@ -24,7 +25,8 @@ var (
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
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)",
HandlerFunc: handleAPISend,
Method: http.MethodPost,
@ -91,14 +93,16 @@ func Register(args plugins.RegistrationArguments) error {
Name: "channel",
},
},
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil
}
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)
if err != 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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
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
import (
@ -14,6 +16,7 @@ const actorName = "shield"
var botTwitchClient *twitch.Client
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
@ -42,7 +45,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 {
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
import (
"context"
"regexp"
"github.com/pkg/errors"
@ -20,6 +23,7 @@ var (
shoutoutChatcommandRegex = regexp.MustCompile(`^/shoutout +([^\s]+)$`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage
@ -51,7 +55,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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(
botTwitchClient.SendShoutout(
context.Background(),
plugins.DeriveChannel(m, eventData),
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
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")
}
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")
}

View file

@ -1,3 +1,5 @@
// Package stopexec contains an actor to stop the rule execution on
// template condition
package stopexec
import (
@ -11,6 +13,7 @@ const actorName = "stopexec"
var formatMessage plugins.MsgFormatter
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
@ -39,7 +42,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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")
if err != nil || whenTemplate == "" {
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
import (
"context"
"regexp"
"strconv"
"time"
@ -22,6 +24,7 @@ var (
timeoutChatcommandRegex = regexp.MustCompile(`^/timeout +([^\s]+) +([0-9]+) +(.+)$`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage
@ -62,7 +65,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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(
botTwitchClient.BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData),
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 {
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")
}
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")
}

View file

@ -1,3 +1,5 @@
// Package variables contains an actor and database client to store
// handle variables
package variables
import (
@ -21,20 +23,22 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
//
//nolint:funlen // Function contains only documentation registration
func Register(args plugins.RegistrationArguments) error {
func Register(args plugins.RegistrationArguments) (err error) {
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")
}
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
args.RegisterActor("setvariable", func() plugins.Actor { return &ActorSetVariable{} })
args.RegisterActor("setvariable", func() plugins.Actor { return &actorSetVariable{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{
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",
HandlerFunc: routeActorSetVarGetValue,
Method: http.MethodGet,
@ -86,9 +90,11 @@ func Register(args plugins.RegistrationArguments) error {
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",
HandlerFunc: routeActorSetVarSetValue,
Method: http.MethodPatch,
@ -110,10 +116,12 @@ func Register(args plugins.RegistrationArguments) error {
Name: "name",
},
},
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
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 {
return "", errors.Wrap(err, "getting variable")
}
@ -134,9 +142,9 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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) {
return false, errors.Wrap(
RemoveVariable(db, varName),
removeVariable(db, varName),
"removing variable",
)
}
@ -155,15 +163,15 @@ func (a ActorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule
}
return false, errors.Wrap(
SetVariable(db, varName, value),
setVariable(db, varName, value),
"setting variable",
)
}
func (a ActorSetVariable) IsAsync() bool { return false }
func (a ActorSetVariable) Name() string { return "setvariable" }
func (actorSetVariable) IsAsync() bool { return false }
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 == "" {
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) {
vc, err := GetVariable(db, mux.Vars(r)["name"])
vc, err := getVariable(db, mux.Vars(r)["name"])
if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return
@ -189,7 +197,7 @@ func routeActorSetVarGetValue(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)
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
err := helpers.Retry(func() error {
err := db.DB().First(&v, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err)
return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // we get our internal error
}
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(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
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(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) 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"
)
v, err := GetVariable(dbc, name)
v, err := getVariable(dbc, name)
assert.NoError(t, err, "getting 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.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.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
import (
@ -19,6 +20,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage
permCheckFn = args.HasPermissionForChannel
@ -96,7 +98,7 @@ type (
)
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"} {
if v, err := attrs.String(field); err != nil || v == "" {
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
}
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 {
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(
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",
)
}
@ -143,7 +147,9 @@ func (v vipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventD
}
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",
)
}

View file

@ -1,6 +1,9 @@
// Package whisper contains an actor to send whispers
package whisper
import (
"context"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
@ -17,6 +20,7 @@ var (
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage
@ -55,7 +59,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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(
botTwitchClient.SendWhisper(to, msg),
botTwitchClient.SendWhisper(context.Background(), to, msg),
"sending whisper",
)
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
return errors.New("to must be non-empty string")
}

View file

@ -11,7 +11,7 @@ import (
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)
if err != nil {
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 (a actor) Name() string { return actorName }
func (actor) IsAsync() bool { return false }
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 == "" {
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
import (
@ -27,14 +29,15 @@ var (
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()
if err := db.DB().AutoMigrate(&storedCustomEvent{}); err != nil {
if err = db.DB().AutoMigrate(&storedCustomEvent{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
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}
@ -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",
HandlerFunc: handleCreateEvent,
Method: http.MethodPost,
@ -94,7 +97,9 @@ func Register(args plugins.RegistrationArguments) error {
Name: "channel",
},
},
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
for schedule, fn := range map[string]func(){
fmt.Sprintf("@every %s", cleanupTimeout): scheduleCleanup,

View file

@ -57,6 +57,7 @@ func (m *memoryCache) Refresh() (err error) {
return m.refresh()
}
//revive:disable-next-line:confusing-naming
func (m *memoryCache) refresh() (err error) {
if m.events, err = getFutureEvents(m.dbc); err != nil {
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
import (
@ -11,10 +13,11 @@ import (
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
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",
HandlerFunc: handleFormattedMessage,
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
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
})
}); err != nil {
return fmt.Errorf("registering API route: %w", err)
}
return nil
}

View file

@ -9,6 +9,7 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"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)
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(
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Create(&overlaysEvent{
Channel: channel,
CreatedAt: evt.Time.UTC(),
EventType: evt.Type,
Fields: strings.TrimSpace(buf.String()),
}).Error
}),
"storing event to database",
)
storEvt := &overlaysEvent{
Channel: channel,
CreatedAt: evt.Time.UTC(),
EventType: evt.Type,
Fields: strings.TrimSpace(buf.String()),
}
if err = helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Create(storEvt).Error
}); 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
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")
}
var out []SocketMessage
var out []socketMessage
for _, e := range evts {
fields := new(plugins.FieldCollection)
if err := json.NewDecoder(strings.NewReader(e.Fields)).Decode(fields); err != nil {
return nil, errors.Wrap(err, "decoding fields")
sm, err := e.ToSocketMessage()
if err != nil {
return nil, errors.Wrap(err, "transforming event")
}
out = append(out, SocketMessage{
IsLive: false,
Time: e.CreatedAt,
Type: e.EventType,
Fields: fields,
})
out = append(out, sm)
}
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 (
channel = "#test"
tEvent1 = time.Now()
evtID uint64
tEvent1 = time.Now().UTC()
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.Zero(t, evts, "expect no events on empty db")
assert.NoError(t, AddChannelEvent(dbc, channel, SocketMessage{
evtID, err = addChannelEvent(dbc, channel, socketMessage{
IsLive: true,
Time: tEvent2,
Type: "event 2",
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,
Time: tEvent1,
Type: "event 1",
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,
Time: tEvent1,
Type: "event",
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.Len(t, evts, 2, "expect 2 events")
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>
<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">
<td>{{ moment(event.time).format('YYYY-MM-DD HH:mm:ss') }}</td>
<td>{{ event.reason }}</td>
<td>{{ event.event }}</td>
<td>
<span
@ -73,13 +74,13 @@
mounted() {
window.botClient = new EventClient({
handlers: {
_: (evt, data, time, live) => {
if (window.botClient.paramOptionFallback('hide', '').split(',').includes(evt)) {
_: ({ fields, reason, time, type }) => {
if (window.botClient.paramOptionFallback('hide', '').split(',').includes(type)) {
return
}
this.events = [
{ event: evt, fields: data, time },
{ event: type, fields, reason, time },
...this.events,
]
},

View file

@ -1,11 +1,22 @@
/**
* Options to pass to the EventClient constructor
* @typedef {Object} Options
* @prop {string} [channel] - Filter for specific channel events (format: `#channel`)
* @prop {Object} [handlers={}] - Map event types to callback functions `(event, fields, time, live) => {...}`
* @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 {string} [token] - API access token to use to connect to the WebSocket (if not set, must be provided through URL hash)
* @prop {String} [channel] - Filter for specific channel events (format: `#channel`)
* @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 {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)
*/
/**
* 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
@ -24,7 +35,7 @@ class EventClient {
* @param {Options} opts Options for the EventClient
*/
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.options = { ...opts }
@ -52,7 +63,7 @@ class EventClient {
* @returns {string} API base URL
*/
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)) {
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 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())
}
/**
* 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
*

View file

@ -8,11 +8,8 @@ new Vue({
new EventClient({
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)
},
handleCustom(evt, data, time, live) {
handleCustom(data) {
switch (data.type) {
case 'soundalert':
if (!live) {
// Not a live event, do not issue alerts
return
}
this.queueAlert({
soundUrl: data.soundUrl,
})

View file

@ -12,8 +12,8 @@ var _ http.FileSystem = httpFSStack{}
type httpFSStack []http.FileSystem
func (h httpFSStack) Open(name string) (http.File, error) {
for _, fs := range h {
if f, err := fs.Open(name); err == nil {
for _, stackedFS := range h {
if f, err := stackedFS.Open(name); err == 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) {
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
import (
"embed"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -31,14 +35,28 @@ const (
)
type (
SocketMessage struct {
IsLive bool `json:"is_live"`
Time time.Time `json:"time"`
Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"`
// sendReason contains an enum of reasons why the message is
// transmitted to the listening overlay sockets
sendReason string
// 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 (
//go:embed default/**
embeddedOverlays embed.FS
@ -53,7 +71,7 @@ var (
"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
upgrader = websocket.Upgrader{
@ -64,19 +82,40 @@ var (
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()
if err := db.DB().AutoMigrate(&overlaysEvent{}); err != nil {
if err = db.DB().AutoMigrate(&overlaysEvent{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
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
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",
HandlerFunc: handleSocketSubscription,
Method: http.MethodGet,
@ -84,9 +123,11 @@ func Register(args plugins.RegistrationArguments) error {
Name: "Websocket",
Path: "/events.sock",
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",
HandlerFunc: handleEventsReplay,
Method: http.MethodGet,
@ -109,9 +150,25 @@ func Register(args plugins.RegistrationArguments) error {
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,
IsPrefix: true,
Method: http.MethodGet,
@ -119,30 +176,43 @@ func Register(args plugins.RegistrationArguments) error {
Path: "/",
ResponseType: plugins.HTTPRouteResponseTypeMultiple,
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()
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 {
fn(event, eventData)
fn(msg)
}
if str.StringInSlice(event, storeExemption) {
return nil
}
return errors.Wrap(
AddChannelEvent(db, plugins.DeriveChannel(nil, eventData), SocketMessage{
IsLive: false,
Time: time.Now(),
Type: event,
Fields: eventData,
}),
"storing event",
)
})
return nil
}); err != nil {
return fmt.Errorf("registering event handler: %w", err)
}
fsStack = httpFSStack{
newPrefixedFS("default", http.FS(embeddedOverlays)),
@ -161,7 +231,7 @@ func Register(args plugins.RegistrationArguments) error {
func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
var (
channel = mux.Vars(r)["channel"]
msgs []SocketMessage
msgs []socketMessage
since = time.Time{}
)
@ -169,7 +239,7 @@ func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
since = s
}
events, err := GetChannelEvents(db, "#"+strings.TrimLeft(channel, "#"))
events, err := getChannelEvents(db, "#"+strings.TrimLeft(channel, "#"))
if err != nil {
http.Error(w, errors.Wrap(err, "getting channel events").Error(), http.StatusInternalServerError)
return
@ -180,6 +250,7 @@ func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
continue
}
msg.Reason = sendReasonBulkReplay
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)
}
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
func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
var (
@ -208,25 +302,18 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
logger.WithError(err).Error("Unable to upgrade socket")
return
}
defer conn.Close()
defer conn.Close() //nolint:errcheck // We don't really care about this
var (
authTimeout = time.NewTimer(authTimeout)
connLock = new(sync.Mutex)
errC = make(chan error, 1)
isAuthorized bool
sendMsgC = make(chan SocketMessage, 1)
sendMsgC = make(chan socketMessage, 1)
)
// Register listener
unsub := subscribeSocket(func(event string, eventData *plugins.FieldCollection) {
sendMsgC <- SocketMessage{
IsLive: true,
Time: time.Now(),
Type: event,
Fields: eventData,
}
})
unsub := subscribeSocket(func(msg socketMessage) { sendMsgC <- msg })
defer unsub()
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 {
logger.WithError(err).Error("Unable to send ping message")
connLock.Unlock()
conn.Close()
conn.Close() //nolint:errcheck,gosec
return
}
@ -274,7 +361,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
continue
}
var recvMsg SocketMessage
var recvMsg socketMessage
if err = json.Unmarshal(p, &recvMsg); err != nil {
errC <- errors.Wrap(err, "decoding message")
return
@ -295,7 +382,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
authTimeout.Stop()
isAuthorized = true
sendMsgC <- SocketMessage{
sendMsgC <- socketMessage{
IsLive: true,
Time: time.Now(),
Type: msgTypeRequestAuth,
@ -315,10 +402,18 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
return
case err := <-errC:
if err != nil {
logger.WithError(err).Error("Message processing caused error")
var cErr *websocket.CloseError
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:
if !isAuthorized {
@ -330,14 +425,14 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
if err := conn.WriteJSON(msg); err != nil {
logger.WithError(err).Error("Unable to send socket message")
connLock.Unlock()
conn.Close()
conn.Close() //nolint:errcheck,gosec
}
connLock.Unlock()
}
}
}
func subscribeSocket(fn func(event string, eventData *plugins.FieldCollection)) func() {
func subscribeSocket(fn func(socketMessage)) func() {
id := uuid.Must(uuid.NewV4()).String()
subscribersLock.Lock()

View file

@ -17,7 +17,7 @@ var ptrStrEmpty = ptrStr("")
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) == "" {
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 (a enterRaffleActor) Name() string { return "enter-raffle" }
func (enterRaffleActor) IsAsync() bool { return false }
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")
if err != nil || keyword == "" {
return errors.New("keyword must be non-empty string")

View file

@ -1,6 +1,7 @@
package raffle
import (
"context"
"strings"
"time"
@ -70,7 +71,7 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
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 {
case err == nil:
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 {
var b [8]byte
rand.Read(b[:])
if _, err := rand.Read(b[:]); err != nil {
return -1
}
// mask off sign bit to ensure positive number
return int64(binary.LittleEndian.Uint64(b[:]) & (1<<63 - 1))
}

View file

@ -45,9 +45,12 @@ func testGenerateRaffe() raffle {
func BenchmarkPickWinnerFromRaffle(b *testing.B) {
tData := testGenerateRaffe()
var err error
b.Run("pick", func(b *testing.B) {
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)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
db = args.GetDatabaseConnector()
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 {
return database.CopyObjects(src, target, &raffle{}, &raffleEntry{})
return database.CopyObjects(src, target, &raffle{}, &raffleEntry{}) //nolint:wrapcheck // internal helper
})
dbc = newDBClient(db)

View file

@ -12,6 +12,7 @@ const (
// Retry contains a standard set of configuration parameters for an
// exponential backoff to be used throughout the bot
func Retry(fn func() error) error {
//nolint:wrapcheck
return backoff.NewBackoff().
WithMaxIterations(maxRetries).
Retry(fn)
@ -21,5 +22,7 @@ func Retry(fn func() error) error {
// the database. The function will be run in a transaction on the
// database and will be retried as if executed using Retry
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
import (
@ -52,7 +54,7 @@ func (c Checker) ScanForLinks(message string) (links []string) {
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 {
if links = scanner(message); links != nil {
return links
@ -87,7 +89,6 @@ func (c Checker) scanPartsConnected(parts []string, connector string) (links []s
for ptJoin := 2; ptJoin < len(parts); ptJoin++ {
for i := 0; i <= len(parts)-ptJoin; i++ {
wg.Add(1)
c.res.Resolve(resolverQueueEntry{
Link: strings.Join(parts[i:i+ptJoin], connector),
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 {
wg.Add(1)
c.res.Resolve(resolverQueueEntry{
Link: part,
Callback: func(link string) { links = str.AppendIfMissing(links, link) },

View file

@ -8,6 +8,7 @@ import (
"strconv"
"testing"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
@ -59,9 +60,10 @@ func TestScanForLinks(t *testing.T) {
c := New()
for _, testCase := range []struct {
Heuristic bool
Message string
ExpectedLinks []string
Heuristic bool
Message string
ExpectedLinks []string
ExpectedContains bool
}{
// Case: full URL is present in the message
{
@ -102,6 +104,7 @@ func TestScanForLinks(t *testing.T) {
ExpectedLinks: []string{
"http://example.com",
},
ExpectedContains: true,
},
// Case: link is obfuscated using space and braces
{
@ -110,6 +113,7 @@ func TestScanForLinks(t *testing.T) {
ExpectedLinks: []string{
"http://example.com",
},
ExpectedContains: true,
},
// Case: multiple links in one message
{
@ -162,9 +166,10 @@ func TestScanForLinks(t *testing.T) {
},
// Case: Multiple spaces in the link
{
Heuristic: true,
Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!",
ExpectedLinks: []string{"http://example.com"},
Heuristic: true,
Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!",
ExpectedLinks: []string{"http://example.com"},
ExpectedContains: true,
},
// Case: Dot in the end of the link with space
{
@ -187,7 +192,21 @@ func TestScanForLinks(t *testing.T) {
}
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"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/sirupsen/logrus"
)
const (
@ -74,6 +75,7 @@ func withSkipVerify() func(*resolver) {
}
func (r resolver) Resolve(qe resolverQueueEntry) {
qe.WaitGroup.Add(1)
r.resolverC <- qe
}
@ -84,6 +86,8 @@ func (resolver) getJar() *cookiejar.Jar {
// resolveFinal takes a link and looks up the final destination of
// that link after all redirects were followed
//
//nolint:gocyclo
func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack []string, userAgent string) string {
if !linkTest.MatchString(link) && !r.skipValidation {
return ""
@ -138,7 +142,11 @@ func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack
if err != nil {
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 {
// We got a redirect

View file

@ -1,6 +1,8 @@
// Package access contains a service to manage Twitch tokens and scopes
package access
import (
"context"
"strings"
"github.com/pkg/errors"
@ -21,6 +23,8 @@ const (
)
type (
// ClientConfig contains a configuration to derive new Twitch clients
// from
ClientConfig struct {
TwitchClient string
TwitchClientSecret string
@ -37,11 +41,15 @@ type (
Scopes string
}
// Service manages the permission database
Service struct{ db database.Connector }
)
// ErrChannelNotAuthorized denotes there is no valid authoriztion for
// the given channel
var ErrChannelNotAuthorized = errors.New("channel is not authorized")
// New creates a new Service on the given database
func New(db database.Connector) (*Service, error) {
return &Service{db}, errors.Wrap(
db.DB().AutoMigrate(&extendedPermission{}),
@ -49,15 +57,18 @@ func New(db database.Connector) (*Service, error) {
)
}
func (s *Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &extendedPermission{})
// CopyDatabase enables the bot to migrate the access database
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) {
err = s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername)
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) {
var (
err error
@ -78,6 +89,8 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
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) {
botUsername, err := s.GetBotUsername()
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
// 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 {
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)
}
// 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) {
var (
err error
@ -157,7 +172,7 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
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")
}); err != nil {
@ -189,6 +204,8 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
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) {
storedScopes, err := s.GetChannelPermissions(channel)
if err != nil {
@ -204,6 +221,8 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b
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) {
storedScopes, err := s.GetChannelPermissions(channel)
if err != nil {
@ -232,7 +251,7 @@ func (s Service) HasTokensForChannel(channel string) (bool, error) {
if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
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")
}); err != nil {
@ -253,12 +272,14 @@ func (s Service) HasTokensForChannel(channel string) (bool, error) {
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) {
var perms []extendedPermission
if err = helpers.Retry(func() error {
return errors.Wrap(s.db.DB().Find(&perms).Error, "listing permissions")
}); err != nil {
return nil, err
return nil, err //nolint:wrapcheck // is already wrapped on the inside
}
for _, perm := range perms {
@ -268,6 +289,7 @@ func (s Service) ListPermittedChannels() (out []string, err error) {
return out, nil
}
// RemoveAllExtendedTwitchCredentials wipes the access database
func (s Service) RemoveAllExtendedTwitchCredentials() error {
return errors.Wrap(
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 {
return errors.Wrap(
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) {
return errors.Wrap(
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) {
if accessToken, err = s.db.EncryptField(accessToken); err != nil {
return errors.Wrap(err, "encrypting access token")

View file

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

View file

@ -1,3 +1,4 @@
// Package timer contains a service to store and manage timers in a database
package timer
import (
@ -19,6 +20,7 @@ import (
)
type (
// Service implements a timer service
Service struct {
db database.Connector
permitTimeout time.Duration
@ -32,6 +34,7 @@ type (
var _ plugins.TimerStore = (*Service)(nil)
// New creates a new Service
func New(db database.Connector, cronService *cron.Cron) (*Service, error) {
s := &Service{
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")
}
func (s *Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &timer{})
// CopyDatabase enables the service to migrate to a new database
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) {
s.permitTimeout = d
}
// Cooldown timer
// AddCooldown adds a new cooldown timer
func (s Service) AddCooldown(tt plugins.TimerType, limiter, ruleID string, expiry time.Time) error {
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) {
return s.HasTimer(s.getCooldownTimerKey(tt, limiter, ruleID))
}
@ -72,10 +79,12 @@ func (Service) getCooldownTimerKey(tt plugins.TimerType, limiter, ruleID string)
// Permit timer
// AddPermit adds a new permit timer
func (s Service) AddPermit(channel, username string) error {
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) {
return s.HasTimer(s.getPermitTimerKey(channel, username))
}
@ -88,12 +97,13 @@ func (Service) getPermitTimerKey(channel, username string) string {
// Generic timer
// HasTimer checks whether a timer with given ID is present
func (s Service) HasTimer(id string) (bool, error) {
var t timer
err := helpers.Retry(func() error {
err := s.db.DB().First(&t, "id = ? AND expires_at >= ?", id, time.Now().UTC()).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err)
return backoff.NewErrCannotRetry(err) //nolint:wrapcheck // We'll get our own error
}
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 {
return errors.Wrap(
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
import "github.com/Luzifer/twitch-bot/v3/plugins"
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
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.)",

View file

@ -10,6 +10,7 @@ import (
"github.com/itchyny/gojq"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
@ -41,7 +42,11 @@ func jsonAPI(uri, path string, fallback ...string) (string, error) {
if err != nil {
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 {
case http.StatusOK:

View file

@ -8,6 +8,7 @@ import (
"net/url"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
func textAPI(uri string, fallback ...string) (string, error) {
@ -29,7 +30,11 @@ func textAPI(uri string, fallback ...string) (string, error) {
if err != nil {
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 {
case http.StatusOK:

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
// Package strings contains string manipulation helpers
package strings
import (
@ -8,6 +9,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins"
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("b64urlenc", plugins.GenericTemplateFunctionGetter(base64URLEncode), plugins.TemplateFuncDocumentation{
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
import (
@ -15,6 +17,7 @@ var (
tcGetter func(string) (*twitch.Client, error)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
permCheckFn = args.HasPermissionForChannel
tcGetter = args.GetTwitchClientForChannel

View file

@ -1,6 +1,7 @@
package twitch
import (
"context"
"time"
"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)
}
fd, err := args.GetTwitchClient().GetFollowDate(from, to)
fd, err := args.GetTwitchClient().GetFollowDate(context.Background(), from, to)
switch {
case err == nil:
return time.Since(fd) > age, nil
@ -61,7 +62,7 @@ func tplTwitchDoesFollowLongerThan(args plugins.RegistrationArguments) {
func tplTwitchDoesFollow(args plugins.RegistrationArguments) {
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 {
case err == nil:
return true, nil
@ -84,7 +85,7 @@ func tplTwitchDoesFollow(args plugins.RegistrationArguments) {
func tplTwitchFollowAge(args plugins.RegistrationArguments) {
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")
}), 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)",
@ -98,7 +99,8 @@ func tplTwitchFollowAge(args plugins.RegistrationArguments) {
func tplTwitchFollowDate(args plugins.RegistrationArguments) {
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{
Description: "Looks up when `from` followed `to` (the bot must be moderator of `to` to read this)",
Syntax: "followDate <from> <to>",

View file

@ -1,10 +1,13 @@
package twitch
import (
"context"
"fmt"
"strings"
"time"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
)
func init() {
@ -18,12 +21,12 @@ func init() {
func tplTwitchRecentGame(args plugins.RegistrationArguments) {
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 == "") {
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{
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]",
@ -36,12 +39,12 @@ func tplTwitchRecentGame(args plugins.RegistrationArguments) {
func tplTwitchRecentTitle(args plugins.RegistrationArguments) {
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 == "") {
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{
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]",
@ -54,9 +57,9 @@ func tplTwitchRecentTitle(args plugins.RegistrationArguments) {
func tplTwitchStreamUptime(args plugins.RegistrationArguments) {
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 {
return 0, err
return 0, fmt.Errorf("getting stream info: %w", err)
}
return time.Since(si.StartedAt), nil
}), plugins.TemplateFuncDocumentation{

View file

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

View file

@ -20,12 +20,12 @@ func init() {
func tplTwitchDisplayName(args plugins.RegistrationArguments) {
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 == "") {
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{
Description: "Returns the display name the specified user set for themselves",
Syntax: "displayName <username> [fallback]",
@ -38,7 +38,8 @@ func tplTwitchDisplayName(args plugins.RegistrationArguments) {
func tplTwitchIDForUsername(args plugins.RegistrationArguments) {
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{
Description: "Returns the user-id for the given username",
Syntax: "idForUsername <username>",
@ -51,7 +52,7 @@ func tplTwitchIDForUsername(args plugins.RegistrationArguments) {
func tplTwitchProfileImage(args plugins.RegistrationArguments) {
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 {
return "", errors.Wrap(err, "getting user info")
}
@ -69,7 +70,8 @@ func tplTwitchProfileImage(args plugins.RegistrationArguments) {
func tplTwitchUsernameForID(args plugins.RegistrationArguments) {
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{
Description: "Returns the current login name of an user-id",
Syntax: "usernameForID <user-id>",

View file

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

101
irc.go
View file

@ -11,7 +11,7 @@ import (
"time"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -48,7 +48,7 @@ func registerRawMessageHandler(fn plugins.RawMessageHandlerFunc) error {
type ircHandler struct {
c *irc.Client
conn *tls.Conn
ctx context.Context
ctx context.Context //nolint:containedctx
ctxCancelFn func()
user string
}
@ -56,7 +56,7 @@ type ircHandler struct {
func newIRCHandler() (*ircHandler, error) {
h := new(ircHandler)
_, username, err := twitchClient.GetAuthorizedUser()
_, username, err := twitchClient.GetAuthorizedUser(context.Background())
if err != nil {
return nil, errors.Wrap(err, "fetching username")
}
@ -68,7 +68,7 @@ func newIRCHandler() (*ircHandler, error) {
return nil, errors.Wrap(err, "connect to IRC server")
}
token, err := twitchClient.GetToken()
token, err := twitchClient.GetToken(context.Background())
if err != nil {
return nil, errors.Wrap(err, "getting auth token")
}
@ -98,11 +98,13 @@ func (i ircHandler) Close() error {
func (i ircHandler) ExecuteJoins(channels []string) {
for _, ch := range channels {
//nolint:errcheck,gosec
i.c.Write(fmt.Sprintf("JOIN #%s", strings.TrimLeft(ch, "#")))
}
}
func (i ircHandler) ExecutePart(channel string) {
//nolint:errcheck,gosec
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()
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)
switch m.Command {
case "001":
// 001 is a welcome event, so we join channels there
//nolint:errcheck,gosec
c.WriteMessage(&irc.Message{
Command: "CAP",
Params: []string{
@ -173,8 +176,10 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
case "RECONNECT":
// RECONNECT (Twitch Commands)
// 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")
i.Close()
logrus.Warn("We were asked to reconnect, closing connection")
if err := i.Close(); err != nil {
logrus.WithError(err).Error("closing IRC connection after reconnect")
}
case "USERNOTICE":
// USERNOTICE (Twitch Commands)
@ -187,7 +192,7 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
i.handleTwitchWhisper(m)
default:
log.WithFields(log.Fields{
logrus.WithFields(logrus.Fields{
"command": m.Command,
"tags": m.Tags,
"trailing": m.Trailing(),
@ -196,13 +201,18 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
}
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) 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 {
if len(m.Params) > 0 {
@ -230,19 +240,19 @@ func (i ircHandler) handleClearChat(m *irc.Message) {
fields.Set("seconds", seconds)
fields.Set("target_id", targetUserID)
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:
// User w/o Duration = Ban
evt = eventTypeBan
fields.Set("target_id", targetUserID)
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:
// No User = /clear
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)
@ -254,7 +264,7 @@ func (i ircHandler) handleClearMessage(m *irc.Message) {
"message_id": m.Tags["target-msg-id"],
"target_name": m.Tags["login"],
})
log.WithFields(log.Fields(fields.Data())).
logrus.WithFields(logrus.Fields(fields.Data())).
WithField("message", m.Trailing()).
Info("Message was deleted")
go handleMessage(i.c, m, eventTypeDelete, fields)
@ -297,14 +307,16 @@ func (i ircHandler) handlePermit(m *irc.Message) {
"to": username,
})
log.WithFields(fields.Data()).Debug("Added permit")
timerService.AddPermit(m.Params[0], username)
logrus.WithFields(fields.Data()).Debug("Added permit")
if err := timerService.AddPermit(m.Params[0], username); err != nil {
logrus.WithError(err).Error("adding permit")
}
go handleMessage(i.c, m, eventTypePermit, fields)
}
func (i ircHandler) handleTwitchNotice(m *irc.Message) {
log.WithFields(log.Fields{
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"tags": m.Tags,
"trailing": m.Trailing(),
@ -313,15 +325,15 @@ func (i ircHandler) handleTwitchNotice(m *irc.Message) {
switch m.Tags["msg-id"] {
case "":
// 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:
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) {
log.WithFields(log.Fields{
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"name": m.Name,
eventFieldUserName: m.User,
@ -353,7 +365,7 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
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)
}
@ -370,7 +382,7 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
"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)
}
@ -378,8 +390,9 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
go handleMessage(i.c, m, nil, nil)
}
//nolint:funlen
func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
log.WithFields(log.Fields{
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"tags": m.Tags,
"trailing": m.Trailing(),
@ -391,17 +404,23 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
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"] {
case "":
// 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":
evtData.SetFromData(map[string]any{
"color": m.Tags["msg-param-color"],
"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)
@ -409,7 +428,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
evtData.SetFromData(map[string]interface{}{
"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)
@ -418,17 +437,11 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"from": m.Tags["login"],
"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)
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{}{
"from": m.Tags["login"],
"message": message,
@ -436,7 +449,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"subscribed_months": i.tagToNumeric(m, "msg-param-cumulative-months", 0),
"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)
@ -446,7 +459,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"multi_month": i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
"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)
@ -461,7 +474,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"to": m.Tags["msg-param-recipient-user-name"],
"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)
@ -474,10 +487,24 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"plan": m.Tags["msg-param-sub-plan"],
"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)
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