mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +00:00
Compare commits
17 commits
1a76b75968
...
e21fd41e49
Author | SHA1 | Date | |
---|---|---|---|
e21fd41e49 | |||
35bc4fcdc6 | |||
5ec6baaf2c | |||
a07ad6fe83 | |||
ee5e7359a2 | |||
3c158ef231 | |||
a336772303 | |||
6df8fd42c2 | |||
0d10b5165f | |||
a1fa9972a8 | |||
3bff986ac4 | |||
e7a493cafe | |||
4059f089e0 | |||
9ebdaa8a71 | |||
cb68b029ec | |||
a2ffc25a26 | |||
932e6907da |
58 changed files with 1848 additions and 774 deletions
61
.github/workflows/integration-crdb.yml
vendored
Normal file
61
.github/workflows/integration-crdb.yml
vendored
Normal 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
|
||||
|
||||
...
|
64
.github/workflows/integration-mariadb.yml
vendored
Normal file
64
.github/workflows/integration-mariadb.yml
vendored
Normal 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
64
.github/workflows/integration-mysql.yml
vendored
Normal 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
|
||||
|
||||
...
|
54
.github/workflows/integration-postgres.yml
vendored
Normal file
54
.github/workflows/integration-postgres.yml
vendored
Normal 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
|
||||
|
||||
...
|
102
.github/workflows/test-and-build.yml
vendored
102
.github/workflows/test-and-build.yml
vendored
|
@ -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
|
||||
|
||||
...
|
||||
|
|
21
History.md
21
History.md
|
@ -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]
|
||||
|
|
11
README.md
11
README.md
|
@ -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
|
||||
|
|
87
actions.go
87
actions.go
|
@ -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
64
authBackends.go
Normal 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
60
authMiddleware.go
Normal 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
67
cli_migrateDatabase.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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
47
go.mod
|
@ -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
112
go.sum
|
@ -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=
|
||||
|
|
|
@ -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{} })
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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, "e{})
|
||||
})
|
||||
|
||||
formatMessage = args.FormatMessage
|
||||
send = args.SendMessage
|
||||
|
||||
|
|
|
@ -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("e{}, "channel = ? AND created_at = ?", channel, createdAt).Error,
|
||||
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
|
||||
return tx.Delete("e{}, "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("e{}).
|
||||
Where("channel = ?", channel).
|
||||
Count(&count).
|
||||
Error; err != nil {
|
||||
if err := helpers.Retry(func() error {
|
||||
return db.DB().
|
||||
Model("e{}).
|
||||
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("e{}).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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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{} })
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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{
|
||||
|
|
80
internal/apimodules/raffle/actor.go
Normal file
80
internal/apimodules/raffle/actor.go
Normal 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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
25
internal/helpers/retry.go
Normal 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) })
|
||||
}
|
|
@ -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()]
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
206
internal/linkcheck/resolver.go
Normal file
206
internal/linkcheck/resolver.go
Normal 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()]
|
||||
}
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
140
internal/service/authcache/authcache.go
Normal file
140
internal/service/authcache/authcache.go
Normal 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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
39
internal/template/twitch/schedule.go
Normal file
39
internal/template/twitch/schedule.go
Normal 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
22
main.go
|
@ -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
12
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
42
pkg/database/copyhelper.go
Normal file
42
pkg/database/copyhelper.go
Normal 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
|
||||
}
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
33
pkg/twitch/auth.go
Normal 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
|
||||
}
|
|
@ -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
39
pkg/twitch/keepalive.go
Normal 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{}{}
|
||||
}
|
|
@ -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 broadcaster’s streaming schedule
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
104
writeAuth.go
104
writeAuth.go
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue