Compare commits

..

17 commits

Author SHA1 Message Date
e21fd41e49
prepare release v3.20.0 2023-12-08 00:36:12 +01:00
35bc4fcdc6
[linkcheck] Use resolver pool to speed up detection
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
5ec6baaf2c
[linkdetector] Add more ways of link detection in heuristic mode
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
a07ad6fe83
[core] Parallelize rule execution
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
ee5e7359a2
[core] Add auth-cache for token auth
to speed up frontend and reduce CPU/MEM consumption on consecutive API
requests

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
3c158ef231
[core] Add way to enable profiling
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
a336772303
[raffle] Add Actor to enter user into raffle using channel-points
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-06 21:57:02 +01:00
6df8fd42c2
[core] Update dependencies
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-04 16:19:36 +01:00
0d10b5165f
[core] Add retries for database access methods
to compensate for database temporarily not being available. This is not
suitable for longer database outages as 5 retries with a 1.5 multiplier
will not give much time to recover but should cover for cluster changes
and short network hickups.

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-12-04 16:19:36 +01:00
a1fa9972a8
[core] Fix: Do not retry requests with status 429
which for example can happen when doing two shoutouts within the 120s
cooldown period enforced by Twitch

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-27 23:18:53 +01:00
3bff986ac4
[CI] Add CRDB integration test
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-27 19:42:16 +01:00
e7a493cafe
[CLI] Add database migration tooling
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-26 16:55:05 +01:00
4059f089e0
[docs] Link the discussion board
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-05 15:23:34 +01:00
9ebdaa8a71
[templating] Add scheduleSegments function
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-05 13:44:16 +01:00
cb68b029ec
[core] Add timeout to eventsub connection dialer
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-05 11:50:56 +01:00
a2ffc25a26
[raffle] Fix datatype in API documentation
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-10-30 11:36:03 +01:00
932e6907da
[eventsub] Replace keepalive timer
as `time.Timer` wasn't really suited for how it was used

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-10-30 11:36:03 +01:00
58 changed files with 1848 additions and 774 deletions

61
.github/workflows/integration-crdb.yml vendored Normal file
View file

@ -0,0 +1,61 @@
---
name: integration-crdb
on:
push:
branches:
- master
permissions:
contents: write
jobs:
test:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
crdb:
image: luzifer/crdb-gh-service
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
cockroachdb-bin \
git \
go \
make
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up CRDB service
run: |
cockroach sql --host crdb --insecure <<EOF
CREATE DATABASE integration;
CREATE USER "twitch_bot" WITH PASSWORD NULL;
ALTER DATABASE integration OWNER to "twitch_bot";
EOF
- name: Run tests against CRDB
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=crdb user=twitch_bot dbname=integration port=26257 sslmode=disable timezone=UTC
run: make test
...

View file

@ -0,0 +1,64 @@
---
name: integration-mariadb
on:
push:
branches:
- master
permissions:
contents: write
jobs:
test:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
git \
go \
make \
mariadb-clients
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MariaDB service
run: |
mariadb -h mariadb -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MariaDB
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mariadb:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
...

64
.github/workflows/integration-mysql.yml vendored Normal file
View file

@ -0,0 +1,64 @@
---
name: integration-mysql
on:
push:
branches:
- master
permissions:
contents: write
jobs:
test:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
git \
go \
make \
mariadb-clients
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MySQL service
run: |
mariadb -h mysql -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MySQL
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mysql:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
...

View file

@ -0,0 +1,54 @@
---
name: integration-postgres
on:
push:
branches:
- master
permissions:
contents: write
jobs:
test:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: twitch-bot-pass
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
git \
go \
make
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Run tests against PostgreSQL
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=postgres user=postgres password=twitch-bot-pass dbname=postgres port=5432 sslmode=disable timezone=UTC
run: make test
...

View file

@ -85,106 +85,4 @@ jobs:
draft: false draft: false
generateReleaseNotes: false generateReleaseNotes: false
database-integration:
# Only execute db-server integration tests when sqlite based tests did run successfully
needs: [test-and-build]
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
database: [mariadb, mysql, postgres]
services:
mariadb:
image: mariadb:11
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
mysql:
image: mysql:8
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: twitch-bot-pass
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
docker \
git \
go \
make \
mariadb-clients
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
# --- MySQL
- name: Set up MySQL service
if: matrix.database == 'mysql'
run: |
mariadb -h mysql -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MySQL
if: matrix.database == 'mysql'
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mysql:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
# --- MariaDB
- name: Set up MariaDB service
if: matrix.database == 'mariadb'
run: |
mariadb -h mariadb -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MariaDB
if: matrix.database == 'mariadb'
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mariadb:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
# --- PostgreSQL
- name: Run tests against PostgreSQL
if: matrix.database == 'postgres'
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=postgres user=postgres password=twitch-bot-pass dbname=postgres port=5432 sslmode=disable timezone=UTC
run: make test
... ...

View file

@ -1,3 +1,24 @@
# 3.20.0 / 2023-12-08
* New Features
* [cli] Add database migration tooling
* [raffle] Add Actor to enter user into raffle using channel-points
* [templating] Add `scheduleSegments` function
* Improvements
* [core] Add auth-cache for token auth
* [core] Parallelize rule execution
* [linkdetector] Add more ways of link detection in heuristic mode
* [linkdetector] Use resolver pool to speed up detection
* Bugfixes
* [core] Add retries for database access methods
* [core] Add timeout to eventsub connection dialer
* [core] Fix: Do not retry requests with status 429
* [core] Update dependencies
* [eventsub] Replace keepalive timer
* [raffle] Fix datatype in API documentation
# 3.19.0 / 2023-10-28 # 3.19.0 / 2023-10-28
> [!IMPORTANT] > [!IMPORTANT]

View file

@ -36,11 +36,12 @@ Usage of twitch-bot:
# twitch-bot help # twitch-bot help
Supported sub-commands are: Supported sub-commands are:
actor-docs Generate markdown documentation for available actors actor-docs Generate markdown documentation for available actors
api-token <token-name> <scope> [...scope] Generate an api-token to be entered into the config api-token <token-name> <scope> [...scope] Generate an api-token to be entered into the config
reset-secrets Remove encrypted data to reset encryption passphrase copy-database <target storage-type> <target DSN> Copies database contents to a new storage DSN i.e. for migrating to a new DBMS
tpl-docs Generate markdown documentation for available template functions reset-secrets Remove encrypted data to reset encryption passphrase
validate-config Try to load configuration file and report errors if any tpl-docs Generate markdown documentation for available template functions
validate-config Try to load configuration file and report errors if any
``` ```
### Database Connection Strings ### Database Connection Strings

View file

@ -71,44 +71,53 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData *plug
go notifyEventHandlers(*event, eventData) go notifyEventHandlers(*event, eventData)
} }
for _, r := range config.GetMatchingRules(m, event, eventData) { matchingRules := config.GetMatchingRules(m, event, eventData)
var ( for i := range matchingRules {
ruleEventData = plugins.NewFieldCollection() go handleMessageRuleExecution(c, m, matchingRules[i], eventData)
preventCooldown bool }
) }
if eventData != nil { func handleMessageRuleExecution(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection) {
ruleEventData.SetFromData(eventData.Data()) var (
} ruleEventData = plugins.NewFieldCollection()
preventCooldown bool
ActionsLoop: )
for _, a := range r.Actions {
apc, err := triggerAction(c, m, r, a, ruleEventData) if eventData != nil {
switch { ruleEventData.SetFromData(eventData.Data())
case err == nil: }
// Rule execution did not cause an error, we store the
// cooldown modifier and continue ActionsLoop:
preventCooldown = preventCooldown || apc for _, a := range r.Actions {
continue ActionsLoop apc, err := triggerAction(c, m, r, a, ruleEventData)
switch {
case errors.Is(err, plugins.ErrStopRuleExecution): case err == nil:
// Action has asked to stop executing this rule so we store // Rule execution did not cause an error, we store the
// the cooldown modifier and stop executing the actions stack // cooldown modifier and continue
preventCooldown = preventCooldown || apc
break ActionsLoop preventCooldown = preventCooldown || apc
continue ActionsLoop
default:
// Action experienced an error: We don't store the cooldown case errors.Is(err, plugins.ErrStopRuleExecution):
// state of this action and stop executing the actions stack // Action has asked to stop executing this rule so we store
// for this rule // the cooldown modifier and stop executing the actions stack
log.WithError(err).Error("Unable to trigger action") // Action experienced an error: We don't store the cooldown
break ActionsLoop // Break execution for this rule when one action fails // state of this action and stop executing the actions stack
} // for this rule
}
preventCooldown = preventCooldown || apc
// Lock command break ActionsLoop
if !preventCooldown {
r.SetCooldown(timerService, m, eventData) default:
} // Break execution for this rule when one action fails
// Lock command
log.WithError(err).Error("Unable to trigger action")
break ActionsLoop
}
}
if !preventCooldown {
r.SetCooldown(timerService, m, eventData)
} }
} }

64
authBackends.go Normal file
View file

@ -0,0 +1,64 @@
package main
import (
"context"
"net/http"
"time"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/pkg/errors"
)
const internalTokenAuthCacheExpiry = 5 * time.Minute
func authBackendInternalToken(token string) (modules []string, expiresAt time.Time, err error) {
for _, auth := range config.AuthTokens {
if auth.validate(token) != nil {
continue
}
// We found a matching token
return auth.Modules, time.Now().Add(internalTokenAuthCacheExpiry), nil
}
return nil, time.Time{}, authcache.ErrUnauthorized
}
func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time, err error) {
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
var httpError twitch.HTTPError
id, user, err := tc.GetAuthorizedUser()
switch {
case err == nil:
// We got a valid user, continue check below
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
// That user is none of our editors: Deny access
return nil, time.Time{}, authcache.ErrUnauthorized
}
_, _, expiresAt, err = tc.GetTokenInfo(context.Background())
if err != nil {
return nil, time.Time{}, errors.Wrap(err, "getting token expiry")
}
// Editors have full access: Return module "*"
return []string{"*"}, expiresAt, nil
case errors.As(err, &httpError):
// We either got "forbidden" or we got another error
if httpError.Code == http.StatusUnauthorized {
// That token wasn't valid or not a Twitch token: Unauthorized
return nil, time.Time{}, authcache.ErrUnauthorized
}
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
default:
// Something else went wrong
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
}
}

60
authMiddleware.go Normal file
View file

@ -0,0 +1,60 @@
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
"golang.org/x/crypto/argon2"
)
const (
// OWASP recommendations - 2023-07-07
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
argonHashLen = 16
argonMemory = 46 * 1024
argonSaltLength = 8
argonThreads = 1
argonTime = 1
)
func fillAuthToken(token *configAuthToken) error {
token.Token = uuid.Must(uuid.NewV4()).String()
salt := make([]byte, argonSaltLength)
if _, err := rand.Read(salt); err != nil {
return errors.Wrap(err, "reading salt")
}
token.Hash = fmt.Sprintf(
argonFmt,
argon2.Version,
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)),
)
return nil
}
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 == "" {
http.Error(w, "auth not successful", http.StatusForbidden)
return
}
err := authService.ValidateTokenFor(token, module)
if err != nil {
http.Error(w, "auth not successful", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}

67
cli_migrateDatabase.go Normal file
View file

@ -0,0 +1,67 @@
package main
import (
"sync"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
var (
dbCopyFuncs = map[string]plugins.DatabaseCopyFunc{}
dbCopyFuncsLock sync.Mutex
)
func init() {
cli.Add(cliRegistryEntry{
Name: "copy-database",
Description: "Copies database contents to a new storage DSN i.e. for migrating to a new DBMS",
Params: []string{"<target storage-type>", "<target DSN>"},
Run: func(args []string) error {
if len(args) < 3 { //nolint:gomnd // Just a count of parameters
return errors.New("Usage: twitch-bot copy-database <target storage-type> <target DSN>")
}
// Core functions cannot register themselves, we take that for them
registerDatabaseCopyFunc("core-values", db.CopyDatabase)
registerDatabaseCopyFunc("permissions", accessService.CopyDatabase)
registerDatabaseCopyFunc("timers", timerService.CopyDatabase)
targetDB, err := database.New(args[1], args[2], cfg.StorageEncryptionPass)
if err != nil {
return errors.Wrap(err, "connecting to target db")
}
defer func() {
if err := targetDB.Close(); err != nil {
logrus.WithError(err).Error("closing connection to target db")
}
}()
return errors.Wrap(
targetDB.DB().Transaction(func(tx *gorm.DB) (err error) {
for name, dbcf := range dbCopyFuncs {
logrus.WithField("name", name).Info("running migration")
if err = dbcf(db.DB(), tx); err != nil {
return errors.Wrapf(err, "running DatabaseCopyFunc %q", name)
}
}
logrus.Info("database has been copied successfully")
return nil
}),
"copying database to target",
)
},
})
}
func registerDatabaseCopyFunc(name string, fn plugins.DatabaseCopyFunc) {
dbCopyFuncsLock.Lock()
defer dbCopyFuncsLock.Unlock()
dbCopyFuncs[name] = fn
}

View file

@ -233,6 +233,19 @@ Uses link- and clip-scanner to detect links / clips and applies link protection
stop_on_no_action: false stop_on_no_action: false
``` ```
## Enter User to Raffle
Enter user to raffle through channelpoints
```yaml
- type: enter-raffle
attributes:
# The keyword for the active raffle to enter the user into
# Optional: false
# Type: string
keyword: ""
```
## Execute Script / Command ## Execute Script / Command
Execute external script / command Execute external script / command

View file

@ -2,6 +2,10 @@
title: "Rule Examples" title: "Rule Examples"
--- ---
{{< lead >}}
These are only a few examples of rules. If you want to share your own rules and show what can be achieved with the bot, head over to the ["Share your Rules"](https://github.com/Luzifer/twitch-bot/discussions/categories/share-your-rules) discussion board and create a new discussion with the YAML definition and a description what your rule does.
{{< /lead >}}
## Chat-addable generic text-respond-commands ## Chat-addable generic text-respond-commands
```yaml ```yaml
@ -55,6 +59,28 @@ title: "Rule Examples"
- moderator - moderator
``` ```
## Display Stream-Schedule in Chat
```yaml
- actions:
- type: respond
attributes:
message: |-
{{- $segs := scheduleSegments .channel 3 -}}
{{- $fmtSegs := list -}}
{{- range $segs -}}
{{- $fmtSegs = mustAppend $fmtSegs (
printf "%s @ %s"
(.Category.Name)
(dateInZone "02.01. 15:40" .StartTime "Europe/Berlin")
) -}}
{{- end -}}
Next streams are: {{ $fmtSegs | join ", " }}
- See more in the Twitch schedule:
https://www.twitch.tv/{{ fixUsername .channel }}/schedule
match_message: '!schedule\b'
```
## Game death counter with dynamic name ## Game death counter with dynamic name
```yaml ```yaml

View file

@ -418,6 +418,19 @@ Example:
* Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none * Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none
``` ```
### `scheduleSegments`
Returns the next n segments in the channels schedule. If n is not given, returns all known segments.
Syntax: `scheduleSegments <channel> [n]`
Example:
```
# {{ $seg := scheduleSegments "luziferus" 1 | first }}Next Stream: {{ $seg.Title }} @ {{ dateInZone "2006-01-02 15:04" $seg.StartTime "Europe/Berlin" }}
* Next Stream: Little Nightmares @ 2023-11-05 18:00
```
### `seededRandom` ### `seededRandom`
Returns a float value stable for the given seed Returns a float value stable for the given seed
@ -428,7 +441,7 @@ Example:
``` ```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% # Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 37% < Your int this hour: 73%
``` ```
### `streamUptime` ### `streamUptime`

View file

@ -18,7 +18,7 @@ The screenshot above shows one draft of a raffle together with one currently act
You can access the entrants list through the "group of people" button in the raffle overview. This becomes available as soon as the raffle has started. You can access the entrants list through the "group of people" button in the raffle overview. This becomes available as soon as the raffle has started.
In this list you can see the status, the nickname and the time of entry for each entrant. The status will be a person (<i class="fas fa-user"></i>) for someone joined through the **Everyone** allowance, a heart (<i class="fas fa-heart"></i>) for a follower, a star (<i class="fas fa-star"></i>) for a subscriber and a diamond (<i class="fas fa-gem"></i>) for a VIP. The list will update itself when there are changes in the entree-list. In this list you can see the status, the nickname and the time of entry for each entrant. The status will be a person (<i class="fas fa-user"></i>) for someone joined through the **Everyone** allowance, a heart (<i class="fas fa-heart"></i>) for a follower, a star (<i class="fas fa-star"></i>) for a subscriber, a diamond (<i class="fas fa-gem"></i>) for a VIP and a coin (<i class="fas fa-coins"></i>) for someone who joined through a channel-point redeem. The list will update itself when there are changes in the entree-list.
![]({{< static "raffle-entrants-closed.png" >}}) ![]({{< static "raffle-entrants-closed.png" >}})
@ -64,3 +64,23 @@ The texts do support templating and do have the same format like other templates
- **Message on raffle close** will be posted when the raffle closes (either you closed it manually or the **Close At** time is reached). - **Message on raffle close** will be posted when the raffle closes (either you closed it manually or the **Close At** time is reached).
Within the templates you do have access to the variables `.user` and `.raffle` (which represents the raffle object). Have a look at the default templates for examples what you can do with them. Within the templates you do have access to the variables `.user` and `.raffle` (which represents the raffle object). Have a look at the default templates for examples what you can do with them.
## Using Channel-Point Rewards to join
To create a raffle to be entered through channel-point rewards you'll do the basic setup of your raffle as usual but you'll do some special adjustments:
- Set the raffle **Keyword** to something no user will ever use in chat (must be one word, can be a bunch of random characters), if a user can guess this, they can enter without using the channel points
- Doesn't matter what you select for **Allowed Entries** (the channel-point actor will ignore that setting)
- Ensure no text contains the `{{ .raffle.Keyword }}` template directive (you don't want to "leak" your keyword)
- Create a Channel-Point reward:
- Name it as you like (but make the name unique among all your rewards as we will use that to determine whether to trigger the rule), set the points to the amount of channel points you like, put limits on it as you like
- You can enable "Skip Queue" but in that case points will be lost when no raffle is active or if any user redeems it more than once per raffle, if you don't set this you can refund the points manually but also you need to mark all raffle entries completed manually.
- Create a new rule:
- Channel: Limit to your channel
- Event: `channelpoint_redeem`
- Disable on template: `{{ ne .reward_title "<the name you chose for the reward>" }}`
- Action: **Enter User to Raffle**, for the keyword enter the same as in the raffle
When an user redeems that reward, the rule will be triggered and if a raffle is active with that keyword, the user will be entered into that raffle as if they triggered the keyword themselves.
**Tip:** If no raffle is active disable / pause the reward to prevent users to waste points on it while there is no raffle active.

47
go.mod
View file

@ -4,18 +4,18 @@ go 1.21
require ( require (
github.com/Luzifer/go-openssl/v4 v4.2.1 github.com/Luzifer/go-openssl/v4 v4.2.1
github.com/Luzifer/go_helpers/v2 v2.20.1 github.com/Luzifer/go_helpers/v2 v2.22.0
github.com/Luzifer/korvike/functions v0.11.0 github.com/Luzifer/korvike/functions v0.11.0
github.com/Luzifer/rconfig/v2 v2.4.0 github.com/Luzifer/rconfig/v2 v2.4.0
github.com/Masterminds/sprig/v3 v3.2.3 github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.25.0
github.com/glebarez/sqlite v1.9.0 github.com/glebarez/sqlite v1.10.0
github.com/go-git/go-git/v5 v5.9.0 github.com/go-git/go-git/v5 v5.10.1
github.com/go-sql-driver/mysql v1.7.1 github.com/go-sql-driver/mysql v1.7.1
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2 github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.1
github.com/itchyny/gojq v0.12.13 github.com/itchyny/gojq v0.12.13
github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/orandin/sentrus v1.0.0 github.com/orandin/sentrus v1.0.0
@ -24,11 +24,11 @@ require (
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.14.0 golang.org/x/crypto v0.16.0
gopkg.in/irc.v4 v4.0.0 gopkg.in/irc.v4 v4.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.5.3 gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
) )
@ -38,9 +38,8 @@ require (
github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/cloudflare/circl v1.3.5 // indirect github.com/cloudflare/circl v1.3.6 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@ -49,18 +48,18 @@ require (
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.1 // indirect github.com/google/uuid v1.4.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.4.0 // indirect github.com/hashicorp/go-hclog v1.4.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.5 // indirect github.com/hashicorp/go-sockaddr v1.0.6 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/vault/api v1.10.0 // indirect github.com/hashicorp/vault/api v1.10.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect github.com/huandu/xstrings v1.4.0 // indirect
@ -68,7 +67,8 @@ require (
github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackc/pgx/v5 v5.5.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@ -89,17 +89,18 @@ require (
github.com/spf13/cast v1.5.1 // indirect github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.13.0 // indirect golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.13.0 // indirect golang.org/x/sync v0.5.0 // indirect
golang.org/x/text v0.13.0 // indirect golang.org/x/sys v0.15.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.27.0 // indirect modernc.org/libc v1.34.11 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.26.0 // indirect modernc.org/sqlite v1.27.0 // indirect
) )

112
go.sum
View file

@ -4,8 +4,8 @@ dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Luzifer/go-openssl/v4 v4.2.1 h1:0+/gaQ5TcBhGmVqGrfyA21eujlbbaNwj0VlOA3nh4ts= github.com/Luzifer/go-openssl/v4 v4.2.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.1/go.mod h1:CZZZWY0buCtkxrkqDPQYigC4Kn55UuO97TEoV+hwz2s=
github.com/Luzifer/go_helpers/v2 v2.20.1 h1:VAp2J8g31X30Xr8/eVV1Xx993MO0tQx9YwNwab6ouB4= github.com/Luzifer/go_helpers/v2 v2.22.0 h1:rJrZkJDzAiq4J0RUbwPI7kQ5rUy7BYQ/GUpo3fSM0y0=
github.com/Luzifer/go_helpers/v2 v2.20.1/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA= github.com/Luzifer/go_helpers/v2 v2.22.0/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA=
github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q= github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q=
github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU= github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o= github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o=
@ -22,8 +22,6 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= github.com/ProtonMail/go-crypto 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 v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -36,8 +34,8 @@ github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTx
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.5 h1:g+wWynZqVALYAlpSQFAa7TscDnUK8mKYtrxMpw6AUKo= github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
github.com/cloudflare/circl v1.3.5/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -60,8 +58,8 @@ github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= 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 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@ -70,12 +68,12 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= 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.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= 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.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY= github.com/go-git/go-git/v5 v5.10.1 h1:tu8/D8i+TWxgKpzQ3Vc43e+kkhXqtsZCKI/egajKnxk=
github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0= github.com/go-git/go-git/v5 v5.10.1/go.mod h1:uEuHjxkHap8kAl//V5F/nNWwqIYtP/402ddd05mp0wg=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
@ -96,17 +94,17 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -125,19 +123,18 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
@ -165,8 +162,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
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= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -182,8 +181,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
@ -206,7 +203,6 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
@ -281,16 +277,16 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -303,8 +299,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -313,8 +309,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -338,15 +334,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -355,12 +351,12 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -369,8 +365,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@ -401,18 +397,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.5.3 h1:qKGY5CPHOuj47K/VxbCXJfFvIUeqMSXXadqdCY+MbBU= gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.3/go.mod h1:F+LtvlFhZT7UBiA81mC9W6Su3D4WUhSboc/36QZU0gk= gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.27.0 h1:Z35IJO5v46n+d1RWRzCD3CiMYYc9TotabBDl75kRmdo= modernc.org/libc v1.34.11 h1:hQDcIUlSG4QAOkXCIQKkaAOV5ptXvkOx4ddbXzgW2JU=
modernc.org/libc v1.27.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ= modernc.org/libc v1.34.11/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw= modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=

View file

@ -9,6 +9,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -28,6 +29,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("counter", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &Counter{})
})
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor("counter", func() plugins.Actor { return &ActorCounter{} }) args.RegisterActor("counter", func() plugins.Actor { return &ActorCounter{} })

View file

@ -5,6 +5,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
) )
@ -18,17 +19,16 @@ type (
func GetCounterValue(db database.Connector, counterName string) (int64, error) { func GetCounterValue(db database.Connector, counterName string) (int64, error) {
var c Counter var c Counter
err := db.DB().First(&c, "name = ?", counterName).Error err := helpers.Retry(func() error {
switch { err := db.DB().First(&c, "name = ?", counterName).Error
case err == nil: if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Value, nil return nil
}
case errors.Is(err, gorm.ErrRecordNotFound): return err
return 0, nil })
default: return c.Value, errors.Wrap(err, "querying counter")
return 0, errors.Wrap(err, "querying counter")
}
} }
func UpdateCounter(db database.Connector, counterName string, value int64, absolute bool) error { func UpdateCounter(db database.Connector, counterName string, value int64, absolute bool) error {
@ -42,10 +42,12 @@ func UpdateCounter(db database.Connector, counterName string, value int64, absol
} }
return errors.Wrap( return errors.Wrap(
db.DB().Clauses(clause.OnConflict{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "name"}}, return tx.Clauses(clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"value"}), Columns: []clause.Column{{Name: "name"}},
}).Create(Counter{Name: counterName, Value: value}).Error, DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(Counter{Name: counterName, Value: value}).Error
}),
"storing counter value", "storing counter value",
) )
} }
@ -53,11 +55,12 @@ func UpdateCounter(db database.Connector, counterName string, value int64, absol
func getCounterRank(db database.Connector, prefix, name string) (rank, count int64, err error) { func getCounterRank(db database.Connector, prefix, name string) (rank, count int64, err error) {
var cc []Counter var cc []Counter
err = db.DB(). if err = helpers.Retry(func() error {
Order("value DESC"). return db.DB().
Find(&cc, "name LIKE ?", prefix+"%"). Order("value DESC").
Error Find(&cc, "name LIKE ?", prefix+"%").
if err != nil { Error
}); err != nil {
return 0, 0, errors.Wrap(err, "querying counters") return 0, 0, errors.Wrap(err, "querying counters")
} }
@ -74,11 +77,13 @@ func getCounterRank(db database.Connector, prefix, name string) (rank, count int
func getCounterTopList(db database.Connector, prefix string, n int) ([]Counter, error) { func getCounterTopList(db database.Connector, prefix string, n int) ([]Counter, error) {
var cc []Counter var cc []Counter
err := db.DB(). err := helpers.Retry(func() error {
Order("value DESC"). return db.DB().
Limit(n). Order("value DESC").
Find(&cc, "name LIKE ?", prefix+"%"). Limit(n).
Error Find(&cc, "name LIKE ?", prefix+"%").
Error
})
return cc, errors.Wrap(err, "querying counters") return cc, errors.Wrap(err, "querying counters")
} }

View file

@ -22,7 +22,7 @@ func Register(args plugins.RegistrationArguments) error {
Fields: []plugins.ActionDocumentationField{ Fields: []plugins.ActionDocumentationField{
{ {
Default: "false", Default: "false",
Description: "Enable heuristic scans to find links with spaces or other means of obfuscation in them", Description: "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!)",
Key: "heuristic", Key: "heuristic",
Name: "Heuristic Scan", Name: "Heuristic Scan",
Optional: true, Optional: true,

View file

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -34,6 +35,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("punish", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &punishLevel{})
})
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage

View file

@ -8,6 +8,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"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/pkg/database"
) )
@ -23,7 +25,7 @@ type (
func calculateCurrentPunishments(db database.Connector) (err error) { func calculateCurrentPunishments(db database.Connector) (err error) {
var ps []punishLevel var ps []punishLevel
if err = db.DB().Find(&ps).Error; err != nil { if err = helpers.Retry(func() error { return db.DB().Find(&ps).Error }); err != nil {
return errors.Wrap(err, "querying punish_levels") return errors.Wrap(err, "querying punish_levels")
} }
@ -72,7 +74,9 @@ func deletePunishment(db database.Connector, channel, user, uuid string) error {
func deletePunishmentForKey(db database.Connector, key string) error { func deletePunishmentForKey(db database.Connector, key string) error {
return errors.Wrap( return errors.Wrap(
db.DB().Delete(&punishLevel{}, "key = ?", key).Error, helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&punishLevel{}, "key = ?", key).Error
}),
"deleting punishment info", "deleting punishment info",
) )
} }
@ -87,7 +91,13 @@ func getPunishment(db database.Connector, channel, user, uuid string) (*levelCon
p punishLevel p punishLevel
) )
err := db.DB().First(&p, "key = ?", getDBKey(channel, user, uuid)).Error 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 err
})
switch { switch {
case err == nil: case err == nil:
return &levelConfig{ return &levelConfig{
@ -114,15 +124,17 @@ func setPunishmentForKey(db database.Connector, key string, lc *levelConfig) err
} }
return errors.Wrap( return errors.Wrap(
db.DB().Clauses(clause.OnConflict{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "key"}}, return tx.Clauses(clause.OnConflict{
UpdateAll: true, Columns: []clause.Column{{Name: "key"}},
}).Create(punishLevel{ UpdateAll: true,
Key: key, }).Create(punishLevel{
LastLevel: lc.LastLevel, Key: key,
Executed: lc.Executed, LastLevel: lc.LastLevel,
Cooldown: lc.Cooldown, Executed: lc.Executed,
}).Error, Cooldown: lc.Cooldown,
}).Error
}),
"updating punishment info", "updating punishment info",
) )
} }

View file

@ -5,6 +5,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -30,6 +31,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("quote", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &quote{})
})
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage send = args.SendMessage

View file

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
) )
@ -20,11 +21,13 @@ type (
func AddQuote(db database.Connector, channel, quoteStr string) error { func AddQuote(db database.Connector, channel, quoteStr string) error {
return errors.Wrap( return errors.Wrap(
db.DB().Create(quote{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Channel: channel, return tx.Create(quote{
CreatedAt: time.Now().UnixNano(), Channel: channel,
Quote: quoteStr, CreatedAt: time.Now().UnixNano(),
}).Error, Quote: quoteStr,
}).Error
}),
"adding quote to database", "adding quote to database",
) )
} }
@ -36,14 +39,18 @@ func DelQuote(db database.Connector, channel string, quoteIdx int) error {
} }
return errors.Wrap( return errors.Wrap(
db.DB().Delete(&quote{}, "channel = ? AND created_at = ?", channel, createdAt).Error, helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&quote{}, "channel = ? AND created_at = ?", channel, createdAt).Error
}),
"deleting quote", "deleting quote",
) )
} }
func GetChannelQuotes(db database.Connector, channel string) ([]string, error) { func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
var qs []quote var qs []quote
if err := db.DB().Where("channel = ?", channel).Order("created_at").Find(&qs).Error; err != nil { if err := helpers.Retry(func() error {
return db.DB().Where("channel = ?", channel).Order("created_at").Find(&qs).Error
}); err != nil {
return nil, errors.Wrap(err, "querying quotes") return nil, errors.Wrap(err, "querying quotes")
} }
@ -57,11 +64,13 @@ func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) { func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
var count int64 var count int64
if err := db.DB(). if err := helpers.Retry(func() error {
Model(&quote{}). return db.DB().
Where("channel = ?", channel). Model(&quote{}).
Count(&count). Where("channel = ?", channel).
Error; err != nil { Count(&count).
Error
}); err != nil {
return 0, errors.Wrap(err, "getting quote count") return 0, errors.Wrap(err, "getting quote count")
} }
@ -83,11 +92,13 @@ func GetQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int6
} }
var q quote var q quote
err := db.DB(). err := helpers.Retry(func() error {
Where("channel = ?", channel). return db.DB().
Limit(1). Where("channel = ?", channel).
Offset(quoteIdx - 1). Limit(1).
First(&q).Error Offset(quoteIdx - 1).
First(&q).Error
})
switch { switch {
case err == nil: case err == nil:
@ -103,7 +114,7 @@ func GetQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int6
func SetQuotes(db database.Connector, channel string, quotes []string) error { func SetQuotes(db database.Connector, channel string, quotes []string) error {
return errors.Wrap( return errors.Wrap(
db.DB().Transaction(func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
if err := tx.Where("channel = ?", channel).Delete(&quote{}).Error; err != nil { if err := tx.Where("channel = ?", channel).Delete(&quote{}).Error; err != nil {
return errors.Wrap(err, "deleting quotes for channel") return errors.Wrap(err, "deleting quotes for channel")
} }
@ -134,10 +145,11 @@ func UpdateQuote(db database.Connector, channel string, idx int, quoteStr string
} }
return errors.Wrap( return errors.Wrap(
db.DB(). helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Where("channel = ? AND created_at = ?", channel, createdAt). return tx.Where("channel = ? AND created_at = ?", channel, createdAt).
Update("quote", quoteStr). Update("quote", quoteStr).
Error, Error
}),
"updating quote", "updating quote",
) )
} }

View file

@ -7,6 +7,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -27,6 +28,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("variable", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &variable{})
})
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor("setvariable", func() plugins.Actor { return &ActorSetVariable{} }) args.RegisterActor("setvariable", func() plugins.Actor { return &ActorSetVariable{} })

View file

@ -5,6 +5,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"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/pkg/database"
) )
@ -17,7 +19,13 @@ type (
func GetVariable(db database.Connector, key string) (string, error) { func GetVariable(db database.Connector, key string) (string, error) {
var v variable var v variable
err := db.DB().First(&v, "name = ?", key).Error err := helpers.Retry(func() error {
err := db.DB().First(&v, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return backoff.NewErrCannotRetry(err)
}
return err
})
switch { switch {
case err == nil: case err == nil:
return v.Value, nil return v.Value, nil
@ -32,17 +40,21 @@ func GetVariable(db database.Connector, key string) (string, error) {
func SetVariable(db database.Connector, key, value string) error { func SetVariable(db database.Connector, key, value string) error {
return errors.Wrap( return errors.Wrap(
db.DB().Clauses(clause.OnConflict{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "name"}}, return tx.Clauses(clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"value"}), Columns: []clause.Column{{Name: "name"}},
}).Create(variable{Name: key, Value: value}).Error, DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(variable{Name: key, Value: value}).Error
}),
"updating value in database", "updating value in database",
) )
} }
func RemoveVariable(db database.Connector, key string) error { func RemoveVariable(db database.Connector, key string) error {
return errors.Wrap( return errors.Wrap(
db.DB().Delete(&variable{}, "name = ?", key).Error, helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&variable{}, "name = ?", key).Error
}),
"deleting value in database", "deleting value in database",
) )
} }

View file

@ -10,6 +10,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -32,6 +33,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("custom_event", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &storedCustomEvent{})
})
mc = &memoryCache{dbc: db} mc = &memoryCache{dbc: db}
eventCreatorFunc = args.CreateEvent eventCreatorFunc = args.CreateEvent

View file

@ -7,7 +7,9 @@ import (
"github.com/gofrs/uuid/v3" "github.com/gofrs/uuid/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -25,20 +27,23 @@ type (
func cleanupStoredEvents(db database.Connector) error { func cleanupStoredEvents(db database.Connector) error {
return errors.Wrap( return errors.Wrap(
db.DB(). helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Where("scheduled_at < ?", time.Now().Add(cleanupTimeout*-1).UTC()). return tx.Where("scheduled_at < ?", time.Now().Add(cleanupTimeout*-1).UTC()).
Delete(&storedCustomEvent{}). Delete(&storedCustomEvent{}).
Error, Error
}),
"deleting past events", "deleting past events",
) )
} }
func getFutureEvents(db database.Connector) (out []storedCustomEvent, err error) { func getFutureEvents(db database.Connector) (out []storedCustomEvent, err error) {
return out, errors.Wrap( return out, errors.Wrap(
db.DB(). helpers.Retry(func() error {
Where("scheduled_at >= ?", time.Now().UTC()). return db.DB().
Find(&out). Where("scheduled_at >= ?", time.Now().UTC()).
Error, Find(&out).
Error
}),
"getting events from database", "getting events from database",
) )
} }
@ -50,12 +55,14 @@ func storeEvent(db database.Connector, scheduleAt time.Time, channel string, fie
} }
return errors.Wrap( return errors.Wrap(
db.DB().Create(storedCustomEvent{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
ID: uuid.Must(uuid.NewV4()).String(), return tx.Create(storedCustomEvent{
Channel: channel, ID: uuid.Must(uuid.NewV4()).String(),
Fields: fieldBuf.String(), Channel: channel,
ScheduledAt: scheduleAt, Fields: fieldBuf.String(),
}).Error, ScheduledAt: scheduleAt,
}).Error
}),
"storing event", "storing event",
) )
} }

View file

@ -7,13 +7,16 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
type ( type (
overlaysEvent struct { overlaysEvent struct {
ID uint64 `gorm:"primaryKey"`
Channel string `gorm:"not null;index:overlays_events_sort_idx"` Channel string `gorm:"not null;index:overlays_events_sort_idx"`
CreatedAt time.Time `gorm:"index:overlays_events_sort_idx"` CreatedAt time.Time `gorm:"index:overlays_events_sort_idx"`
EventType string EventType string
@ -28,12 +31,14 @@ func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) e
} }
return errors.Wrap( return errors.Wrap(
db.DB().Create(overlaysEvent{ helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
Channel: channel, return tx.Create(&overlaysEvent{
CreatedAt: evt.Time.UTC(), Channel: channel,
EventType: evt.Type, CreatedAt: evt.Time.UTC(),
Fields: strings.TrimSpace(buf.String()), EventType: evt.Type,
}).Error, Fields: strings.TrimSpace(buf.String()),
}).Error
}),
"storing event to database", "storing event to database",
) )
} }
@ -41,7 +46,9 @@ func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) e
func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, error) { func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, error) {
var evts []overlaysEvent var evts []overlaysEvent
if err := db.DB().Where("channel = ?", channel).Order("created_at").Find(&evts).Error; err != nil { if err := helpers.Retry(func() error {
return db.DB().Where("channel = ?", channel).Order("created_at").Find(&evts).Error
}); err != nil {
return nil, errors.Wrap(err, "querying channel events") return nil, errors.Wrap(err, "querying channel events")
} }

View file

@ -15,6 +15,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
@ -69,6 +70,10 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("overlay_events", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &overlaysEvent{})
})
validateToken = args.ValidateToken validateToken = args.ValidateToken
args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{ args.RegisterAPIRoute(plugins.HTTPRouteRegistrationArgs{

View file

@ -0,0 +1,80 @@
package raffle
import (
"time"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
)
type (
enterRaffleActor struct{}
)
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) {
if m != nil || evtData.MustString("reward_id", ptrStrEmpty) == "" {
return false, errors.New("enter-raffle actor is only supposed to act on channelpoint redeems")
}
r, err := dbc.GetByChannelAndKeyword(
evtData.MustString("channel", ptrStrEmpty),
attrs.MustString("keyword", ptrStrEmpty),
)
if err != nil {
if errors.Is(err, errRaffleNotFound) {
// We don't need to care, that was no raffle input
return false, errors.Errorf("specified keyword %q does not belong to active raffle", attrs.MustString("keyword", ptrStrEmpty))
}
return false, errors.Wrap(err, "fetching raffle")
}
re := raffleEntry{
EnteredAs: "reward",
RaffleID: r.ID,
UserID: evtData.MustString("user_id", ptrStrEmpty),
UserLogin: evtData.MustString("user", ptrStrEmpty),
UserDisplayName: evtData.MustString("user", ptrStrEmpty),
EnteredAt: time.Now().UTC(),
}
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{
"user_id": re.UserID,
"user": re.UserLogin,
})
// We have everything we need to create an entry
if err = dbc.Enter(re); err != nil {
logrus.WithFields(logrus.Fields{
"raffle": r.ID,
"user_id": re.UserID,
"user": re.UserLogin,
}).WithError(err).Error("creating raffle entry")
return false, errors.Wrap(
r.SendEvent(raffleMessageEventEntryFailed, raffleEventFields),
"sending entry-failed chat message",
)
}
return false, errors.Wrap(
r.SendEvent(raffleMessageEventEntry, raffleEventFields),
"sending entry chat message",
)
}
func (a enterRaffleActor) IsAsync() bool { return false }
func (a enterRaffleActor) Name() string { return "enter-raffle" }
func (a 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")
}
return nil
}

View file

@ -15,6 +15,8 @@ import (
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const moduleName = "raffle"
var apiRoutes = []plugins.HTTPRouteRegistrationArgs{ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
{ {
Description: "Lists all raffles known to the bot", Description: "Lists all raffles known to the bot",
@ -23,7 +25,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return ras, errors.Wrap(err, "fetching raffles from database") return ras, errors.Wrap(err, "fetching raffles from database")
}, nil), }, nil),
Method: http.MethodGet, Method: http.MethodGet,
Module: actorName, Module: moduleName,
Name: "List Raffles", Name: "List Raffles",
Path: "/", Path: "/",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -41,7 +43,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Create(ra), "creating raffle") return nil, errors.Wrap(dbc.Create(ra), "creating raffle")
}, nil), }, nil),
Method: http.MethodPost, Method: http.MethodPost,
Module: actorName, Module: moduleName,
Name: "Create Raffle", Name: "Create Raffle",
Path: "/", Path: "/",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -54,7 +56,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Delete(ids["id"]), "fetching raffle from database") return nil, errors.Wrap(dbc.Delete(ids["id"]), "fetching raffle from database")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodDelete, Method: http.MethodDelete,
Module: actorName, Module: moduleName,
Name: "Delete Raffle", Name: "Delete Raffle",
Path: "/{id}", Path: "/{id}",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -74,7 +76,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return ra, errors.Wrap(err, "fetching raffle from database") return ra, errors.Wrap(err, "fetching raffle from database")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodGet, Method: http.MethodGet,
Module: actorName, Module: moduleName,
Name: "Get Raffle", Name: "Get Raffle",
Path: "/{id}", Path: "/{id}",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -102,7 +104,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Update(ra), "updating raffle") return nil, errors.Wrap(dbc.Update(ra), "updating raffle")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Update Raffle", Name: "Update Raffle",
Path: "/{id}", Path: "/{id}",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -121,7 +123,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Clone(ids["id"]), "cloning raffle") return nil, errors.Wrap(dbc.Clone(ids["id"]), "cloning raffle")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Clone Raffle", Name: "Clone Raffle",
Path: "/{id}/clone", Path: "/{id}/clone",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -140,7 +142,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Close(ids["id"]), "closing raffle") return nil, errors.Wrap(dbc.Close(ids["id"]), "closing raffle")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Close Raffle", Name: "Close Raffle",
Path: "/{id}/close", Path: "/{id}/close",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -159,7 +161,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.PickWinner(ids["id"]), "picking winner") return nil, errors.Wrap(dbc.PickWinner(ids["id"]), "picking winner")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Pick Raffle Winner", Name: "Pick Raffle Winner",
Path: "/{id}/pick", Path: "/{id}/pick",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -183,7 +185,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Reopen(ids["id"], time.Duration(dur)*time.Second), "reopening raffle") return nil, errors.Wrap(dbc.Reopen(ids["id"], time.Duration(dur)*time.Second), "reopening raffle")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Reopen Raffle", Name: "Reopen Raffle",
Path: "/{id}/reopen", Path: "/{id}/reopen",
QueryParams: []plugins.HTTPRouteParamDocumentation{ QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -191,7 +193,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
Description: "Number of seconds to leave the raffle open", Description: "Number of seconds to leave the raffle open",
Name: "duration", Name: "duration",
Required: true, Required: true,
Type: "integer", Type: "int",
}, },
}, },
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -210,7 +212,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Start(ids["id"]), "starting raffle") return nil, errors.Wrap(dbc.Start(ids["id"]), "starting raffle")
}, []string{"id"}), }, []string{"id"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Start Raffle", Name: "Start Raffle",
Path: "/{id}/start", Path: "/{id}/start",
RequiresWriteAuth: true, RequiresWriteAuth: true,
@ -229,7 +231,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.RedrawWinner(ids["id"], ids["winner"]), "re-picking winner") return nil, errors.Wrap(dbc.RedrawWinner(ids["id"], ids["winner"]), "re-picking winner")
}, []string{"id", "winner"}), }, []string{"id", "winner"}),
Method: http.MethodPut, Method: http.MethodPut,
Module: actorName, Module: moduleName,
Name: "Re-Pick Raffle Winner", Name: "Re-Pick Raffle Winner",
Path: "/{id}/repick/{winner}", Path: "/{id}/repick/{winner}",
RequiresWriteAuth: true, RequiresWriteAuth: true,

View file

@ -7,7 +7,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -121,10 +123,12 @@ func newDBClient(db database.Connector) *dbClient {
func (d *dbClient) AutoCloseExpired() (err error) { func (d *dbClient) AutoCloseExpired() (err error) {
var rr []raffle var rr []raffle
if err = d.db.DB(). if err = helpers.Retry(func() error {
Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()). return d.db.DB().
Find(&rr). Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()).
Error; err != nil { Find(&rr).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffles to close") return errors.Wrap(err, "fetching raffles to close")
} }
@ -142,10 +146,12 @@ func (d *dbClient) AutoCloseExpired() (err error) {
func (d *dbClient) AutoSendReminders() (err error) { func (d *dbClient) AutoSendReminders() (err error) {
var rr []raffle var rr []raffle
if err = d.db.DB(). if err = helpers.Retry(func() error {
Where("status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)", raffleStatusActive, true, time.Now().UTC()). return d.db.DB().
Find(&rr). Where("status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)", raffleStatusActive, true, time.Now().UTC()).
Error; err != nil { Find(&rr).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffles to send reminders") return errors.Wrap(err, "fetching raffles to send reminders")
} }
@ -162,10 +168,12 @@ func (d *dbClient) AutoSendReminders() (err error) {
func (d *dbClient) AutoStart() (err error) { func (d *dbClient) AutoStart() (err error) {
var rr []raffle var rr []raffle
if err = d.db.DB(). if err = helpers.Retry(func() error {
Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()). return d.db.DB().
Find(&rr). Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()).
Error; err != nil { Find(&rr).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffles to start") return errors.Wrap(err, "fetching raffles to start")
} }
@ -208,10 +216,12 @@ func (d *dbClient) Close(raffleID uint64) error {
return errors.Wrap(err, "getting raffle") return errors.Wrap(err, "getting raffle")
} }
if err = d.db.DB().Model(&raffle{}). if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Where("id = ?", raffleID). return tx.Model(&raffle{}).
Update("status", raffleStatusEnded). Where("id = ?", raffleID).
Error; err != nil { Update("status", raffleStatusEnded).
Error
}); err != nil {
return errors.Wrap(err, "setting status closed") return errors.Wrap(err, "setting status closed")
} }
@ -231,7 +241,9 @@ func (d *dbClient) Close(raffleID uint64) error {
// the database without modification and therefore need to be filled // the database without modification and therefore need to be filled
// before calling this function // before calling this function
func (d *dbClient) Create(r raffle) error { func (d *dbClient) Create(r raffle) error {
if err := d.db.DB().Create(&r).Error; err != nil { if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Create(&r).Error
}); err != nil {
return errors.Wrap(err, "creating database record") return errors.Wrap(err, "creating database record")
} }
@ -242,17 +254,23 @@ func (d *dbClient) Create(r raffle) error {
// Delete removes all entries for the given raffle and afterwards // Delete removes all entries for the given raffle and afterwards
// deletes the raffle itself // deletes the raffle itself
func (d *dbClient) Delete(raffleID uint64) (err error) { func (d *dbClient) Delete(raffleID uint64) (err error) {
if err = d.db.DB(). if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Where("raffle_id = ?", raffleID). if err = tx.
Delete(&raffleEntry{}). Where("raffle_id = ?", raffleID).
Error; err != nil { Delete(&raffleEntry{}).
return errors.Wrap(err, "deleting raffle entries") Error; err != nil {
} return errors.Wrap(err, "deleting raffle entries")
}
if err = d.db.DB(). if err = tx.
Where("id = ?", raffleID). Where("id = ?", raffleID).
Delete(&raffle{}).Error; err != nil { Delete(&raffle{}).Error; err != nil {
return errors.Wrap(err, "creating database record") return errors.Wrap(err, "creating database record")
}
return nil
}); err != nil {
return errors.Wrap(err, "deleting raffle")
} }
frontendNotify(frontendNotifyEventRaffleChange) frontendNotify(frontendNotifyEventRaffleChange)
@ -263,7 +281,7 @@ func (d *dbClient) Delete(raffleID uint64) (err error) {
// the database without modification and therefore need to be filled // the database without modification and therefore need to be filled
// before calling this function // before calling this function
func (d *dbClient) Enter(re raffleEntry) error { func (d *dbClient) Enter(re raffleEntry) error {
if err := d.db.DB().Create(&re).Error; err != nil { if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error { return tx.Create(&re).Error }); err != nil {
return errors.Wrap(err, "creating database record") return errors.Wrap(err, "creating database record")
} }
@ -274,11 +292,13 @@ func (d *dbClient) Enter(re raffleEntry) error {
// Get retrieves a raffle from the database // Get retrieves a raffle from the database
func (d *dbClient) Get(raffleID uint64) (out raffle, err error) { func (d *dbClient) Get(raffleID uint64) (out raffle, err error) {
return out, errors.Wrap( return out, errors.Wrap(
d.db.DB(). helpers.Retry(func() error {
Where("raffles.id = ?", raffleID). return d.db.DB().
Preload("Entries"). Where("raffles.id = ?", raffleID).
First(&out). Preload("Entries").
Error, First(&out).
Error
}),
"getting raffle from database", "getting raffle from database",
) )
} }
@ -302,10 +322,12 @@ func (d *dbClient) GetByChannelAndKeyword(channel, keyword string) (raffle, erro
// List returns a list of all known raffles // List returns a list of all known raffles
func (d *dbClient) List() (raffles []raffle, _ error) { func (d *dbClient) List() (raffles []raffle, _ error) {
return raffles, errors.Wrap( return raffles, errors.Wrap(
d.db.DB().Model(&raffle{}). helpers.Retry(func() error {
Order("id DESC"). return d.db.DB().Model(&raffle{}).
Find(&raffles). Order("id DESC").
Error, Find(&raffles).
Error
}),
"updating column", "updating column",
) )
} }
@ -314,10 +336,12 @@ func (d *dbClient) List() (raffles []raffle, _ error) {
// sent for the given raffle ID. No other fields are modified // sent for the given raffle ID. No other fields are modified
func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error { func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error {
return errors.Wrap( return errors.Wrap(
d.db.DB().Model(&raffle{}). helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Where("id = ?", raffleID). return tx.Model(&raffle{}).
Update("text_reminder_next_send", next). Where("id = ?", raffleID).
Error, Update("text_reminder_next_send", next).
Error
}),
"updating column", "updating column",
) )
} }
@ -336,10 +360,12 @@ func (d *dbClient) PickWinner(raffleID uint64) error {
} }
speakUpUntil := time.Now().UTC().Add(r.WaitForResponse) speakUpUntil := time.Now().UTC().Add(r.WaitForResponse)
if err = d.db.DB().Model(&raffleEntry{}). if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Where("id = ?", winner.ID). return tx.Model(&raffleEntry{}).
Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}). Where("id = ?", winner.ID).
Error; err != nil { Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}).
Error
}); err != nil {
return errors.Wrap(err, "updating winner") return errors.Wrap(err, "updating winner")
} }
@ -364,10 +390,12 @@ func (d *dbClient) PickWinner(raffleID uint64) error {
// RedrawWinner marks the previous winner as redrawn (and therefore // RedrawWinner marks the previous winner as redrawn (and therefore
// crossed out as winner in the interface) and picks a new one // crossed out as winner in the interface) and picks a new one
func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error { func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error {
if err := d.db.DB().Model(&raffleEntry{}). if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Where("id = ?", winnerID). return tx.Model(&raffleEntry{}).
Update("was_redrawn", true). Where("id = ?", winnerID).
Error; err != nil { Update("was_redrawn", true).
Error
}); err != nil {
return errors.Wrap(err, "updating previous winner") return errors.Wrap(err, "updating previous winner")
} }
@ -385,10 +413,12 @@ func (d *dbClient) RefreshActiveRaffles() error {
tmp = map[string]uint64{} tmp = map[string]uint64{}
) )
if err := d.db.DB(). if err := helpers.Retry(func() error {
Where("status = ?", raffleStatusActive). return d.db.DB().
Find(&actives). Where("status = ?", raffleStatusActive).
Error; err != nil { Find(&actives).
Error
}); err != nil {
return errors.Wrap(err, "fetching active raffles") return errors.Wrap(err, "fetching active raffles")
} }
@ -411,19 +441,23 @@ func (d *dbClient) RefreshSpeakUp() error {
tmp = map[string]*speakUpWait{} tmp = map[string]*speakUpWait{}
) )
if err := d.db.DB().Debug(). if err := helpers.Retry(func() error {
Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()). return d.db.DB().Debug().
Find(&res). Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()).
Error; err != nil { Find(&res).
Error
}); err != nil {
return errors.Wrap(err, "querying active entries") return errors.Wrap(err, "querying active entries")
} }
for _, e := range res { for _, e := range res {
var r raffle var r raffle
if err := d.db.DB(). if err := helpers.Retry(func() error {
Where("id = ?", e.RaffleID). return d.db.DB().
First(&r). Where("id = ?", e.RaffleID).
Error; err != nil { First(&r).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffle for entry") return errors.Wrap(err, "fetching raffle for entry")
} }
tmp[strings.Join([]string{r.Channel, e.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: e.ID, Until: *e.SpeakUpUntil} tmp[strings.Join([]string{r.Channel, e.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: e.ID, Until: *e.SpeakUpUntil}
@ -445,14 +479,15 @@ func (d *dbClient) RegisterSpeakUp(channel, user, message string) error {
return nil return nil
} }
if err := d.db.DB(). if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Model(&raffleEntry{}). return tx.Model(&raffleEntry{}).
Where("id = ?", w.RaffleEntryID). Where("id = ?", w.RaffleEntryID).
Updates(map[string]any{ Updates(map[string]any{
"DrawResponse": message, "DrawResponse": message,
"SpeakUpUntil": nil, "SpeakUpUntil": nil,
}). }).
Error; err != nil { Error
}); err != nil {
return errors.Wrap(err, "registering speak-up") return errors.Wrap(err, "registering speak-up")
} }
@ -472,14 +507,15 @@ func (d *dbClient) Reopen(raffleID uint64, duration time.Duration) error {
return errors.Wrap(err, "getting specified raffle") return errors.Wrap(err, "getting specified raffle")
} }
if err = d.db.DB(). if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Model(&raffle{}). return tx.Model(&raffle{}).
Where("id = ?", raffleID). Where("id = ?", raffleID).
Updates(map[string]any{ Updates(map[string]any{
"CloseAt": time.Now().UTC().Add(duration), "CloseAt": time.Now().UTC().Add(duration),
"status": raffleStatusActive, "status": raffleStatusActive,
}). }).
Error; err != nil { Error
}); err != nil {
return errors.Wrap(err, "updating raffle") return errors.Wrap(err, "updating raffle")
} }
@ -557,11 +593,12 @@ func (d *dbClient) Update(r raffle) error {
r.Entries = nil r.Entries = nil
r.TextReminderNextSend = old.TextReminderNextSend r.TextReminderNextSend = old.TextReminderNextSend
if err := d.db.DB(). if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
Model(&raffle{}). return tx.Model(&raffle{}).
Where("id = ?", r.ID). Where("id = ?", r.ID).
Updates(&r). Updates(&r).
Error; err != nil { Error
}); err != nil {
return errors.Wrap(err, "updating raffle") return errors.Wrap(err, "updating raffle")
} }

View file

@ -5,14 +5,13 @@ package raffle
import ( import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const actorName = "raffle"
var ( var (
db database.Connector db database.Connector
dbc *dbClient dbc *dbClient
@ -28,6 +27,10 @@ func Register(args plugins.RegistrationArguments) (err error) {
return errors.Wrap(err, "applying schema migration") return errors.Wrap(err, "applying schema migration")
} }
args.RegisterCopyDatabaseFunc("raffle", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &raffle{}, &raffleEntry{})
})
dbc = newDBClient(db) dbc = newDBClient(db)
if err = dbc.RefreshActiveRaffles(); err != nil { if err = dbc.RefreshActiveRaffles(); err != nil {
return errors.Wrap(err, "refreshing active raffle cache") return errors.Wrap(err, "refreshing active raffle cache")
@ -53,7 +56,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
} { } {
if err := fn(); err != nil { if err := fn(); err != nil {
logrus.WithFields(logrus.Fields{ logrus.WithFields(logrus.Fields{
"actor": actorName, "actor": moduleName,
"cron": name, "cron": name,
}).WithError(err).Error("executing cron action") }).WithError(err).Error("executing cron action")
} }
@ -66,5 +69,24 @@ func Register(args plugins.RegistrationArguments) (err error) {
return errors.Wrap(err, "registering raw message handler") return errors.Wrap(err, "registering raw message handler")
} }
args.RegisterActor(enterRaffleActor{}.Name(), func() plugins.Actor { return &enterRaffleActor{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Enter user to raffle through channelpoints",
Name: "Enter User to Raffle",
Type: enterRaffleActor{}.Name(),
Fields: []plugins.ActionDocumentationField{
{
Default: "",
Description: "The keyword for the active raffle to enter the user into",
Key: "keyword",
Name: "Keyword",
Optional: false,
SupportTemplate: false,
Type: plugins.ActionDocumentationFieldTypeString,
},
},
})
return nil return nil
} }

25
internal/helpers/retry.go Normal file
View file

@ -0,0 +1,25 @@
package helpers
import (
"github.com/Luzifer/go_helpers/v2/backoff"
"gorm.io/gorm"
)
const (
maxRetries = 5
)
// Retry contains a standard set of configuration parameters for an
// exponential backoff to be used throughout the bot
func Retry(fn func() error) error {
return backoff.NewBackoff().
WithMaxIterations(maxRetries).
Retry(fn)
}
// RetryTransaction takes a database object and a function acting on
// 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) })
}

View file

@ -1,58 +1,35 @@
package linkcheck package linkcheck
import ( import (
"context"
"crypto/rand"
_ "embed"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp" "regexp"
"strings" "strings"
"time" "sync"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
) )
const (
// DefaultCheckTimeout defines the default time the request to a site
// may take to answer
DefaultCheckTimeout = 10 * time.Second
maxRedirects = 50
)
type ( type (
// Checker contains logic to detect and resolve links in a message // Checker contains logic to detect and resolve links in a message
Checker struct { Checker struct {
checkTimeout time.Duration res *resolver
userAgents []string
skipValidation bool // Only for tests, not settable from the outside
} }
) )
var (
defaultUserAgents = []string{}
dropSet = regexp.MustCompile(`[^a-zA-Z0-9.:/\s_-]`)
linkTest = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`)
numericHost = regexp.MustCompile(`^(?:[0-9]+\.)*[0-9]+(?::[0-9]+)?$`)
//go:embed user-agents.txt
uaList string
)
func init() {
defaultUserAgents = strings.Split(strings.TrimSpace(uaList), "\n")
}
// New creates a new Checker instance with default settings // New creates a new Checker instance with default settings
func New() *Checker { func New(opts ...func(*Checker)) *Checker {
return &Checker{ c := &Checker{
checkTimeout: DefaultCheckTimeout, res: defaultResolver,
userAgents: defaultUserAgents,
} }
for _, o := range opts {
o(c)
}
return c
}
func withResolver(r *resolver) func(*Checker) {
return func(c *Checker) { c.res = r }
} }
// HeuristicScanForLinks takes a message and tries to find links // HeuristicScanForLinks takes a message and tries to find links
@ -61,9 +38,10 @@ func New() *Checker {
func (c Checker) HeuristicScanForLinks(message string) []string { func (c Checker) HeuristicScanForLinks(message string) []string {
return c.scan(message, return c.scan(message,
c.scanPlainNoObfuscate, c.scanPlainNoObfuscate,
c.scanObfuscateSpace,
c.scanObfuscateSpecialCharsAndSpaces,
c.scanDotObfuscation, c.scanDotObfuscation,
c.scanObfuscateSpace,
c.scanObfuscateSpecialCharsAndSpaces(regexp.MustCompile(`[^a-zA-Z0-9.:/\s_-]`), ""), // Leave dots intact and just join parts
c.scanObfuscateSpecialCharsAndSpaces(regexp.MustCompile(`[^a-zA-Z0-9:/\s_-]`), "."), // Remove dots also and connect by them
) )
} }
@ -74,117 +52,6 @@ func (c Checker) ScanForLinks(message string) (links []string) {
return c.scan(message, c.scanPlainNoObfuscate) return c.scan(message, c.scanPlainNoObfuscate)
} }
// resolveFinal takes a link and looks up the final destination of
// that link after all redirects were followed
func (c Checker) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack []string, userAgent string) string {
if !linkTest.MatchString(link) && !c.skipValidation {
return ""
}
if str.StringInSlice(link, callStack) || len(callStack) == maxRedirects {
// We got ourselves a loop: Yay!
return link
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Jar: cookieJar,
}
ctx, cancel := context.WithTimeout(context.Background(), c.checkTimeout)
defer cancel()
u, err := url.Parse(link)
if err != nil {
return ""
}
if u.Scheme == "" {
// We have no scheme and the url is in the path, lets add the
// scheme and re-parse the URL to avoid some confusion
u.Scheme = "http"
u, err = url.Parse(u.String())
if err != nil {
return ""
}
}
if numericHost.MatchString(u.Host) && !c.skipValidation {
// Host is fully numeric: We don't support scanning that
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode > 299 && resp.StatusCode < 400 {
// We got a redirect
tu, err := url.Parse(resp.Header.Get("location"))
if err != nil {
return ""
}
target := c.resolveReference(u, tu)
return c.resolveFinal(target, cookieJar, append(callStack, link), userAgent)
}
// We got a response, it's no redirect, we count this as a success
return u.String()
}
func (Checker) resolveReference(origin *url.URL, loc *url.URL) string {
// Special Case: vkontakte used as shortener / obfuscation
if loc.Path == "/away.php" && loc.Query().Has("to") {
// VK is doing HTML / JS redirect magic so we take that from them
// and execute the redirect directly here in code
return loc.Query().Get("to")
}
if loc.Host == "consent.youtube.com" && loc.Query().Has("continue") {
// Youtube links end up in consent page but we want the real
// target so we use the continue parameter where we strip the
// cbrd query parameters as that one causes an infinite loop.
contTarget, err := url.Parse(loc.Query().Get("continue"))
if err == nil {
v := contTarget.Query()
v.Del("cbrd")
contTarget.RawQuery = v.Encode()
return contTarget.String()
}
return loc.Query().Get("continue")
}
if loc.Host == "www.instagram.com" && loc.Query().Has("next") {
// Instagram likes its login page, we on the other side don't
// care about the sign-in or even the content. Therefore we
// just take their redirect target and use that as the next
// URL
return loc.Query().Get("next")
}
// Default fallback behavior: Do a normal resolve
return origin.ResolveReference(loc).String()
}
func (Checker) getJar() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}
func (c Checker) scan(message string, scanFns ...func(string) []string) (links []string) { func (c Checker) scan(message string, scanFns ...func(string) []string) (links []string) {
for _, scanner := range scanFns { for _, scanner := range scanFns {
if links = scanner(message); links != nil { if links = scanner(message); links != nil {
@ -203,37 +70,53 @@ func (c Checker) scanDotObfuscation(message string) (links []string) {
func (c Checker) scanObfuscateSpace(message string) (links []string) { func (c Checker) scanObfuscateSpace(message string) (links []string) {
// Spammers use spaces in their links to prevent link protection matches // Spammers use spaces in their links to prevent link protection matches
parts := regexp.MustCompile(`\s+`).Split(message, -1) parts := regexp.MustCompile(`\s+`).Split(message, -1)
return c.scanPartsConnected(parts, "")
}
func (c Checker) scanObfuscateSpecialCharsAndSpaces(set *regexp.Regexp, connector string) func(string) []string {
return func(message string) (links []string) {
// First clean URL from all characters not acceptable in Domains (plus some extra chars)
message = set.ReplaceAllString(message, " ")
parts := regexp.MustCompile(`\s+`).Split(message, -1)
return c.scanPartsConnected(parts, connector)
}
}
func (c Checker) scanPartsConnected(parts []string, connector string) (links []string) {
wg := new(sync.WaitGroup)
for ptJoin := 2; ptJoin < len(parts); ptJoin++ { for ptJoin := 2; ptJoin < len(parts); ptJoin++ {
for i := 0; i <= len(parts)-ptJoin; i++ { for i := 0; i <= len(parts)-ptJoin; i++ {
if link := c.resolveFinal(strings.Join(parts[i:i+ptJoin], ""), c.getJar(), nil, c.userAgent()); link != "" { wg.Add(1)
links = append(links, link) c.res.Resolve(resolverQueueEntry{
} Link: strings.Join(parts[i:i+ptJoin], connector),
Callback: func(link string) { links = str.AppendIfMissing(links, link) },
WaitGroup: wg,
})
} }
} }
return links wg.Wait()
}
func (c Checker) scanObfuscateSpecialCharsAndSpaces(message string) (links []string) { return links
// First clean URL from all characters not acceptable in Domains (plus some extra chars)
message = dropSet.ReplaceAllString(message, "")
return c.scanObfuscateSpace(message)
} }
func (c Checker) scanPlainNoObfuscate(message string) (links []string) { func (c Checker) scanPlainNoObfuscate(message string) (links []string) {
parts := regexp.MustCompile(`\s+`).Split(message, -1) var (
parts = regexp.MustCompile(`\s+`).Split(message, -1)
wg = new(sync.WaitGroup)
)
for _, part := range parts { for _, part := range parts {
if link := c.resolveFinal(part, c.getJar(), nil, c.userAgent()); link != "" { wg.Add(1)
links = append(links, link) c.res.Resolve(resolverQueueEntry{
} Link: part,
Callback: func(link string) { links = str.AppendIfMissing(links, link) },
WaitGroup: wg,
})
} }
wg.Wait()
return links return links
} }
func (c Checker) userAgent() string {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.userAgents))))
return c.userAgents[n.Int64()]
}

View file

@ -18,13 +18,11 @@ func TestInfiniteRedirect(t *testing.T) {
hdl.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) }) hdl.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) })
var ( var (
c = New() c = New(withResolver(newResolver(1, withSkipVerify())))
ts = httptest.NewServer(hdl) ts = httptest.NewServer(hdl)
) )
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
c.skipValidation = true
msg := fmt.Sprintf("Here have a redirect loop: %s", ts.URL) msg := fmt.Sprintf("Here have a redirect loop: %s", ts.URL)
// We expect /test to be the first repeat as the callstack will look like this: // We expect /test to be the first repeat as the callstack will look like this:
@ -41,13 +39,11 @@ func TestMaxRedirects(t *testing.T) {
}) })
var ( var (
c = New() c = New(withResolver(newResolver(1, withSkipVerify())))
ts = httptest.NewServer(hdl) ts = httptest.NewServer(hdl)
) )
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
c.skipValidation = true
msg := fmt.Sprintf("Here have a redirect loop: %s", ts.URL) msg := fmt.Sprintf("Here have a redirect loop: %s", ts.URL)
// We expect the call to `/N` to have N previous entries and therefore be the break-point // We expect the call to `/N` to have N previous entries and therefore be the break-point
@ -170,9 +166,16 @@ func TestScanForLinks(t *testing.T) {
Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!", Message: "Hey there, see my new project on exa mpl e. com! Get it fast now!",
ExpectedLinks: []string{"http://example.com"}, ExpectedLinks: []string{"http://example.com"},
}, },
// Case: Dot in the end of the link with space
{
Heuristic: true,
Message: "See example com. Nice testing stuff there!",
ExpectedLinks: []string{"http://example.com"},
},
// Case: false positives // Case: false positives
{Heuristic: true, Message: "game dot exe has stopped working", ExpectedLinks: nil}, {Heuristic: true, Message: "game dot exe has stopped working", ExpectedLinks: nil},
{Heuristic: true, Message: "You're following since 12.12.2020 DogChamp", ExpectedLinks: nil}, {Heuristic: false, Message: "You're following since 12.12.2020 DogChamp", ExpectedLinks: nil},
{Heuristic: true, Message: "You're following since 12.12.2020 DogChamp", ExpectedLinks: []string{"http://You.re"}},
{Heuristic: false, Message: "Hey btw. es kann sein, dass", ExpectedLinks: nil}, {Heuristic: false, Message: "Hey btw. es kann sein, dass", ExpectedLinks: nil},
} { } {
t.Run(fmt.Sprintf("h:%v lc:%d m:%s", testCase.Heuristic, len(testCase.ExpectedLinks), testCase.Message), func(t *testing.T) { t.Run(fmt.Sprintf("h:%v lc:%d m:%s", testCase.Heuristic, len(testCase.ExpectedLinks), testCase.Message), func(t *testing.T) {
@ -196,13 +199,10 @@ func TestUserAgentListNotEmpty(t *testing.T) {
} }
func TestUserAgentRandomizer(t *testing.T) { func TestUserAgentRandomizer(t *testing.T) {
var ( uas := map[string]int{}
c = New()
uas = map[string]int{}
)
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
uas[c.userAgent()]++ uas[defaultResolver.userAgent()]++
} }
for _, c := range uas { for _, c := range uas {

View file

@ -0,0 +1,206 @@
package linkcheck
import (
"context"
"crypto/rand"
_ "embed"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/Luzifer/go_helpers/v2/str"
)
const (
// DefaultCheckTimeout defines the default time the request to a site
// may take to answer
DefaultCheckTimeout = 10 * time.Second
maxRedirects = 50
resolverPoolSize = 25
)
type (
resolver struct {
resolverC chan resolverQueueEntry
skipValidation bool
}
resolverQueueEntry struct {
Link string
Callback func(string)
WaitGroup *sync.WaitGroup
}
)
var (
defaultUserAgents = []string{}
linkTest = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`)
numericHost = regexp.MustCompile(`^(?:[0-9]+\.)*[0-9]+(?::[0-9]+)?$`)
//go:embed user-agents.txt
uaList string
defaultResolver = newResolver(resolverPoolSize)
)
func init() {
defaultUserAgents = strings.Split(strings.TrimSpace(uaList), "\n")
}
func newResolver(poolSize int, opts ...func(*resolver)) *resolver {
r := &resolver{
resolverC: make(chan resolverQueueEntry),
}
for _, o := range opts {
o(r)
}
for i := 0; i < poolSize; i++ {
go r.runResolver()
}
return r
}
func withSkipVerify() func(*resolver) {
return func(r *resolver) { r.skipValidation = true }
}
func (r resolver) Resolve(qe resolverQueueEntry) {
r.resolverC <- qe
}
func (resolver) getJar() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}
// resolveFinal takes a link and looks up the final destination of
// that link after all redirects were followed
func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack []string, userAgent string) string {
if !linkTest.MatchString(link) && !r.skipValidation {
return ""
}
if str.StringInSlice(link, callStack) || len(callStack) == maxRedirects {
// We got ourselves a loop: Yay!
return link
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Jar: cookieJar,
}
ctx, cancel := context.WithTimeout(context.Background(), DefaultCheckTimeout)
defer cancel()
u, err := url.Parse(link)
if err != nil {
return ""
}
if u.Scheme == "" {
// We have no scheme and the url is in the path, lets add the
// scheme and re-parse the URL to avoid some confusion
u.Scheme = "http"
u, err = url.Parse(u.String())
if err != nil {
return ""
}
}
if numericHost.MatchString(u.Host) && !r.skipValidation {
// Host is fully numeric: We don't support scanning that
return ""
}
// Sanitize host: Trailing dots are valid but not required
u.Host = strings.TrimRight(u.Host, ".")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode > 299 && resp.StatusCode < 400 {
// We got a redirect
tu, err := url.Parse(resp.Header.Get("location"))
if err != nil {
return ""
}
target := r.resolveReference(u, tu)
return r.resolveFinal(target, cookieJar, append(callStack, link), userAgent)
}
// We got a response, it's no redirect, we count this as a success
return u.String()
}
func (resolver) resolveReference(origin *url.URL, loc *url.URL) string {
// Special Case: vkontakte used as shortener / obfuscation
if loc.Path == "/away.php" && loc.Query().Has("to") {
// VK is doing HTML / JS redirect magic so we take that from them
// and execute the redirect directly here in code
return loc.Query().Get("to")
}
if loc.Host == "consent.youtube.com" && loc.Query().Has("continue") {
// Youtube links end up in consent page but we want the real
// target so we use the continue parameter where we strip the
// cbrd query parameters as that one causes an infinite loop.
contTarget, err := url.Parse(loc.Query().Get("continue"))
if err == nil {
v := contTarget.Query()
v.Del("cbrd")
contTarget.RawQuery = v.Encode()
return contTarget.String()
}
return loc.Query().Get("continue")
}
if loc.Host == "www.instagram.com" && loc.Query().Has("next") {
// Instagram likes its login page, we on the other side don't
// care about the sign-in or even the content. Therefore we
// just take their redirect target and use that as the next
// URL
return loc.Query().Get("next")
}
// Default fallback behavior: Do a normal resolve
return origin.ResolveReference(loc).String()
}
func (r resolver) runResolver() {
for qe := range r.resolverC {
if link := r.resolveFinal(qe.Link, r.getJar(), nil, r.userAgent()); link != "" {
qe.Callback(link)
}
qe.WaitGroup.Done()
}
}
func (resolver) userAgent() string {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(defaultUserAgents))))
return defaultUserAgents[n.Int64()]
}

View file

@ -7,7 +7,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
) )
@ -47,9 +49,12 @@ func New(db database.Connector) (*Service, error) {
) )
} }
func (s Service) GetBotUsername() (string, error) { func (s *Service) CopyDatabase(src, target *gorm.DB) error {
var botUsername string return database.CopyObjects(src, target, &extendedPermission{})
err := s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername) }
func (s Service) GetBotUsername() (botUsername string, err error) {
err = s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername)
return botUsername, errors.Wrap(err, "reading bot username") return botUsername, errors.Wrap(err, "reading bot username")
} }
@ -59,11 +64,15 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
perm extendedPermission perm extendedPermission
) )
if err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error; err != nil { if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil
} }
return nil, errors.Wrap(err, "getting twitch credential from database")
return errors.Wrap(err, "getting twitch credential from database")
}); err != nil {
return nil, err
} }
return strings.Split(perm.Scopes, " "), nil return strings.Split(perm.Scopes, " "), nil
@ -145,11 +154,14 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
perm extendedPermission perm extendedPermission
) )
if err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error; err != nil { if err = helpers.Retry(func() error {
err = s.db.DB().First(&perm, "channel = ?", strings.TrimLeft(channel, "#")).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChannelNotAuthorized return backoff.NewErrCannotRetry(ErrChannelNotAuthorized)
} }
return nil, errors.Wrap(err, "getting twitch credential from database") return errors.Wrap(err, "getting twitch credential from database")
}); err != nil {
return nil, err
} }
if perm.AccessToken, err = s.db.DecryptField(perm.AccessToken); err != nil { if perm.AccessToken, err = s.db.DecryptField(perm.AccessToken); err != nil {
@ -200,13 +212,14 @@ func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (boo
return true, nil return true, nil
} }
func (s Service) ListPermittedChannels() ([]string, error) { func (s Service) ListPermittedChannels() (out []string, err error) {
var perms []extendedPermission var perms []extendedPermission
if err := s.db.DB().Find(&perms).Error; err != nil { if err = helpers.Retry(func() error {
return nil, errors.Wrap(err, "listing permissions") return errors.Wrap(s.db.DB().Find(&perms).Error, "listing permissions")
}); err != nil {
return nil, err
} }
var out []string
for _, perm := range perms { for _, perm := range perms {
out = append(out, perm.Channel) out = append(out, perm.Channel)
} }
@ -216,14 +229,18 @@ func (s Service) ListPermittedChannels() ([]string, error) {
func (s Service) RemoveAllExtendedTwitchCredentials() error { func (s Service) RemoveAllExtendedTwitchCredentials() error {
return errors.Wrap( return errors.Wrap(
s.db.DB().Delete(&extendedPermission{}, "1 = 1").Error, helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&extendedPermission{}, "1 = 1").Error
}),
"deleting data from table", "deleting data from table",
) )
} }
func (s Service) RemoveExendedTwitchCredentials(channel string) error { func (s Service) RemoveExendedTwitchCredentials(channel string) error {
return errors.Wrap( return errors.Wrap(
s.db.DB().Delete(&extendedPermission{}, "channel = ?", strings.TrimLeft(channel, "#")).Error, helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&extendedPermission{}, "channel = ?", strings.TrimLeft(channel, "#")).Error
}),
"deleting data from table", "deleting data from table",
) )
} }
@ -245,15 +262,17 @@ func (s Service) SetExtendedTwitchCredentials(channel, accessToken, refreshToken
} }
return errors.Wrap( return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{ helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "channel"}}, return tx.Clauses(clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "scopes"}), Columns: []clause.Column{{Name: "channel"}},
}).Create(extendedPermission{ DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "scopes"}),
Channel: strings.TrimLeft(channel, "#"), }).Create(extendedPermission{
AccessToken: accessToken, Channel: strings.TrimLeft(channel, "#"),
RefreshToken: refreshToken, AccessToken: accessToken,
Scopes: strings.Join(scope, " "), RefreshToken: refreshToken,
}).Error, Scopes: strings.Join(scope, " "),
}).Error
}),
"inserting data into table", "inserting data into table",
) )
} }

View file

@ -0,0 +1,140 @@
// Package authcache implements a cache for token auth to hold auth-
// results with cpu/mem inexpensive methods instead of always using
// secure but expensive methods to validate the token
package authcache
import (
"crypto/sha256"
"fmt"
"sync"
"time"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/pkg/errors"
)
const NegativeCacheTime = 5 * time.Minute
type (
Service struct {
backends []AuthFunc
cache map[string]*CacheEntry
lock sync.RWMutex
}
CacheEntry struct {
AuthResult error // Allows for negative caching
ExpiresAt time.Time
Modules []string
}
// AuthFunc is a backend-function to resolve a token to a list of
// modules the token is authorized for, an expiry-time and an error.
// The error MUST be ErrUnauthorized in case the user is not found,
// if the error is another, the backend resolve will be cancelled
// and no further backends are queried.
AuthFunc func(token string) (modules []string, expiresAt time.Time, err error)
)
// ErrUnauthorized denotes the token could not be found in any backend
// auth method and therefore is not an user
var ErrUnauthorized = errors.New("unauthorized")
func New(backends ...AuthFunc) *Service {
s := &Service{
backends: backends,
cache: make(map[string]*CacheEntry),
}
go s.runCleanup()
return s
}
func (s *Service) ValidateTokenFor(token string, modules ...string) error {
s.lock.RLock()
cached := s.cache[s.cacheKey(token)]
s.lock.RUnlock()
if cached != nil && cached.ExpiresAt.After(time.Now()) {
// We do have a recent cache entry for that token: continue to use
return cached.validateFor(modules)
}
// No recent cache entry: We need to ask the expensive backends
var ce CacheEntry
backendLoop:
for _, fn := range s.backends {
ce.Modules, ce.ExpiresAt, ce.AuthResult = fn(token)
switch {
case ce.AuthResult == nil:
// Valid result & auth, the user was found
break backendLoop
case errors.Is(ce.AuthResult, ErrUnauthorized):
// Valid result, user was not found
continue backendLoop
default:
// Something went wrong, bail out and do not cache
return errors.Wrap(ce.AuthResult, "querying authorization in backend")
}
}
// We got a final result: That might be ErrUnauthorized or a valid
// 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)
}
s.lock.Lock()
s.cache[s.cacheKey(token)] = &ce
s.lock.Unlock()
// Finally return the result for the requested modules
return ce.validateFor(modules)
}
func (*Service) cacheKey(token string) string {
return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(token)))
}
func (s *Service) cleanup() {
s.lock.Lock()
defer s.lock.Unlock()
var (
now = time.Now()
remove []string
)
for key := range s.cache {
if s.cache[key].ExpiresAt.Before(now) {
remove = append(remove, key)
}
}
for _, key := range remove {
delete(s.cache, key)
}
}
func (s *Service) runCleanup() {
for range time.NewTicker(time.Minute).C {
s.cleanup()
}
}
func (c CacheEntry) validateFor(modules []string) error {
if c.AuthResult != nil {
return c.AuthResult
}
for _, reqMod := range modules {
if !str.StringInSlice(reqMod, c.Modules) && !str.StringInSlice("*", c.Modules) {
return errors.New("missing module in auth")
}
}
return nil
}

View file

@ -12,6 +12,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"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/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -44,6 +46,10 @@ func New(db database.Connector, cronService *cron.Cron) (*Service, error) {
return s, errors.Wrap(s.db.DB().AutoMigrate(&timer{}), "applying migrations") return s, errors.Wrap(s.db.DB().AutoMigrate(&timer{}), "applying migrations")
} }
func (s *Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &timer{})
}
func (s *Service) UpdatePermitTimeout(d time.Duration) { func (s *Service) UpdatePermitTimeout(d time.Duration) {
s.permitTimeout = d s.permitTimeout = d
} }
@ -84,7 +90,13 @@ func (Service) getPermitTimerKey(channel, username string) string {
func (s Service) HasTimer(id string) (bool, error) { func (s Service) HasTimer(id string) (bool, error) {
var t timer var t timer
err := s.db.DB().First(&t, "id = ? AND expires_at >= ?", id, time.Now().UTC()).Error 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 err
})
switch { switch {
case err == nil: case err == nil:
return true, nil return true, nil
@ -99,19 +111,23 @@ func (s Service) HasTimer(id string) (bool, error) {
func (s Service) SetTimer(id string, expiry time.Time) error { func (s Service) SetTimer(id string, expiry time.Time) error {
return errors.Wrap( return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{ helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "id"}}, return tx.Clauses(clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"expires_at"}), Columns: []clause.Column{{Name: "id"}},
}).Create(timer{ DoUpdates: clause.AssignmentColumns([]string{"expires_at"}),
ID: id, }).Create(timer{
ExpiresAt: expiry.UTC(), ID: id,
}).Error, ExpiresAt: expiry.UTC(),
}).Error
}),
"storing counter in database", "storing counter in database",
) )
} }
func (s Service) cleanupTimers() { func (s Service) cleanupTimers() {
if err := s.db.DB().Delete(&timer{}, "expires_at < ?", time.Now().UTC()).Error; err != nil { if err := helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
return tx.Delete(&timer{}, "expires_at < ?", time.Now().UTC()).Error
}); err != nil {
logrus.WithError(err).Error("cleaning up expired timers") logrus.WithError(err).Error("cleaning up expired timers")
} }
} }

View file

@ -0,0 +1,39 @@
package twitch
import (
"context"
"math"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
)
func init() {
regFn = append(
regFn,
tplTwitchScheduleSegments,
)
}
func tplTwitchScheduleSegments(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("scheduleSegments", plugins.GenericTemplateFunctionGetter(func(channel string, n ...int) ([]twitch.ChannelStreamScheduleSegment, error) {
schedule, err := args.GetTwitchClient().GetChannelStreamSchedule(context.Background(), channel)
if err != nil {
return nil, errors.Wrap(err, "getting schedule")
}
if len(n) > 0 {
return schedule.Segments[:int(math.Min(float64(n[0]), float64(len(schedule.Segments))))], nil
}
return schedule.Segments, nil
}), plugins.TemplateFuncDocumentation{
Description: "Returns the next n segments in the channels schedule. If n is not given, returns all known segments.",
Syntax: "scheduleSegments <channel> [n]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ $seg := scheduleSegments "luziferus" 1 | first }}Next Stream: {{ $seg.Title }} @ {{ dateInZone "2006-01-02 15:04" $seg.StartTime "Europe/Berlin" }}`,
FakedOutput: "Next Stream: Little Nightmares @ 2023-11-05 18:00",
},
})
}

22
main.go
View file

@ -5,6 +5,7 @@ import (
"math" "math"
"net" "net"
"net/http" "net/http"
"net/http/pprof"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -22,6 +23,7 @@ import (
"github.com/Luzifer/rconfig/v2" "github.com/Luzifer/rconfig/v2"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/internal/service/access" "github.com/Luzifer/twitch-bot/v3/internal/service/access"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
"github.com/Luzifer/twitch-bot/v3/internal/service/timer" "github.com/Luzifer/twitch-bot/v3/internal/service/timer"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -68,6 +70,7 @@ var (
db database.Connector db database.Connector
accessService *access.Service accessService *access.Service
authService *authcache.Service
timerService *timer.Service timerService *timer.Service
twitchClient *twitch.Client twitchClient *twitch.Client
@ -135,6 +138,11 @@ func main() {
log.WithError(err).Fatal("applying access migration") log.WithError(err).Fatal("applying access migration")
} }
authService = authcache.New(
authBackendInternalToken,
authBackendTwitchToken,
)
cronService = cron.New(cron.WithSeconds()) cronService = cron.New(cron.WithSeconds())
if timerService, err = timer.New(db, cronService); err != nil { if timerService, err = timer.New(db, cronService); err != nil {
@ -174,6 +182,20 @@ func main() {
router.HandleFunc("/openapi.json", handleSwaggerRequest) router.HandleFunc("/openapi.json", handleSwaggerRequest)
router.HandleFunc("/selfcheck", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(runID)) }) router.HandleFunc("/selfcheck", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(runID)) })
if os.Getenv("ENABLE_PROFILING") == "true" {
router.HandleFunc("/debug/pprof/", pprof.Index)
router.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
router.Handle("/debug/pprof/block", pprof.Handler("block"))
router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
router.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
router.Handle("/debug/pprof/heap", pprof.Handler("heap"))
router.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
router.HandleFunc("/debug/pprof/profile", pprof.Profile)
router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
router.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
router.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
router.MethodNotAllowedHandler = corsMiddleware(http.HandlerFunc(func(res http.ResponseWriter, r *http.Request) { router.MethodNotAllowedHandler = corsMiddleware(http.HandlerFunc(func(res http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
// Most likely JS client asking for CORS headers // Most likely JS client asking for CORS headers

12
package-lock.json generated
View file

@ -1369,9 +1369,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.3.4", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@ -5307,9 +5307,9 @@
"optional": true "optional": true
}, },
"axios": { "axios": {
"version": "1.3.4", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"requires": { "requires": {
"follow-redirects": "^1.15.0", "follow-redirects": "^1.15.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",

View file

@ -42,7 +42,7 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
switch driverName { switch driverName {
case "mysql": case "mysql":
mysqlDriver.SetLogger(newLogrusLogWriterWithLevel(logrus.ErrorLevel, driverName)) mysqlDriver.SetLogger(NewLogrusLogWriterWithLevel(logrus.StandardLogger(), logrus.ErrorLevel, driverName))
innerDB = mysql.Open(connString) innerDB = mysql.Open(connString)
dbTuner = tuneMySQLDatabase dbTuner = tuneMySQLDatabase
@ -63,7 +63,13 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
db, err := gorm.Open(innerDB, &gorm.Config{ db, err := gorm.Open(innerDB, &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true, DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.New(newLogrusLogWriterWithLevel(logrus.TraceLevel, driverName), logger.Config{}), Logger: logger.New(NewLogrusLogWriterWithLevel(logrus.StandardLogger(), logrus.TraceLevel, driverName), logger.Config{
SlowThreshold: time.Second,
Colorful: false,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
LogLevel: logger.Info,
}),
}) })
if err != nil { if err != nil {
return nil, errors.Wrap(err, "connecting database") return nil, errors.Wrap(err, "connecting database")
@ -83,10 +89,13 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
} }
func (c connector) Close() error { func (c connector) Close() error {
// return errors.Wrap(c.db.Close(), "closing database")
return nil return nil
} }
func (c connector) CopyDatabase(src, target *gorm.DB) error {
return CopyObjects(src, target, &coreKV{})
}
func (c connector) DB() *gorm.DB { func (c connector) DB() *gorm.DB {
return c.db return c.db
} }

View file

@ -0,0 +1,42 @@
package database
import (
"reflect"
"github.com/pkg/errors"
"gorm.io/gorm"
)
const copyBatchSize = 100
// CopyObjects is a helper to copy elements of a given type from the
// src to the target GORM database interface
func CopyObjects(src, target *gorm.DB, objects ...any) (err error) {
for _, obj := range objects {
copySlice := reflect.New(reflect.SliceOf(reflect.TypeOf(obj))).Elem().Addr().Interface()
if err = target.AutoMigrate(obj); err != nil {
return errors.Wrap(err, "applying migration to target")
}
if err = target.Where("1 = 1").Delete(obj).Error; err != nil {
return errors.Wrap(err, "cleaning target table")
}
if err = src.FindInBatches(copySlice, copyBatchSize, func(tx *gorm.DB, _ int) error {
if err = target.Save(copySlice).Error; err != nil {
if errors.Is(err, gorm.ErrEmptySlice) {
// That's fine and no reason to exit here
return nil
}
return errors.Wrap(err, "inserting collected elements")
}
return nil
}).Error; err != nil {
return errors.Wrap(err, "batch-copying data")
}
}
return nil
}

View file

@ -13,6 +13,7 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/Luzifer/go_helpers/v2/backoff" "github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
) )
const ( const (
@ -31,7 +32,9 @@ type (
// DeleteCoreMeta removes a core_kv table entry // DeleteCoreMeta removes a core_kv table entry
func (c connector) DeleteCoreMeta(key string) error { func (c connector) DeleteCoreMeta(key string) error {
return errors.Wrap( return errors.Wrap(
c.db.Delete(&coreKV{}, "name = ?", key).Error, helpers.RetryTransaction(c.db, func(tx *gorm.DB) error {
return tx.Delete(&coreKV{}, "name = ?", key).Error
}),
"deleting key from database", "deleting key from database",
) )
} }
@ -61,7 +64,9 @@ func (c connector) ReadEncryptedCoreMeta(key string, value any) error {
// ResetEncryptedCoreMeta removes all CoreKV entries from the database // ResetEncryptedCoreMeta removes all CoreKV entries from the database
func (c connector) ResetEncryptedCoreMeta() error { func (c connector) ResetEncryptedCoreMeta() error {
return errors.Wrap( return errors.Wrap(
c.db.Delete(&coreKV{}, "value LIKE ?", "U2FsdGVkX1%").Error, helpers.RetryTransaction(c.db, func(tx *gorm.DB) error {
return tx.Delete(&coreKV{}, "value LIKE ?", "U2FsdGVkX1%").Error
}),
"removing encrypted meta entries", "removing encrypted meta entries",
) )
} }
@ -110,11 +115,14 @@ func (c connector) ValidateEncryption() error {
func (c connector) readCoreMeta(key string, value any, processor func(string) (string, error)) (err error) { func (c connector) readCoreMeta(key string, value any, processor func(string) (string, error)) (err error) {
var data coreKV var data coreKV
if err = c.db.First(&data, "name = ?", key).Error; err != nil { if err = helpers.Retry(func() error {
err = c.db.First(&data, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCoreMetaNotFound return ErrCoreMetaNotFound
} }
return errors.Wrap(err, "querying core meta table") return errors.Wrap(err, "querying core meta table")
}); err != nil {
return err
} }
if data.Value == "" { if data.Value == "" {
@ -149,10 +157,12 @@ func (c connector) storeCoreMeta(key string, value any, processor func(string) (
data := coreKV{Name: key, Value: encValue} data := coreKV{Name: key, Value: encValue}
return errors.Wrap( return errors.Wrap(
c.db.Clauses(clause.OnConflict{ helpers.RetryTransaction(c.db, func(tx *gorm.DB) error {
Columns: []clause.Column{{Name: "name"}}, return tx.Clauses(clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"value"}), Columns: []clause.Column{{Name: "name"}},
}).Create(data).Error, DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(data).Error
}),
"upserting core meta value", "upserting core meta value",
) )
} }

View file

@ -11,6 +11,7 @@ type (
// convenience methods // convenience methods
Connector interface { Connector interface {
Close() error Close() error
CopyDatabase(src, target *gorm.DB) error
DB() *gorm.DB DB() *gorm.DB
DeleteCoreMeta(key string) error DeleteCoreMeta(key string) error
ReadCoreMeta(key string, value any) error ReadCoreMeta(key string, value any) error

View file

@ -8,18 +8,18 @@ import (
) )
type ( type (
logWriter struct{ io.Writer } LogWriter struct{ io.Writer }
) )
func newLogrusLogWriterWithLevel(level logrus.Level, dbDriver string) logWriter { func NewLogrusLogWriterWithLevel(logger *logrus.Logger, level logrus.Level, dbDriver string) LogWriter {
writer := logrus.WithField("database", dbDriver).WriterLevel(level) writer := logger.WithField("database", dbDriver).WriterLevel(level)
return logWriter{writer} return LogWriter{writer}
} }
func (l logWriter) Print(a ...any) { func (l LogWriter) Print(a ...any) {
fmt.Fprint(l.Writer, a...) fmt.Fprint(l.Writer, a...)
} }
func (l logWriter) Printf(format string, a ...any) { func (l LogWriter) Printf(format string, a ...any) {
fmt.Fprintf(l.Writer, format, a...) fmt.Fprintf(l.Writer, format, a...)
} }

33
pkg/twitch/auth.go Normal file
View file

@ -0,0 +1,33 @@
package twitch
import (
"context"
"net/http"
"time"
"github.com/pkg/errors"
)
// GetTokenInfo requests a validation for the token set within the
// client and returns the authorized user, their granted scopes on this
// token and an error in case something went wrong.
func (c *Client) GetTokenInfo(ctx context.Context) (user string, scopes []string, expiresAt time.Time, err error) {
var payload OAuthTokenValidationResponse
if c.accessToken == "" {
return "", nil, time.Time{}, errors.New("no access token present")
}
if err := c.Request(ClientRequestOpts{
AuthType: AuthTypeBearerToken,
Context: ctx,
Method: http.MethodGet,
OKStatus: http.StatusOK,
Out: &payload,
URL: "https://id.twitch.tv/oauth2/validate",
}); err != nil {
return "", nil, time.Time{}, errors.Wrap(err, "validating token")
}
return payload.Login, payload.Scopes, time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second), nil
}

View file

@ -18,6 +18,7 @@ import (
const ( const (
eventsubLiveSocketDest = "wss://eventsub.wss.twitch.tv/ws" eventsubLiveSocketDest = "wss://eventsub.wss.twitch.tv/ws"
socketConnectTimeout = 15 * time.Second
socketInitialTimeout = 30 * time.Second socketInitialTimeout = 30 * time.Second
socketTimeoutGraceMultiplier = 1.5 socketTimeoutGraceMultiplier = 1.5
) )
@ -166,7 +167,8 @@ func (e *EventSubSocketClient) Run() error {
errC = make(chan error, 1) errC = make(chan error, 1)
keepaliveTimeout = socketInitialTimeout keepaliveTimeout = socketInitialTimeout
msgC = make(chan eventSubSocketMessage, 1) msgC = make(chan eventSubSocketMessage, 1)
socketTimeout = time.NewTimer(keepaliveTimeout) timeoutC = make(chan struct{}, 1)
socketTimeout = newKeepaliveTracker(timeoutC, keepaliveTimeout)
) )
if err := e.connect(e.socketDest, msgC, errC, "client init"); err != nil { if err := e.connect(e.socketDest, msgC, errC, "client init"); err != nil {
@ -187,9 +189,14 @@ func (e *EventSubSocketClient) Run() error {
return err return err
} }
case <-socketTimeout.C: case <-timeoutC:
// No message received, deeming connection dead // No message received, deeming connection dead
socketTimeout.Reset(socketInitialTimeout) e.logger.WithFields(logrus.Fields{
"expired": socketTimeout.ExpiresAt(),
"last_event": socketTimeout.LastRenew(),
}).Warn("eventsub socket missed keepalive")
socketTimeout = newKeepaliveTracker(timeoutC, socketInitialTimeout)
if err := e.connect(e.socketDest, msgC, errC, "socket timeout"); err != nil { if err := e.connect(e.socketDest, msgC, errC, "socket timeout"); err != nil {
errC <- errors.Wrap(err, "re-connecting after timeout") errC <- errors.Wrap(err, "re-connecting after timeout")
continue continue
@ -198,7 +205,7 @@ func (e *EventSubSocketClient) Run() error {
case msg := <-msgC: case msg := <-msgC:
// The keepalive timer is reset with each notification or // The keepalive timer is reset with each notification or
// keepalive message. // keepalive message.
socketTimeout.Reset(keepaliveTimeout) socketTimeout.Renew(keepaliveTimeout)
switch msg.Metadata.MessageType { switch msg.Metadata.MessageType {
case eventsubSocketMessageTypeKeepalive: case eventsubSocketMessageTypeKeepalive:
@ -236,7 +243,10 @@ func (e *EventSubSocketClient) Run() error {
func (e *EventSubSocketClient) connect(url string, msgC chan eventSubSocketMessage, errC chan error, reason string) error { func (e *EventSubSocketClient) connect(url string, msgC chan eventSubSocketMessage, errC chan error, reason string) error {
e.logger.WithField("reason", reason).Debug("(re-)connecting websocket") e.logger.WithField("reason", reason).Debug("(re-)connecting websocket")
conn, _, err := websocket.DefaultDialer.Dial(url, nil) //nolint:bodyclose // Close is implemented at other place ctx, cancel := context.WithTimeout(context.Background(), socketConnectTimeout)
defer cancel()
conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) //nolint:bodyclose // Close is implemented at other place
if err != nil { if err != nil {
return errors.Wrap(err, "dialing websocket") return errors.Wrap(err, "dialing websocket")
} }
@ -397,7 +407,7 @@ func (e *EventSubSocketClient) subscribe() error {
return errors.Wrapf(err, "subscribing to %s/%s", st.Event, st.Version) return errors.Wrapf(err, "subscribing to %s/%s", st.Event, st.Version)
} }
e.logger.WithField("topic", strings.Join([]string{st.Event, st.Version}, "/")).Debug("subscripted to topic") e.logger.WithField("topic", strings.Join([]string{st.Event, st.Version}, "/")).Debug("subscribed to topic")
} }
return nil return nil

39
pkg/twitch/keepalive.go Normal file
View file

@ -0,0 +1,39 @@
package twitch
import "time"
const keepaliveTrackerCheckInterval = 100 * time.Millisecond
type (
keepaliveTracker struct {
c chan<- struct{}
expires time.Time
renewed time.Time
}
)
func newKeepaliveTracker(timeout chan<- struct{}, d time.Duration) *keepaliveTracker {
t := &keepaliveTracker{
c: timeout,
expires: time.Now().Add(d),
}
go t.run()
return t
}
func (t keepaliveTracker) ExpiresAt() time.Time { return t.expires }
func (t keepaliveTracker) LastRenew() time.Time { return t.renewed }
func (t *keepaliveTracker) Renew(d time.Duration) {
t.expires = time.Now().Add(d)
t.renewed = time.Now()
}
func (t *keepaliveTracker) run() {
for t.expires.After(time.Now()) {
time.Sleep(keepaliveTrackerCheckInterval)
}
t.c <- struct{}{}
}

View file

@ -14,26 +14,28 @@ type (
// ChannelStreamSchedule represents the schedule of a channels with // ChannelStreamSchedule represents the schedule of a channels with
// its segments represening single planned streams // its segments represening single planned streams
ChannelStreamSchedule struct { ChannelStreamSchedule struct {
Segments []struct { Segments []ChannelStreamScheduleSegment `json:"segments"`
ID string `json:"id"` BroadcasterID string `json:"broadcaster_id"`
StartTime time.Time `json:"start_time"` BroadcasterName string `json:"broadcaster_name"`
EndTime time.Time `json:"end_time"` BroadcasterLogin string `json:"broadcaster_login"`
Title string `json:"title"`
CanceledUntil *time.Time `json:"canceled_until"`
Category struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"category"`
IsRecurring bool `json:"is_recurring"`
} `json:"segments"`
BroadcasterID string `json:"broadcaster_id"`
BroadcasterName string `json:"broadcaster_name"`
BroadcasterLogin string `json:"broadcaster_login"`
Vacation struct { Vacation struct {
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"` EndTime time.Time `json:"end_time"`
} `json:"vacation"` } `json:"vacation"`
} }
ChannelStreamScheduleSegment struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Title string `json:"title"`
CanceledUntil *time.Time `json:"canceled_until"`
Category struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"category"`
IsRecurring bool `json:"is_recurring"`
}
) )
// GetChannelStreamSchedule gets the broadcasters streaming schedule // GetChannelStreamSchedule gets the broadcasters streaming schedule

View file

@ -90,12 +90,22 @@ type (
// ValidateStatus is the default validation function used when no // ValidateStatus is the default validation function used when no
// ValidateFunc is given in the ClientRequestOpts and checks for the // ValidateFunc is given in the ClientRequestOpts and checks for the
// returned HTTP status is equal to the OKStatus // returned HTTP status is equal to the OKStatus.
//
// When the status is http.StatusTooManyRequests the function will
// return an error terminating any retries as retrying would not make
// sense (the error returned from Request will still be an HTTPError
// with status 429).
// //
// When wrapping this function the body should not have been read // When wrapping this function the body should not have been read
// before in order to have the response body available in the returned // before in order to have the response body available in the returned
// HTTPError // HTTPError
func ValidateStatus(opts ClientRequestOpts, resp *http.Response) error { func ValidateStatus(opts ClientRequestOpts, resp *http.Response) error {
if resp.StatusCode == http.StatusTooManyRequests {
// Twitch doesn't want to hear any more of this
return backoff.NewErrCannotRetry(newHTTPError(resp.StatusCode, nil, nil))
}
if opts.OKStatus != 0 && resp.StatusCode != opts.OKStatus { if opts.OKStatus != 0 && resp.StatusCode != opts.OKStatus {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {

View file

@ -5,6 +5,7 @@ import (
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -38,6 +39,8 @@ type (
CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error) CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error)
DatabaseCopyFunc func(src, target *gorm.DB) error
EventHandlerFunc func(evt string, eventData *FieldCollection) error EventHandlerFunc func(evt string, eventData *FieldCollection) error
EventHandlerRegisterFunc func(EventHandlerFunc) error EventHandlerRegisterFunc func(EventHandlerFunc) error
@ -83,6 +86,10 @@ type (
RegisterActorDocumentation ActorDocumentationRegistrationFunc RegisterActorDocumentation ActorDocumentationRegistrationFunc
// RegisterAPIRoute registers a new HTTP handler function including documentation // RegisterAPIRoute registers a new HTTP handler function including documentation
RegisterAPIRoute HTTPRouteRegistrationFunc RegisterAPIRoute HTTPRouteRegistrationFunc
// RegisterCopyDatabaseFunc registers a DatabaseCopyFunc for the
// database migration tool. Modules not registering such a func
// will not be copied over when migrating to another database.
RegisterCopyDatabaseFunc func(name string, fn DatabaseCopyFunc)
// RegisterCron is a method to register cron functions in the global cron instance // RegisterCron is a method to register cron functions in the global cron instance
RegisterCron CronRegistrationFunc RegisterCron CronRegistrationFunc
// RegisterEventHandler is a method to register a handler function receiving ALL events // RegisterEventHandler is a method to register a handler function receiving ALL events

View file

@ -163,12 +163,13 @@ func getRegistrationArguments() plugins.RegistrationArguments {
RegisterActorDocumentation: registerActorDocumentation, RegisterActorDocumentation: registerActorDocumentation,
RegisterAPIRoute: registerRoute, RegisterAPIRoute: registerRoute,
RegisterCron: cronService.AddFunc, RegisterCron: cronService.AddFunc,
RegisterCopyDatabaseFunc: registerDatabaseCopyFunc,
RegisterEventHandler: registerEventHandlers, RegisterEventHandler: registerEventHandlers,
RegisterMessageModFunc: registerChatcommand, RegisterMessageModFunc: registerChatcommand,
RegisterRawMessageHandler: registerRawMessageHandler, RegisterRawMessageHandler: registerRawMessageHandler,
RegisterTemplateFunction: tplFuncs.Register, RegisterTemplateFunction: tplFuncs.Register,
SendMessage: sendMessage, SendMessage: sendMessage,
ValidateToken: validateAuthToken, ValidateToken: authService.ValidateTokenFor,
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error { CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
handleMessage(ircHdl.Client(), nil, &evt, eventData) handleMessage(ircHdl.Client(), nil, &evt, eventData)

View file

@ -227,6 +227,12 @@
:icon="['fas', 'heart']" :icon="['fas', 'heart']"
title="Follower" title="Follower"
/> />
<font-awesome-icon
v-else-if="entry.enteredAs === 'reward'"
fixed-width
:icon="['fas', 'coins']"
title="Subscriber"
/>
<font-awesome-icon <font-awesome-icon
v-else-if="entry.enteredAs === 'subscriber'" v-else-if="entry.enteredAs === 'subscriber'"
fixed-width fixed-width

View file

@ -1,104 +0,0 @@
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
"golang.org/x/crypto/argon2"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)
const (
// OWASP recommendations - 2023-07-07
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
argonHashLen = 16
argonMemory = 46 * 1024
argonSaltLength = 8
argonThreads = 1
argonTime = 1
)
func fillAuthToken(token *configAuthToken) error {
token.Token = uuid.Must(uuid.NewV4()).String()
salt := make([]byte, argonSaltLength)
if _, err := rand.Read(salt); err != nil {
return errors.Wrap(err, "reading salt")
}
token.Hash = fmt.Sprintf(
argonFmt,
argon2.Version,
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)),
)
return nil
}
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 == "" {
http.Error(w, "auth not successful", http.StatusForbidden)
return
}
for _, fn := range []func() error{
// First try to validate against internal token management
func() error { return validateAuthToken(token, module) },
// If not successful validate against Twitch and check for bot-editors
func() error { return validateTwitchBotEditorAuthToken(token) },
} {
if err := fn(); err != nil {
continue
}
h.ServeHTTP(w, r)
return
}
http.Error(w, "auth not successful", http.StatusForbidden)
})
}
func validateAuthToken(token string, modules ...string) error {
for _, auth := range config.AuthTokens {
if auth.validate(token) != nil {
continue
}
for _, reqMod := range modules {
if !str.StringInSlice(reqMod, auth.Modules) && !str.StringInSlice("*", auth.Modules) {
return errors.New("missing module in auth")
}
}
return nil // We found a matching token and it has all required tokens
}
return errors.New("no matching token")
}
func validateTwitchBotEditorAuthToken(token string) error {
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
id, user, err := tc.GetAuthorizedUser()
if err != nil {
return errors.Wrap(err, "getting authorized user")
}
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
return errors.New("user is not an bot-edtior")
}
return nil
}