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
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
> [!IMPORTANT]

View file

@ -36,11 +36,12 @@ Usage of twitch-bot:
# twitch-bot help
Supported sub-commands are:
actor-docs Generate markdown documentation for available actors
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
tpl-docs Generate markdown documentation for available template functions
validate-config Try to load configuration file and report errors if any
actor-docs Generate markdown documentation for available actors
api-token <token-name> <scope> [...scope] Generate an api-token to be entered into the config
copy-database <target storage-type> <target DSN> Copies database contents to a new storage DSN i.e. for migrating to a new DBMS
reset-secrets Remove encrypted data to reset encryption passphrase
tpl-docs Generate markdown documentation for available template functions
validate-config Try to load configuration file and report errors if any
```
### 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)
}
for _, r := range config.GetMatchingRules(m, event, eventData) {
var (
ruleEventData = plugins.NewFieldCollection()
preventCooldown bool
)
if eventData != nil {
ruleEventData.SetFromData(eventData.Data())
}
ActionsLoop:
for _, a := range r.Actions {
apc, err := triggerAction(c, m, r, a, ruleEventData)
switch {
case err == nil:
// Rule execution did not cause an error, we store the
// cooldown modifier and continue
preventCooldown = preventCooldown || apc
continue ActionsLoop
case errors.Is(err, plugins.ErrStopRuleExecution):
// Action has asked to stop executing this rule so we store
// the cooldown modifier and stop executing the actions stack
preventCooldown = preventCooldown || apc
break ActionsLoop
default:
// Action experienced an error: We don't store the cooldown
// state of this action and stop executing the actions stack
// for this rule
log.WithError(err).Error("Unable to trigger action")
break ActionsLoop // Break execution for this rule when one action fails
}
}
// Lock command
if !preventCooldown {
r.SetCooldown(timerService, m, eventData)
}
matchingRules := config.GetMatchingRules(m, event, eventData)
for i := range matchingRules {
go handleMessageRuleExecution(c, m, matchingRules[i], eventData)
}
}
func handleMessageRuleExecution(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection) {
var (
ruleEventData = plugins.NewFieldCollection()
preventCooldown bool
)
if eventData != nil {
ruleEventData.SetFromData(eventData.Data())
}
ActionsLoop:
for _, a := range r.Actions {
apc, err := triggerAction(c, m, r, a, ruleEventData)
switch {
case err == nil:
// Rule execution did not cause an error, we store the
// cooldown modifier and continue
preventCooldown = preventCooldown || apc
continue ActionsLoop
case errors.Is(err, plugins.ErrStopRuleExecution):
// Action has asked to stop executing this rule so we store
// the cooldown modifier and stop executing the actions stack
// Action experienced an error: We don't store the cooldown
// state of this action and stop executing the actions stack
// for this rule
preventCooldown = preventCooldown || apc
break ActionsLoop
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
```
## 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 external script / command

View file

@ -2,6 +2,10 @@
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
```yaml
@ -55,6 +59,28 @@ title: "Rule Examples"
- 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
```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
```
### `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`
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: 37%
< Your int this hour: 73%
```
### `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.
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" >}})
@ -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).
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 (
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/rconfig/v2 v2.4.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.25.0
github.com/glebarez/sqlite v1.9.0
github.com/go-git/go-git/v5 v5.9.0
github.com/glebarez/sqlite v1.10.0
github.com/go-git/go-git/v5 v5.10.1
github.com/go-sql-driver/mysql v1.7.1
github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/itchyny/gojq v0.12.13
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/orandin/sentrus v1.0.0
@ -24,11 +24,11 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.14.0
golang.org/x/crypto v0.16.0
gopkg.in/irc.v4 v4.0.0
gopkg.in/yaml.v3 v3.0.1
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
)
@ -38,9 +38,8 @@ require (
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
github.com/acomagu/bufpipe v1.0.4 // 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/davecgh/go-spew v1.1.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/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.4.0 // indirect
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-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-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/vault/api v1.10.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/jackc/pgpassfile v1.0.0 // 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/jinzhu/inflection v1.0.0 // 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/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.14.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.27.0 // indirect
modernc.org/libc v1.34.11 // indirect
modernc.org/mathutil v1.6.0 // 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/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_helpers/v2 v2.20.1 h1:VAp2J8g31X30Xr8/eVV1Xx993MO0tQx9YwNwab6ouB4=
github.com/Luzifer/go_helpers/v2 v2.20.1/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA=
github.com/Luzifer/go_helpers/v2 v2.22.0 h1:rJrZkJDzAiq4J0RUbwPI7kQ5rUy7BYQ/GUpo3fSM0y0=
github.com/Luzifer/go_helpers/v2 v2.22.0/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA=
github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q=
github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o=
@ -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/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/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/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
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/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.5 h1:g+wWynZqVALYAlpSQFAa7TscDnUK8mKYtrxMpw6AUKo=
github.com/cloudflare/circl v1.3.5/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
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.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
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/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-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.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY=
github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.10.1 h1:tu8/D8i+TWxgKpzQ3Vc43e+kkhXqtsZCKI/egajKnxk=
github.com/go-git/go-git/v5 v5.10.1/go.mod h1:uEuHjxkHap8kAl//V5F/nNWwqIYtP/402ddd05mp0wg=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
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=
@ -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/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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
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.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
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.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
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-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.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
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.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
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.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
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.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
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.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU=
github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
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.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/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/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
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/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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.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.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
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/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-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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
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-20180826012351-8a410e7b638d/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.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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -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-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.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -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.5.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -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.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.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
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-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.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.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@ -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=
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/postgres v1.5.3 h1:qKGY5CPHOuj47K/VxbCXJfFvIUeqMSXXadqdCY+MbBU=
gorm.io/driver/postgres v1.5.3/go.mod h1:F+LtvlFhZT7UBiA81mC9W6Su3D4WUhSboc/36QZU0gk=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
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.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.27.0 h1:Z35IJO5v46n+d1RWRzCD3CiMYYc9TotabBDl75kRmdo=
modernc.org/libc v1.27.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
modernc.org/libc v1.34.11 h1:hQDcIUlSG4QAOkXCIQKkaAOV5ptXvkOx4ddbXzgW2JU=
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/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=

View file

@ -9,6 +9,7 @@ import (
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -28,6 +29,10 @@ func Register(args plugins.RegistrationArguments) error {
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
args.RegisterActor("counter", func() plugins.Actor { return &ActorCounter{} })

View file

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

View file

@ -22,7 +22,7 @@ func Register(args plugins.RegistrationArguments) error {
Fields: []plugins.ActionDocumentationField{
{
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",
Name: "Heuristic Scan",
Optional: true,

View file

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"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")
}
args.RegisterCopyDatabaseFunc("punish", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &punishLevel{})
})
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage

View file

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

View file

@ -5,6 +5,7 @@ import (
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -30,6 +31,10 @@ func Register(args plugins.RegistrationArguments) error {
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
send = args.SendMessage

View file

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
)
@ -20,11 +21,13 @@ type (
func AddQuote(db database.Connector, channel, quoteStr string) error {
return errors.Wrap(
db.DB().Create(quote{
Channel: channel,
CreatedAt: time.Now().UnixNano(),
Quote: quoteStr,
}).Error,
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Create(quote{
Channel: channel,
CreatedAt: time.Now().UnixNano(),
Quote: quoteStr,
}).Error
}),
"adding quote to database",
)
}
@ -36,14 +39,18 @@ func DelQuote(db database.Connector, channel string, quoteIdx int) error {
}
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",
)
}
func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
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")
}
@ -57,11 +64,13 @@ func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
var count int64
if err := db.DB().
Model(&quote{}).
Where("channel = ?", channel).
Count(&count).
Error; err != nil {
if err := helpers.Retry(func() error {
return db.DB().
Model(&quote{}).
Where("channel = ?", channel).
Count(&count).
Error
}); err != nil {
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
err := db.DB().
Where("channel = ?", channel).
Limit(1).
Offset(quoteIdx - 1).
First(&q).Error
err := helpers.Retry(func() error {
return db.DB().
Where("channel = ?", channel).
Limit(1).
Offset(quoteIdx - 1).
First(&q).Error
})
switch {
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 {
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 {
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(
db.DB().
Where("channel = ? AND created_at = ?", channel, createdAt).
Update("quote", quoteStr).
Error,
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Where("channel = ? AND created_at = ?", channel, createdAt).
Update("quote", quoteStr).
Error
}),
"updating quote",
)
}

View file

@ -7,6 +7,7 @@ import (
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -27,6 +28,10 @@ func Register(args plugins.RegistrationArguments) error {
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
args.RegisterActor("setvariable", func() plugins.Actor { return &ActorSetVariable{} })

View file

@ -5,6 +5,8 @@ import (
"gorm.io/gorm"
"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"
)
@ -17,7 +19,13 @@ type (
func GetVariable(db database.Connector, key string) (string, error) {
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 {
case err == 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 {
return errors.Wrap(
db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(variable{Name: key, Value: value}).Error,
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(variable{Name: key, Value: value}).Error
}),
"updating value in database",
)
}
func RemoveVariable(db database.Connector, key string) error {
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",
)
}

View file

@ -10,6 +10,7 @@ import (
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -32,6 +33,10 @@ func Register(args plugins.RegistrationArguments) error {
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}
eventCreatorFunc = args.CreateEvent

View file

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

View file

@ -7,13 +7,16 @@ import (
"time"
"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/plugins"
)
type (
overlaysEvent struct {
ID uint64 `gorm:"primaryKey"`
Channel string `gorm:"not null;index:overlays_events_sort_idx"`
CreatedAt time.Time `gorm:"index:overlays_events_sort_idx"`
EventType string
@ -28,12 +31,14 @@ func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) e
}
return errors.Wrap(
db.DB().Create(overlaysEvent{
Channel: channel,
CreatedAt: evt.Time.UTC(),
EventType: evt.Type,
Fields: strings.TrimSpace(buf.String()),
}).Error,
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Create(&overlaysEvent{
Channel: channel,
CreatedAt: evt.Time.UTC(),
EventType: evt.Type,
Fields: strings.TrimSpace(buf.String()),
}).Error
}),
"storing event to database",
)
}
@ -41,7 +46,9 @@ func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) e
func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, error) {
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")
}

View file

@ -15,6 +15,7 @@ import (
"github.com/gorilla/websocket"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/str"
"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")
}
args.RegisterCopyDatabaseFunc("overlay_events", func(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &overlaysEvent{})
})
validateToken = args.ValidateToken
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"
)
const moduleName = "raffle"
var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
{
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")
}, nil),
Method: http.MethodGet,
Module: actorName,
Module: moduleName,
Name: "List Raffles",
Path: "/",
RequiresWriteAuth: true,
@ -41,7 +43,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Create(ra), "creating raffle")
}, nil),
Method: http.MethodPost,
Module: actorName,
Module: moduleName,
Name: "Create Raffle",
Path: "/",
RequiresWriteAuth: true,
@ -54,7 +56,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Delete(ids["id"]), "fetching raffle from database")
}, []string{"id"}),
Method: http.MethodDelete,
Module: actorName,
Module: moduleName,
Name: "Delete Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
@ -74,7 +76,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return ra, errors.Wrap(err, "fetching raffle from database")
}, []string{"id"}),
Method: http.MethodGet,
Module: actorName,
Module: moduleName,
Name: "Get Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
@ -102,7 +104,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Update(ra), "updating raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Update Raffle",
Path: "/{id}",
RequiresWriteAuth: true,
@ -121,7 +123,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Clone(ids["id"]), "cloning raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Clone Raffle",
Path: "/{id}/clone",
RequiresWriteAuth: true,
@ -140,7 +142,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Close(ids["id"]), "closing raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Close Raffle",
Path: "/{id}/close",
RequiresWriteAuth: true,
@ -159,7 +161,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.PickWinner(ids["id"]), "picking winner")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Pick Raffle Winner",
Path: "/{id}/pick",
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")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Reopen Raffle",
Path: "/{id}/reopen",
QueryParams: []plugins.HTTPRouteParamDocumentation{
@ -191,7 +193,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
Description: "Number of seconds to leave the raffle open",
Name: "duration",
Required: true,
Type: "integer",
Type: "int",
},
},
RequiresWriteAuth: true,
@ -210,7 +212,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.Start(ids["id"]), "starting raffle")
}, []string{"id"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Start Raffle",
Path: "/{id}/start",
RequiresWriteAuth: true,
@ -229,7 +231,7 @@ var apiRoutes = []plugins.HTTPRouteRegistrationArgs{
return nil, errors.Wrap(dbc.RedrawWinner(ids["id"], ids["winner"]), "re-picking winner")
}, []string{"id", "winner"}),
Method: http.MethodPut,
Module: actorName,
Module: moduleName,
Name: "Re-Pick Raffle Winner",
Path: "/{id}/repick/{winner}",
RequiresWriteAuth: true,

View file

@ -7,7 +7,9 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
@ -121,10 +123,12 @@ func newDBClient(db database.Connector) *dbClient {
func (d *dbClient) AutoCloseExpired() (err error) {
var rr []raffle
if err = d.db.DB().
Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()).
Find(&rr).
Error; err != nil {
if err = helpers.Retry(func() error {
return d.db.DB().
Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()).
Find(&rr).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffles to close")
}
@ -142,10 +146,12 @@ func (d *dbClient) AutoCloseExpired() (err error) {
func (d *dbClient) AutoSendReminders() (err error) {
var rr []raffle
if err = d.db.DB().
Where("status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)", raffleStatusActive, true, time.Now().UTC()).
Find(&rr).
Error; err != nil {
if err = helpers.Retry(func() error {
return d.db.DB().
Where("status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)", raffleStatusActive, true, time.Now().UTC()).
Find(&rr).
Error
}); err != nil {
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) {
var rr []raffle
if err = d.db.DB().
Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()).
Find(&rr).
Error; err != nil {
if err = helpers.Retry(func() error {
return d.db.DB().
Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()).
Find(&rr).
Error
}); err != nil {
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")
}
if err = d.db.DB().Model(&raffle{}).
Where("id = ?", raffleID).
Update("status", raffleStatusEnded).
Error; err != nil {
if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffle{}).
Where("id = ?", raffleID).
Update("status", raffleStatusEnded).
Error
}); err != nil {
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
// before calling this function
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")
}
@ -242,17 +254,23 @@ func (d *dbClient) Create(r raffle) error {
// Delete removes all entries for the given raffle and afterwards
// deletes the raffle itself
func (d *dbClient) Delete(raffleID uint64) (err error) {
if err = d.db.DB().
Where("raffle_id = ?", raffleID).
Delete(&raffleEntry{}).
Error; err != nil {
return errors.Wrap(err, "deleting raffle entries")
}
if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
if err = tx.
Where("raffle_id = ?", raffleID).
Delete(&raffleEntry{}).
Error; err != nil {
return errors.Wrap(err, "deleting raffle entries")
}
if err = d.db.DB().
Where("id = ?", raffleID).
Delete(&raffle{}).Error; err != nil {
return errors.Wrap(err, "creating database record")
if err = tx.
Where("id = ?", raffleID).
Delete(&raffle{}).Error; err != nil {
return errors.Wrap(err, "creating database record")
}
return nil
}); err != nil {
return errors.Wrap(err, "deleting raffle")
}
frontendNotify(frontendNotifyEventRaffleChange)
@ -263,7 +281,7 @@ func (d *dbClient) Delete(raffleID uint64) (err error) {
// the database without modification and therefore need to be filled
// before calling this function
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")
}
@ -274,11 +292,13 @@ func (d *dbClient) Enter(re raffleEntry) error {
// Get retrieves a raffle from the database
func (d *dbClient) Get(raffleID uint64) (out raffle, err error) {
return out, errors.Wrap(
d.db.DB().
Where("raffles.id = ?", raffleID).
Preload("Entries").
First(&out).
Error,
helpers.Retry(func() error {
return d.db.DB().
Where("raffles.id = ?", raffleID).
Preload("Entries").
First(&out).
Error
}),
"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
func (d *dbClient) List() (raffles []raffle, _ error) {
return raffles, errors.Wrap(
d.db.DB().Model(&raffle{}).
Order("id DESC").
Find(&raffles).
Error,
helpers.Retry(func() error {
return d.db.DB().Model(&raffle{}).
Order("id DESC").
Find(&raffles).
Error
}),
"updating column",
)
}
@ -314,10 +336,12 @@ func (d *dbClient) List() (raffles []raffle, _ error) {
// sent for the given raffle ID. No other fields are modified
func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error {
return errors.Wrap(
d.db.DB().Model(&raffle{}).
Where("id = ?", raffleID).
Update("text_reminder_next_send", next).
Error,
helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffle{}).
Where("id = ?", raffleID).
Update("text_reminder_next_send", next).
Error
}),
"updating column",
)
}
@ -336,10 +360,12 @@ func (d *dbClient) PickWinner(raffleID uint64) error {
}
speakUpUntil := time.Now().UTC().Add(r.WaitForResponse)
if err = d.db.DB().Model(&raffleEntry{}).
Where("id = ?", winner.ID).
Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}).
Error; err != nil {
if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffleEntry{}).
Where("id = ?", winner.ID).
Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}).
Error
}); err != nil {
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
// crossed out as winner in the interface) and picks a new one
func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error {
if err := d.db.DB().Model(&raffleEntry{}).
Where("id = ?", winnerID).
Update("was_redrawn", true).
Error; err != nil {
if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffleEntry{}).
Where("id = ?", winnerID).
Update("was_redrawn", true).
Error
}); err != nil {
return errors.Wrap(err, "updating previous winner")
}
@ -385,10 +413,12 @@ func (d *dbClient) RefreshActiveRaffles() error {
tmp = map[string]uint64{}
)
if err := d.db.DB().
Where("status = ?", raffleStatusActive).
Find(&actives).
Error; err != nil {
if err := helpers.Retry(func() error {
return d.db.DB().
Where("status = ?", raffleStatusActive).
Find(&actives).
Error
}); err != nil {
return errors.Wrap(err, "fetching active raffles")
}
@ -411,19 +441,23 @@ func (d *dbClient) RefreshSpeakUp() error {
tmp = map[string]*speakUpWait{}
)
if err := d.db.DB().Debug().
Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()).
Find(&res).
Error; err != nil {
if err := helpers.Retry(func() error {
return d.db.DB().Debug().
Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()).
Find(&res).
Error
}); err != nil {
return errors.Wrap(err, "querying active entries")
}
for _, e := range res {
var r raffle
if err := d.db.DB().
Where("id = ?", e.RaffleID).
First(&r).
Error; err != nil {
if err := helpers.Retry(func() error {
return d.db.DB().
Where("id = ?", e.RaffleID).
First(&r).
Error
}); err != nil {
return errors.Wrap(err, "fetching raffle for entry")
}
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
}
if err := d.db.DB().
Model(&raffleEntry{}).
Where("id = ?", w.RaffleEntryID).
Updates(map[string]any{
"DrawResponse": message,
"SpeakUpUntil": nil,
}).
Error; err != nil {
if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffleEntry{}).
Where("id = ?", w.RaffleEntryID).
Updates(map[string]any{
"DrawResponse": message,
"SpeakUpUntil": nil,
}).
Error
}); err != nil {
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")
}
if err = d.db.DB().
Model(&raffle{}).
Where("id = ?", raffleID).
Updates(map[string]any{
"CloseAt": time.Now().UTC().Add(duration),
"status": raffleStatusActive,
}).
Error; err != nil {
if err = helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffle{}).
Where("id = ?", raffleID).
Updates(map[string]any{
"CloseAt": time.Now().UTC().Add(duration),
"status": raffleStatusActive,
}).
Error
}); err != nil {
return errors.Wrap(err, "updating raffle")
}
@ -557,11 +593,12 @@ func (d *dbClient) Update(r raffle) error {
r.Entries = nil
r.TextReminderNextSend = old.TextReminderNextSend
if err := d.db.DB().
Model(&raffle{}).
Where("id = ?", r.ID).
Updates(&r).
Error; err != nil {
if err := helpers.RetryTransaction(d.db.DB(), func(tx *gorm.DB) error {
return tx.Model(&raffle{}).
Where("id = ?", r.ID).
Updates(&r).
Error
}); err != nil {
return errors.Wrap(err, "updating raffle")
}

View file

@ -5,14 +5,13 @@ package raffle
import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const actorName = "raffle"
var (
db database.Connector
dbc *dbClient
@ -28,6 +27,10 @@ func Register(args plugins.RegistrationArguments) (err error) {
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)
if err = dbc.RefreshActiveRaffles(); err != nil {
return errors.Wrap(err, "refreshing active raffle cache")
@ -53,7 +56,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
} {
if err := fn(); err != nil {
logrus.WithFields(logrus.Fields{
"actor": actorName,
"actor": moduleName,
"cron": name,
}).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")
}
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
}

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
import (
"context"
"crypto/rand"
_ "embed"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
"sync"
"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 (
// Checker contains logic to detect and resolve links in a message
Checker struct {
checkTimeout time.Duration
userAgents []string
skipValidation bool // Only for tests, not settable from the outside
res *resolver
}
)
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
func New() *Checker {
return &Checker{
checkTimeout: DefaultCheckTimeout,
userAgents: defaultUserAgents,
func New(opts ...func(*Checker)) *Checker {
c := &Checker{
res: defaultResolver,
}
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
@ -61,9 +38,10 @@ func New() *Checker {
func (c Checker) HeuristicScanForLinks(message string) []string {
return c.scan(message,
c.scanPlainNoObfuscate,
c.scanObfuscateSpace,
c.scanObfuscateSpecialCharsAndSpaces,
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)
}
// 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) {
for _, scanner := range scanFns {
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) {
// Spammers use spaces in their links to prevent link protection matches
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 i := 0; i <= len(parts)-ptJoin; i++ {
if link := c.resolveFinal(strings.Join(parts[i:i+ptJoin], ""), c.getJar(), nil, c.userAgent()); link != "" {
links = append(links, link)
}
wg.Add(1)
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) {
// First clean URL from all characters not acceptable in Domains (plus some extra chars)
message = dropSet.ReplaceAllString(message, "")
return c.scanObfuscateSpace(message)
return links
}
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 {
if link := c.resolveFinal(part, c.getJar(), nil, c.userAgent()); link != "" {
links = append(links, link)
}
wg.Add(1)
c.res.Resolve(resolverQueueEntry{
Link: part,
Callback: func(link string) { links = str.AppendIfMissing(links, link) },
WaitGroup: wg,
})
}
wg.Wait()
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) })
var (
c = New()
c = New(withResolver(newResolver(1, withSkipVerify())))
ts = httptest.NewServer(hdl)
)
t.Cleanup(ts.Close)
c.skipValidation = true
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:
@ -41,13 +39,11 @@ func TestMaxRedirects(t *testing.T) {
})
var (
c = New()
c = New(withResolver(newResolver(1, withSkipVerify())))
ts = httptest.NewServer(hdl)
)
t.Cleanup(ts.Close)
c.skipValidation = true
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
@ -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!",
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
{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},
} {
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) {
var (
c = New()
uas = map[string]int{}
)
uas := map[string]int{}
for i := 0; i < 10; i++ {
uas[c.userAgent()]++
uas[defaultResolver.userAgent()]++
}
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/clause"
"github.com/Luzifer/go_helpers/v2/backoff"
"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/twitch"
)
@ -47,9 +49,12 @@ func New(db database.Connector) (*Service, error) {
)
}
func (s Service) GetBotUsername() (string, error) {
var botUsername string
err := s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername)
func (s *Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &extendedPermission{})
}
func (s Service) GetBotUsername() (botUsername string, err error) {
err = s.db.ReadCoreMeta(coreMetaKeyBotUsername, &botUsername)
return botUsername, errors.Wrap(err, "reading bot username")
}
@ -59,11 +64,15 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
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) {
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
@ -145,11 +154,14 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
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) {
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 {
@ -200,13 +212,14 @@ func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (boo
return true, nil
}
func (s Service) ListPermittedChannels() ([]string, error) {
func (s Service) ListPermittedChannels() (out []string, err error) {
var perms []extendedPermission
if err := s.db.DB().Find(&perms).Error; err != nil {
return nil, errors.Wrap(err, "listing permissions")
if err = helpers.Retry(func() error {
return errors.Wrap(s.db.DB().Find(&perms).Error, "listing permissions")
}); err != nil {
return nil, err
}
var out []string
for _, perm := range perms {
out = append(out, perm.Channel)
}
@ -216,14 +229,18 @@ func (s Service) ListPermittedChannels() ([]string, error) {
func (s Service) RemoveAllExtendedTwitchCredentials() error {
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",
)
}
func (s Service) RemoveExendedTwitchCredentials(channel string) error {
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",
)
}
@ -245,15 +262,17 @@ func (s Service) SetExtendedTwitchCredentials(channel, accessToken, refreshToken
}
return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}},
DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "scopes"}),
}).Create(extendedPermission{
Channel: strings.TrimLeft(channel, "#"),
AccessToken: accessToken,
RefreshToken: refreshToken,
Scopes: strings.Join(scope, " "),
}).Error,
helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}},
DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "scopes"}),
}).Create(extendedPermission{
Channel: strings.TrimLeft(channel, "#"),
AccessToken: accessToken,
RefreshToken: refreshToken,
Scopes: strings.Join(scope, " "),
}).Error
}),
"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/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/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")
}
func (s *Service) CopyDatabase(src, target *gorm.DB) error {
return database.CopyObjects(src, target, &timer{})
}
func (s *Service) UpdatePermitTimeout(d time.Duration) {
s.permitTimeout = d
}
@ -84,7 +90,13 @@ func (Service) getPermitTimerKey(channel, username string) string {
func (s Service) HasTimer(id string) (bool, error) {
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 {
case err == 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 {
return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"expires_at"}),
}).Create(timer{
ID: id,
ExpiresAt: expiry.UTC(),
}).Error,
helpers.RetryTransaction(s.db.DB(), func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"expires_at"}),
}).Create(timer{
ID: id,
ExpiresAt: expiry.UTC(),
}).Error
}),
"storing counter in database",
)
}
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")
}
}

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"
"net"
"net/http"
"net/http/pprof"
"os"
"strings"
"sync"
@ -22,6 +23,7 @@ import (
"github.com/Luzifer/rconfig/v2"
"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/authcache"
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -68,6 +70,7 @@ var (
db database.Connector
accessService *access.Service
authService *authcache.Service
timerService *timer.Service
twitchClient *twitch.Client
@ -135,6 +138,11 @@ func main() {
log.WithError(err).Fatal("applying access migration")
}
authService = authcache.New(
authBackendInternalToken,
authBackendTwitchToken,
)
cronService = cron.New(cron.WithSeconds())
if timerService, err = timer.New(db, cronService); err != nil {
@ -174,6 +182,20 @@ func main() {
router.HandleFunc("/openapi.json", handleSwaggerRequest)
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) {
if r.Method == http.MethodOptions {
// Most likely JS client asking for CORS headers

12
package-lock.json generated
View file

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

View file

@ -42,7 +42,7 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
switch driverName {
case "mysql":
mysqlDriver.SetLogger(newLogrusLogWriterWithLevel(logrus.ErrorLevel, driverName))
mysqlDriver.SetLogger(NewLogrusLogWriterWithLevel(logrus.StandardLogger(), logrus.ErrorLevel, driverName))
innerDB = mysql.Open(connString)
dbTuner = tuneMySQLDatabase
@ -63,7 +63,13 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
db, err := gorm.Open(innerDB, &gorm.Config{
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 {
return nil, errors.Wrap(err, "connecting database")
@ -83,10 +89,13 @@ func New(driverName, connString, encryptionSecret string) (Connector, error) {
}
func (c connector) Close() error {
// return errors.Wrap(c.db.Close(), "closing database")
return nil
}
func (c connector) CopyDatabase(src, target *gorm.DB) error {
return CopyObjects(src, target, &coreKV{})
}
func (c connector) DB() *gorm.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"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
)
const (
@ -31,7 +32,9 @@ type (
// DeleteCoreMeta removes a core_kv table entry
func (c connector) DeleteCoreMeta(key string) error {
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",
)
}
@ -61,7 +64,9 @@ func (c connector) ReadEncryptedCoreMeta(key string, value any) error {
// ResetEncryptedCoreMeta removes all CoreKV entries from the database
func (c connector) ResetEncryptedCoreMeta() error {
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",
)
}
@ -110,11 +115,14 @@ func (c connector) ValidateEncryption() error {
func (c connector) readCoreMeta(key string, value any, processor func(string) (string, error)) (err error) {
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) {
return ErrCoreMetaNotFound
}
return errors.Wrap(err, "querying core meta table")
}); err != nil {
return err
}
if data.Value == "" {
@ -149,10 +157,12 @@ func (c connector) storeCoreMeta(key string, value any, processor func(string) (
data := coreKV{Name: key, Value: encValue}
return errors.Wrap(
c.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(data).Error,
helpers.RetryTransaction(c.db, func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(data).Error
}),
"upserting core meta value",
)
}

View file

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

View file

@ -8,18 +8,18 @@ import (
)
type (
logWriter struct{ io.Writer }
LogWriter struct{ io.Writer }
)
func newLogrusLogWriterWithLevel(level logrus.Level, dbDriver string) logWriter {
writer := logrus.WithField("database", dbDriver).WriterLevel(level)
return logWriter{writer}
func NewLogrusLogWriterWithLevel(logger *logrus.Logger, level logrus.Level, dbDriver string) LogWriter {
writer := logger.WithField("database", dbDriver).WriterLevel(level)
return LogWriter{writer}
}
func (l logWriter) Print(a ...any) {
func (l LogWriter) Print(a ...any) {
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...)
}

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 (
eventsubLiveSocketDest = "wss://eventsub.wss.twitch.tv/ws"
socketConnectTimeout = 15 * time.Second
socketInitialTimeout = 30 * time.Second
socketTimeoutGraceMultiplier = 1.5
)
@ -166,7 +167,8 @@ func (e *EventSubSocketClient) Run() error {
errC = make(chan error, 1)
keepaliveTimeout = socketInitialTimeout
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 {
@ -187,9 +189,14 @@ func (e *EventSubSocketClient) Run() error {
return err
}
case <-socketTimeout.C:
case <-timeoutC:
// 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 {
errC <- errors.Wrap(err, "re-connecting after timeout")
continue
@ -198,7 +205,7 @@ func (e *EventSubSocketClient) Run() error {
case msg := <-msgC:
// The keepalive timer is reset with each notification or
// keepalive message.
socketTimeout.Reset(keepaliveTimeout)
socketTimeout.Renew(keepaliveTimeout)
switch msg.Metadata.MessageType {
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 {
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 {
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)
}
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

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
// its segments represening single planned streams
ChannelStreamSchedule struct {
Segments []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"`
} `json:"segments"`
BroadcasterID string `json:"broadcaster_id"`
BroadcasterName string `json:"broadcaster_name"`
BroadcasterLogin string `json:"broadcaster_login"`
Segments []ChannelStreamScheduleSegment `json:"segments"`
BroadcasterID string `json:"broadcaster_id"`
BroadcasterName string `json:"broadcaster_name"`
BroadcasterLogin string `json:"broadcaster_login"`
Vacation struct {
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
} `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

View file

@ -90,12 +90,22 @@ type (
// ValidateStatus is the default validation function used when no
// 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
// before in order to have the response body available in the returned
// HTTPError
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 {
body, err := io.ReadAll(resp.Body)
if err != nil {

View file

@ -5,6 +5,7 @@ import (
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -38,6 +39,8 @@ type (
CronRegistrationFunc func(spec string, cmd func()) (cron.EntryID, error)
DatabaseCopyFunc func(src, target *gorm.DB) error
EventHandlerFunc func(evt string, eventData *FieldCollection) error
EventHandlerRegisterFunc func(EventHandlerFunc) error
@ -83,6 +86,10 @@ type (
RegisterActorDocumentation ActorDocumentationRegistrationFunc
// RegisterAPIRoute registers a new HTTP handler function including documentation
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 CronRegistrationFunc
// RegisterEventHandler is a method to register a handler function receiving ALL events

View file

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

View file

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