Compare commits

...

45 Commits

Author SHA1 Message Date
Ash Keel 5bdc05ced8
ci: update image for build ci
Build / build (push) Successful in 1m53s Details
Test / test (push) Successful in 35s Details
2024-04-20 23:12:23 +02:00
Ash Keel 06f7e9be98
ci: upx!
Build / build (push) Successful in 1m53s Details
Test / test (push) Successful in 35s Details
2024-04-20 23:06:20 +02:00
Ash Keel bba0bce1ec
fix: prevent migration from breaking new installs 2024-04-20 23:05:54 +02:00
Ash Keel 2b7f332d88
ci: fu
Build / build (push) Has been cancelled Details
Test / test (push) Has been cancelled Details
2024-04-20 22:36:25 +02:00
Ash Keel ad0e93b4c0
ci: fuck minio now tbh
Build / build (push) Has been cancelled Details
Test / test (push) Has been cancelled Details
2024-04-20 21:59:55 +02:00
Ash Keel 9642ef52e6
ci: say it with me again: fuck you forgejo
Build / build (push) Has been cancelled Details
Test / test (push) Has been cancelled Details
2024-04-20 21:43:21 +02:00
Ash Keel 02099a34b0
ci: say it with me: fuck you forgejo
Build / build (push) Has been cancelled Details
Test / test (push) Has been cancelled Details
2024-04-20 21:42:43 +02:00
Ash Keel 7db0f43e90
ci: omre
Build / build (push) Successful in 2m3s Details
Test / test (push) Successful in 35s Details
2024-04-20 21:31:15 +02:00
Ash Keel 40b51ed69e
ci: upload to minio
Test / test (push) Waiting to run Details
Build / build (push) Has been cancelled Details
2024-04-20 21:30:51 +02:00
Ash Keel 732eda6f1f
ci: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Build / build (push) Successful in 5m15s Details
Test / test (push) Successful in 38s Details
2024-04-20 21:09:26 +02:00
Ash Keel e5489ca54c
ci: AAAAAAAAAAAAA
Build / build (push) Failing after 41s Details
Test / test (push) Successful in 35s Details
2024-04-20 18:50:09 +02:00
Ash Keel 2debea3f11
screw ghci
Build / release (push) Failing after 1m8s Details
Test / test (push) Successful in 37s Details
2024-04-20 17:54:27 +02:00
Ash Keel d13960a00e
ci: fix image url
Build / release (push) Failing after 1s Details
Test / test (push) Successful in 1m16s Details
2024-04-20 17:49:33 +02:00
Ash Keel 455fddb031
fix: dont crash when the webserver restarts
Build / release (push) Failing after 1s Details
Test / test (push) Has been cancelled Details
2024-04-20 17:48:39 +02:00
Ash Keel 4eee1ccc12
ci: use proper image
Build / release (push) Failing after 0s Details
Test / test (push) Failing after 58s Details
2024-04-20 17:42:38 +02:00
Ash Keel d3496d5d39
ci: oops
Build / build (push) Failing after 1m59s Details
Test / test (push) Has been cancelled Details
2024-04-20 16:59:25 +02:00
Ash Keel 4c5bf8fd78
ci: new setup
Test / test (push) Waiting to run Details
Build / build (push) Failing after 21s Details
2024-04-20 16:56:26 +02:00
Ash Keel dac126e891
fix: properly switch to a newly set custom account
continuous-integration/drone/tag Build is failing Details
2024-04-20 16:19:56 +02:00
Ash Keel fe02999663
feat: add old subscription cleanup routine 2024-04-20 16:07:54 +02:00
Ash Keel 7f6e14cd48
fix: update kilovolt with less concurrency issues 2024-04-20 16:07:33 +02:00
Ash Keel c186c0b942
fix: remove unneeded reset of the twitch client when authorizing new chat user 2024-04-20 16:07:11 +02:00
Ash Keel 422c70c9d4
add text copy for chat account 2024-04-20 15:00:07 +02:00
Ash Keel 2cec7b1ffe
fix: update kilovolt with fixed callbacks concurrency 2024-04-20 14:51:45 +02:00
Ash Keel 9844c5480f
fix: reset chat account and fix onboarding 2024-04-20 14:51:30 +02:00
Ash Keel 2c2b98e58e
feat & fix chat account stuff 2024-04-18 21:20:50 +02:00
Ash Keel 3d0e824b4b
refactor: move logic around! 2024-04-02 23:15:57 +02:00
Ash Keel 07e3a00990
refactor: preparatory code for re-introducing custom account for chat responses 2024-04-02 10:41:28 +02:00
Ash Keel decccb9fed
chore: fix tests 2024-03-21 20:58:44 +01:00
Ash Keel 019e558b22
fix: fix recent event list being wiped when a new event arrived 2024-03-21 20:58:27 +01:00
Ash Keel bc83a743f3
fix: sane errors 2024-03-16 01:20:15 +01:00
Ash Keel ce2ce81768
refactor: remove jsoniter 2024-03-15 23:48:34 +01:00
Ash Keel edcc4fb7f9
fix: use log ID 2024-03-14 13:48:15 +01:00
Ash Keel 97a81373ab
docs: update changelog 2024-03-14 13:48:05 +01:00
Ash Keel f4930d7758
feat: update kilovolt, replace zap with slog 2024-03-14 13:33:52 +01:00
Ash Keel a06b9457ea
refactor: ctx over cancel 2024-03-13 00:50:59 +01:00
Ash Keel 31d44b950e
feat: migration and more chat stuff 2024-03-12 23:39:18 +01:00
Ash Keel 0d1c60451b
feat: WIP twitch rework 2024-03-10 17:38:18 +01:00
Ash Keel bcdecf50c0
feat: move to a multi-key system for events 2024-02-29 21:44:17 +01:00
Ash Keel f35f3f0458
feat: add problem detection system 2024-02-29 21:19:23 +01:00
Ash Keel 3c3ea7bdb4
fix: use non-native tooltip for recent event 2024-02-25 21:20:08 +01:00
Ash Keel b5f5c2975c
fix: woops this is different 2024-02-25 16:56:37 +01:00
Ash Keel 82b7d51df7
refactor: minor lint fixes 2024-02-25 14:58:35 +01:00
Ash Keel fbb943f307
refactor: minor lint fixes 2024-02-25 14:46:59 +01:00
Ash Keel e34974aaa3
chore: update frontend dependencies 2024-02-23 10:36:50 +01:00
Ash Keel ab7b8d48f9
fix: update Go dependencies and replace 'single' package with Wails single instance lock 2024-02-23 09:56:19 +01:00
108 changed files with 6369 additions and 5766 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
*.exe
*.db
*.db.lock
/data/
/backups/
.vscode
.idea
strimertul_*
/build/bin/
*.log
api.json

View File

@ -8,7 +8,7 @@ trigger:
steps:
- name: Build
image: ghcr.io/abjrcode/cross-wails:v2.6.0
image: ghcr.io/abjrcode/cross-wails:v2.8.1
commands:
- apt-get update
- apt-get install -y upx

View File

@ -0,0 +1,22 @@
name: Build
on:
push:
workflow_dispatch:
env:
GOPRIVATE: git.sr.ht
jobs:
build:
runs-on: docker
container:
image: ghcr.io/ashkeel/cross-wails:v2.8.1@sha256:205e8cd7128765972de385919a0d9b389dc20541569d7c7b2251b964ed1110fe
credentials:
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Build
run: wails build

View File

@ -0,0 +1,42 @@
name: Release new version
on:
push:
tags:
- "v*.*.*"
- "v*.*.*-alpha.*"
- "v*.*.*-beta.*"
- "v*.*.*-rc.*"
workflow_dispatch:
env:
GOPRIVATE: git.sr.ht
jobs:
release:
runs-on: docker
container:
image: ghcr.io/ashkeel/cross-wails:v2.8.1@sha256:205e8cd7128765972de385919a0d9b389dc20541569d7c7b2251b964ed1110fe
credentials:
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Build linux/amd64 release
run: GOOS=linux GOARCH=amd64 CC=x86_64-linux-gnu-gcc wails build -ldflags "-X main.appVersion=${GITHUB_REF_NAME}" -platform linux/amd64 -upx -upxflags "-9" -o strimertul-amd64
- name: Build linux/arm64 release
run: GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc wails build -ldflags "-X main.appVersion=${GITHUB_REF_NAME}" -m -nosyncgomod -skipbindings -s -platform linux/arm64 -upx -upxflags "-9" -o strimertul-arm64
- name: Build windows/amd64 release
run: GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc wails build -ldflags "-X main.appVersion=${GITHUB_REF_NAME}" -m -nosyncgomod -skipbindings -s -platform windows/amd64 -upx -upxflags "-9"
- name: Move binaries from build/bin
run: mkdir "${GITHUB_REF_NAME}" && mv build/bin/strimertul* "${GITHUB_REF_NAME}/"
- name: Upload binaries to MinIO
uses: https://github.com/yakubique/minio-upload@v1.1.3
with:
endpoint: https://artifacts.fromouter.space
access_key: ${{ secrets.MINIO_ACCESS }}
secret_key: ${{ secrets.MINIO_SECRET }}
bucket: strimertul-builds
source: ./${{ github.ref_name }}/
target: /${{ github.ref_name }}/
recursive: true

View File

@ -0,0 +1,20 @@
name: Test
on:
push:
workflow_dispatch:
env:
GOPRIVATE: git.sr.ht
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Test
run: go test -v ./...

View File

@ -1,21 +0,0 @@
name: Build
on:
push:
pull_request:
workflow_dispatch:
env:
GOPRIVATE: git.sr.ht
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ashkeel/wails-build-action@bf2d6a3c440266e9bb8b3527a4b1db1896dccbab
with:
build-name: strimertul
build-platform: linux/amd64
package: false
go-version: "1.21"

View File

@ -1,53 +0,0 @@
name: Release
on:
push:
tags:
- "v*.*.*"
- "v*.*.*-beta.*"
- "v*.*.*-rc.*"
env:
GOPRIVATE: git.sr.ht
jobs:
release:
strategy:
fail-fast: false
matrix:
build:
[
{
name: strimertul-linux-amd64,
platform: linux/amd64,
os: ubuntu-latest,
},
{ name: "strimertul", platform: windows/amd64, os: windows-latest },
{
name: "strimertul",
platform: darwin/universal,
os: macos-latest,
},
]
runs-on: ${{ matrix.build.os }}
steps:
- uses: actions/checkout@v2
- name: Process version tag
id: version
uses: ncipollo/semantic-version-action@v1
- name: Update wails.json fileVersion
uses: jossef/action-set-json-field@v2.1
with:
file: wails.json
field: info.productVersion
value: "${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }}.${{ steps.version.outputs.patch }}"
- uses: ashkeel/wails-build-action@0faaf35c690d88c3463349c6bf0bbdc53af5e5a8
with:
build-name: ${{ matrix.build.name }}
build-platform: ${{ matrix.build.platform }}
windows-nsis-installer: false
macos-package-file-name: strimertul
macos-package-type: dmg
go-version: "1.21"
draft: true
ldflags: "-X main.appVersion=${{ github.ref_name }}"

View File

@ -1,21 +0,0 @@
name: Test
on:
push:
pull_request:
workflow_dispatch:
env:
GOPRIVATE: git.sr.ht
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Test
run: go test -v ./...

12
.golangci.yml Normal file
View File

@ -0,0 +1,12 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- revive
- errorlint
- errname
- contextcheck

View File

@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## current
### Added
- The UI sidebar has been modified to better adapt to small window sizes
- A new part of the dashboard will now inform the user if any configuration problems have been detected.
### Changed
- The required set of permissions has changed. Existing users must re-authenticate their users to the app connected to strimertül.
- The `twitch/ev/eventsub-event` and `twitch/eventsub-history` keys have been replaced by a set of keys in the format `twitch/ev/eventsub-event/<event-id>` and `twitch/eventsub-history/<event-id>`. Users of the old system will have to adjust their logic. A simple trick is to change from get/subscribing from a single key to the entire prefix. The data structure is the same.
- The `twitch/bot/@send-message` key has been renamed to `twitch/chat/@send-message`. The data structure is the same.
- A lot of keys for internal use have been changed, make sure to check the new reference for fixing up any integrations you might have. A migration process will convert v3 keys to v4 keys.
- The log format has changed significantly as the internal logging library has been replaced.
- The Twitch chat integration has been rewritten from the ground up to not use an IRC bot and rely on EventSub. This means that you will need to reconfigure your twitch account, especially if you used a different account as the "bot" account. Because of this rewrite, the terminology around chat functionalities have been renamed from "Bot" to "Chat" (e.g. "Bot commands" are now "Chat commands").
- The (i) icon next to "Recent events" in the dashboard now uses a custom tooltip that shows up more consistently.
- The "strimertul is already running" message now pops up from the currently running instance.
- Setting up a secondary user for chat interactions is now much simpler through an auth flow much like the one for setting up the main user.
### Fixed
- Updated Kilovolt to a version that fixes an annoying crash when managing subscriptions.
- A new cleanup routine will remove old eventsub subscriptions that are no longer used. This should remove most of the errors when trying to set new API keys about "too many subscriptions".
### Removed
- `twitch/@send-chat-message` has been removed. Use `twitch/chat/@send-message` instead.
- The `twitch/ev/chat-message` and `twitch/chat-history` keys have been removed. Use the EventSub keys `twitch/ev/eventsub-event/channel.chat.message` and `twitch/eventsub-history/channel.chat.message` instead. The data structure will be different!
## 3.3.1 - 2023-11-12
### Changed

View File

@ -1,4 +1,4 @@
ARG BASE_IMAGE=ghcr.io/abjrcode/cross-wails:v2.6.0
ARG BASE_IMAGE=ghcr.io/abjrcode/cross-wails:v2.8.0
FROM ${BASE_IMAGE} as builder

View File

@ -23,7 +23,7 @@ You can also build the project yourself, refer to the Building section below.
Strimertül is a single executable app that provides the following:
- HTTP server for serving static assets and a websocket API
- Twitch bot for handling chat messages and providing custom commands
- Twitch EventSub handlers for chat functionalities such as custom commands and alerts
- Polling-based loyalty system for rewards and community goals
At strimertül's core is [Kilovolt](https://git.sr.ht/~ashkeel/kilovolt), a pub/sub key-value store accessible via websocket. You can access every functionality of strimertul through the Kilovolt API. Check [this repository](https://github.com/strimertul/kilovolt-clients) for a list of officially supported kilovolt clients (or submit your own). You should be able to easily build a client yourself by just creating a websocket connection and using the [Kilovolt protocol](https://github.com/strimertul/kilovolt/blob/main/PROTOCOL.md).
@ -54,6 +54,4 @@ To build a redistributable, production mode package, use `wails build`.
## License
Kilovolt's code is based on Gorilla Websocket's server example, licensed under [BSD-2-Clause](https://github.com/gorilla/websocket/blob/master/LICENSE)
The entire project is licensed under [AGPL-3.0-only](LICENSE) (see `LICENSE`).

161
app.go
View File

@ -3,46 +3,48 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/slog"
"mime/multipart"
"net/http"
"os"
"runtime/debug"
"strconv"
kv "github.com/strimertul/kilovolt/v11"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/containers/sync"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
"github.com/nicklaw5/helix/v2"
"github.com/postfinance/single"
"github.com/urfave/cli/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/docs"
"git.sr.ht/~ashkeel/strimertul/loyalty"
"git.sr.ht/~ashkeel/strimertul/migrations"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
// App struct
type App struct {
ctx context.Context
lock *single.Single
cliParams *cli.Context
driver database.DatabaseDriver
driver database.Driver
ready *sync.RWSync[bool]
isFatalError *sync.RWSync[bool]
backupOptions database.BackupOptions
cancelLogs database.CancelFunc
logger *slog.Logger
db *database.LocalDBClient
twitchManager *twitch.Manager
twitchManager *client.Manager
httpServer *webserver.WebServer
loyaltyManager *loyalty.Manager
}
@ -53,30 +55,15 @@ func NewApp(cliParams *cli.Context) *App {
cliParams: cliParams,
ready: sync.NewRWSync(false),
isFatalError: sync.NewRWSync(false),
logger: slog.Default(),
}
}
// startup is called when the app starts
func (a *App) startup(ctx context.Context) {
// Ensure only one copy of strimertul is running at all times
var err error
a.lock, err = single.New("strimertul")
if err != nil {
log.Fatal(err)
}
if err = a.lock.Lock(); err != nil {
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "strimertul is already running",
Message: "Only one copy of strimertul can run at the same time, make sure to close other instances first",
})
log.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
a.stop(ctx)
_ = logger.Sync()
switch v := r.(type) {
case error:
a.showFatalError(v, v.Error())
@ -86,7 +73,7 @@ func (a *App) startup(ctx context.Context) {
}
}()
logger.Info("Started", zap.String("version", appVersion))
slog.Info("Started", slog.String("version", appVersion))
a.ctx = ctx
@ -97,13 +84,19 @@ func (a *App) startup(ctx context.Context) {
}
// Initialize database
if err = a.initializeDatabase(); err != nil {
if err := a.initializeDatabase(); err != nil {
a.showFatalError(err, "Failed to initialize database")
return
}
// Check for migrations
if err := migrations.Run(a.driver, a.db, slog.With(slog.String("operation", "migration"))); err != nil {
a.showFatalError(err, "Failed to migrate database to latest version")
return
}
// Initialize components
if err = a.initializeComponents(); err != nil {
if err := a.initializeComponents(); err != nil {
a.showFatalError(err, "Failed to initialize required component")
return
}
@ -113,14 +106,14 @@ func (a *App) startup(ctx context.Context) {
a.ready.Set(true)
runtime.EventsEmit(ctx, "ready", true)
logger.Info("Strimertul is ready")
slog.Info("Strimertul is ready")
// Add logs I/O to UI
_, a.cancelLogs = a.listenForLogs()
a.cancelLogs, _ = a.listenForLogs()
go a.forwardLogs()
// Run HTTP server
if err = a.httpServer.Listen(); err != nil {
if err := a.httpServer.Listen(); err != nil {
a.showFatalError(err, "HTTP server stopped")
return
}
@ -144,7 +137,7 @@ func (a *App) initializeDatabase() error {
go hub.Run()
hub.UseInteractiveAuth(a.interactiveAuth)
a.db, err = database.NewLocalClient(hub, logger)
a.db, err = database.NewLocalClient(hub)
if err != nil {
return fmt.Errorf("could not initialize database client: %w", err)
}
@ -156,19 +149,31 @@ func (a *App) initializeComponents() error {
var err error
// Create logger and endpoints
a.httpServer, err = webserver.NewServer(a.db, logger, webserver.DefaultServerFactory)
a.httpServer, err = webserver.NewServer(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "webserver"))),
a.db,
webserver.DefaultServerFactory,
)
if err != nil {
return fmt.Errorf("could not initialize http server: %w", err)
}
// Create twitch client
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
a.twitchManager, err = client.NewManager(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "twitch"))),
a.db,
a.httpServer,
)
if err != nil {
return fmt.Errorf("could not initialize twitch client: %w", err)
}
// Initialize loyalty system
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
a.loyaltyManager, err = loyalty.NewManager(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "logger"))),
a.db,
a.twitchManager,
)
if err != nil {
return fmt.Errorf("could not initialize loyalty manager: %w", err)
}
@ -176,42 +181,35 @@ func (a *App) initializeComponents() error {
return nil
}
func (a *App) listenForLogs() (error, database.CancelFunc) {
func (a *App) listenForLogs() (database.CancelFunc, error) {
return a.db.SubscribeKey(docs.LogRPCKey, func(newValue string) {
var entry docs.ExternalLog
if err := json.Unmarshal([]byte(newValue), &entry); err != nil {
return
}
level, err := zapcore.ParseLevel(string(entry.Level))
if err != nil {
level = zapcore.InfoLevel
var level slog.Level
if err := level.UnmarshalText([]byte(entry.Level)); err != nil {
level = slog.LevelInfo
}
fields := parseAsFields(entry.Data)
logger.Log(level, entry.Message, fields...)
slog.Log(a.ctx, level, entry.Message, log.ParseLogFields(entry.Data)...)
})
}
func (a *App) forwardLogs() {
for entry := range incomingLogs {
for entry := range log.IncomingLogs {
runtime.EventsEmit(a.ctx, "log-event", entry)
}
}
func (a *App) stop(context.Context) {
func (a *App) stop(_ context.Context) {
if a.cancelLogs != nil {
a.cancelLogs()
}
if a.lock != nil {
warnOnError(a.lock.Unlock(), "Could not remove lock file")
}
if a.loyaltyManager != nil {
warnOnError(a.loyaltyManager.Close(), "Could not cleanly close loyalty manager")
}
if a.twitchManager != nil {
warnOnError(a.twitchManager.Close(), "Could not cleanly close twitch client")
}
if a.httpServer != nil {
warnOnError(a.httpServer.Close(), "Could not cleanly close HTTP server")
}
@ -225,7 +223,7 @@ func (a *App) AuthenticateKVClient(id string) {
if err != nil {
return
}
warnOnError(a.driver.Hub().SetAuthenticated(idInt, true), "Could not mark session as authenticated", zap.String("session-id", id))
warnOnError(a.driver.Hub().SetAuthenticated(idInt, true), "Could not mark session as authenticated", slog.String("session-id", id))
}
func (a *App) IsServerReady() bool {
@ -243,16 +241,30 @@ func (a *App) GetKilovoltBind() string {
return a.httpServer.Config.Get().Bind
}
func (a *App) GetTwitchAuthURL() string {
return a.twitchManager.Client().GetAuthorizationURL()
func (a *App) GetTwitchAuthURL(state string) string {
return twitch.GetAuthorizationURL(a.twitchManager.Client().API, state)
}
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
return a.twitchManager.Client().GetLoggedUser()
func (a *App) GetTwitchLoggedUser(key string) (helix.User, error) {
userClient, err := twitch.GetUserClient(a.db, key, false)
if err != nil {
return helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, err
}
if len(users.Data.Users) < 1 {
return helix.User{}, errors.New("no users found")
}
return users.Data.Users[0], nil
}
func (a *App) GetLastLogs() []LogEntry {
return lastLogs.Get()
func (a *App) GetLastLogs() []log.Entry {
return log.LastLogs.Get()
}
func (a *App) GetDocumentation() map[string]docs.KeyObject {
@ -265,34 +277,34 @@ func (a *App) SendCrashReport(errorData string, info string) (string, error) {
// Add text fields
if err := w.WriteField("error", errorData); err != nil {
logger.Error("Could not encode field error for crash report", zap.Error(err))
slog.Error("Could not encode field error for crash report", log.Error(err))
}
if len(info) > 0 {
if err := w.WriteField("info", info); err != nil {
logger.Error("Could not encode field info for crash report", zap.Error(err))
slog.Error("Could not encode field info for crash report", log.Error(err))
}
}
// Add log files
_ = logger.Sync()
addFile(w, "log", logFilename)
addFile(w, "paniclog", panicFilename)
addFile(w, "log", log.Filename)
addFile(w, "paniclog", log.PanicFilename)
if err := w.Close(); err != nil {
logger.Error("Could not prepare request for crash report", zap.Error(err))
slog.Error("Could not prepare request for crash report", log.Error(err))
return "", err
}
resp, err := http.Post(crashReportURL, w.FormDataContentType(), &b)
if err != nil {
logger.Error("Could not send crash report", zap.Error(err))
slog.Error("Could not send crash report", log.Error(err))
return "", err
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode != http.StatusOK {
byt, _ := io.ReadAll(resp.Body)
logger.Error("Crash report server returned error", zap.String("status", resp.Status), zap.String("response", string(byt)))
slog.Error("Crash report server returned error", slog.String("status", resp.Status), slog.String("response", string(byt)))
return "", fmt.Errorf("crash report server returned error: %s - %s", resp.Status, string(byt))
}
@ -314,7 +326,7 @@ func (a *App) GetAppVersion() VersionInfo {
}
func (a *App) TestTemplate(message string, data any) error {
tpl, err := a.twitchManager.Client().Bot.MakeTemplate(message)
tpl, err := a.twitchManager.Client().GetTemplateEngine().MakeTemplate(message)
if err != nil {
return err
}
@ -341,30 +353,37 @@ func (a *App) interactiveAuth(client kv.Client, message map[string]any) bool {
return <-authResult
}
func (a *App) showFatalError(err error, text string, fields ...zap.Field) {
func (a *App) showFatalError(err error, text string, fields ...any) {
if err != nil {
fields = append(fields, zap.Error(err))
fields = append(fields, zap.String("Z", string(debug.Stack())))
logger.Error(text, fields...)
fields = append(fields, log.ErrorSkip(err, 2), slog.String("Z", string(debug.Stack())))
slog.Error(text, fields...)
runtime.EventsEmit(a.ctx, "fatalError")
a.isFatalError.Set(true)
}
}
func (a *App) onSecondInstanceLaunch(_ options.SecondInstanceData) {
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "strimertul is already running",
Message: "Only one copy of strimertul can run at the same time, make sure to close other instances first",
})
}
func addFile(m *multipart.Writer, field string, filename string) {
logfile, err := m.CreateFormFile(field, filename)
if err != nil {
logger.Error("Could not encode field log for crash report", zap.Error(err))
slog.Error("Could not encode field log for crash report", log.Error(err))
return
}
file, err := os.Open(filename)
if err != nil {
logger.Error("Could not open file for including in crash report", zap.Error(err), zap.String("file", filename))
slog.Error("Could not open file for including in crash report", slog.String("file", filename), log.Error(err))
return
}
if _, err = io.Copy(logfile, file); err != nil {
logger.Error("Could not read from file for including in crash report", zap.Error(err), zap.String("file", filename))
slog.Error("Could not read from file for including in crash report", slog.String("file", filename), log.Error(err))
}
}

View File

@ -2,28 +2,29 @@ package main
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
)
func BackupTask(driver database.DatabaseDriver, options database.BackupOptions) {
func BackupTask(driver database.Driver, options database.BackupOptions) {
if options.BackupDir == "" {
logger.Warn("Backup directory not set, database backups are disabled")
slog.Warn("Backup directory not set, database backups are disabled")
return
}
err := os.MkdirAll(options.BackupDir, 0o755)
if err != nil {
logger.Error("Could not create backup directory, moving to a temporary folder", zap.Error(err))
slog.Error("Could not create backup directory, moving to a temporary folder", log.Error(err))
options.BackupDir = os.TempDir()
logger.Info("Using temporary directory", zap.String("backup-dir", options.BackupDir))
slog.Info("Using temporary directory", slog.String("backup-dir", options.BackupDir))
return
}
@ -34,25 +35,25 @@ func BackupTask(driver database.DatabaseDriver, options database.BackupOptions)
}
}
func performBackup(driver database.DatabaseDriver, options database.BackupOptions) {
func performBackup(driver database.Driver, options database.BackupOptions) {
// Run backup procedure
file, err := os.Create(fmt.Sprintf("%s/%s.db", options.BackupDir, time.Now().Format("20060102-150405")))
if err != nil {
logger.Error("Could not create backup file", zap.Error(err))
slog.Error("Could not create backup file", log.Error(err))
return
}
err = driver.Backup(file)
if err != nil {
logger.Error("Could not backup database", zap.Error(err))
slog.Error("Could not backup database", log.Error(err))
}
_ = file.Close()
logger.Info("Database backup created", zap.String("backup-file", file.Name()))
slog.Info("Database backup created", slog.String("backup-file", file.Name()))
// Remove old backups
files, err := os.ReadDir(options.BackupDir)
if err != nil {
logger.Error("Could not read backup directory", zap.Error(err))
slog.Error("Could not read backup directory", log.Error(err))
return
}
@ -65,7 +66,7 @@ func performBackup(driver database.DatabaseDriver, options database.BackupOption
for _, file := range toRemove {
err = os.Remove(fmt.Sprintf("%s/%s", options.BackupDir, file.Name()))
if err != nil {
logger.Error("Could not remove backup file", zap.Error(err))
slog.Error("Could not remove backup file", log.Error(err))
}
}
}
@ -80,7 +81,7 @@ type BackupInfo struct {
func (a *App) GetBackups() (list []BackupInfo) {
files, err := os.ReadDir(a.backupOptions.BackupDir)
if err != nil {
logger.Error("Could not read backup directory", zap.Error(err))
slog.Error("Could not read backup directory", log.Error(err))
return nil
}
@ -91,7 +92,7 @@ func (a *App) GetBackups() (list []BackupInfo) {
info, err := file.Info()
if err != nil {
logger.Error("Could not get info for backup file", zap.Error(err))
slog.Error("Could not get info for backup file", log.Error(err))
continue
}
@ -111,7 +112,7 @@ func (a *App) RestoreBackup(backupName string) error {
if err != nil {
return fmt.Errorf("could not open import file for reading: %w", err)
}
defer utils.Close(file, logger)
defer utils.Close(file)
inStream := file
if a.driver == nil {
@ -126,6 +127,6 @@ func (a *App) RestoreBackup(backupName string) error {
return fmt.Errorf("could not restore database: %w", err)
}
logger.Info("Restored database from backup")
slog.Info("Restored database from backup")
return nil
}

View File

@ -1,13 +1,14 @@
package main
import (
"encoding/json"
"log/slog"
"os"
"git.sr.ht/~ashkeel/strimertul/utils"
"github.com/urfave/cli/v2"
"git.sr.ht/~ashkeel/strimertul/database"
"github.com/urfave/cli/v2"
"git.sr.ht/~ashkeel/strimertul/utils"
)
func cliImport(ctx *cli.Context) error {
@ -18,7 +19,7 @@ func cliImport(ctx *cli.Context) error {
if err != nil {
return fatalError(err, "could not open import file for reading")
}
defer utils.Close(file, logger)
defer utils.Close(file)
inStream = file
}
var entries map[string]string
@ -37,7 +38,7 @@ func cliImport(ctx *cli.Context) error {
return fatalError(err, "import failed")
}
logger.Info("Imported database from file")
slog.Info("Imported database from file")
return nil
}
@ -49,7 +50,7 @@ func cliRestore(ctx *cli.Context) error {
if err != nil {
return fatalError(err, "could not open import file for reading")
}
defer utils.Close(file, logger)
defer utils.Close(file)
inStream = file
}
@ -63,7 +64,7 @@ func cliRestore(ctx *cli.Context) error {
return fatalError(err, "restore failed")
}
logger.Info("Restored database from backup")
slog.Info("Restored database from backup")
return nil
}
@ -75,7 +76,7 @@ func cliExport(ctx *cli.Context) error {
if err != nil {
return fatalError(err, "could not open output file for writing")
}
defer utils.Close(file, logger)
defer utils.Close(file)
outStream = file
}
@ -89,6 +90,6 @@ func cliExport(ctx *cli.Context) error {
return fatalError(err, "export failed")
}
logger.Info("Exported database")
slog.Info("Exported database")
return nil
}

View File

@ -1,18 +1,16 @@
package database
import (
"context"
"encoding/json"
"errors"
"fmt"
jsoniter "github.com/json-iterator/go"
kv "github.com/strimertul/kilovolt/v11"
"go.uber.org/zap"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
)
type CancelFunc func()
var json = jsoniter.ConfigFastest
var (
// ErrUnknown is returned when a response is received that doesn't match any expected outcome.
ErrUnknown = errors.New("unknown error")
@ -21,6 +19,26 @@ var (
ErrEmptyKey = errors.New("empty key")
)
type Database interface {
GetKey(key string) (string, error)
PutKey(key string, data string) error
RemoveKey(key string) error
SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error)
SubscribeKey(key string, fn func(string)) (cancelFn CancelFunc, err error)
SubscribePrefixContext(ctx context.Context, fn kv.SubscriptionCallback, prefixes ...string) error
SubscribeKeyContext(ctx context.Context, key string, fn func(string)) error
GetAll(prefix string) (map[string]string, error)
GetJSON(key string, dst any) error
PutJSON(key string, data any) error
PutJSONBulk(kvs map[string]any) error
Hub() *kv.Hub
}
type LocalDBClient struct {
client *kv.LocalClient
hub *kv.Hub
@ -31,9 +49,9 @@ type KvPair struct {
Data string
}
func NewLocalClient(hub *kv.Hub, logger *zap.Logger) (*LocalDBClient, error) {
func NewLocalClient(hub *kv.Hub) (*LocalDBClient, error) {
// Create local client
localClient := kv.NewLocalClient(kv.ClientOptions{}, logger)
localClient := kv.NewLocalClient(kv.ClientOptions{})
// Run client and add it to the hub
go localClient.Run()
@ -74,26 +92,52 @@ func (mod *LocalDBClient) PutKey(key string, data string) error {
return err
}
func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (err error, cancelFn CancelFunc) {
func (mod *LocalDBClient) SubscribePrefixContext(ctx context.Context, fn kv.SubscriptionCallback, prefixes ...string) error {
cancel, err := mod.SubscribePrefix(fn, prefixes...)
if err != nil {
return err
}
go func() {
<-ctx.Done()
cancel()
}()
return nil
}
func (mod *LocalDBClient) SubscribeKeyContext(ctx context.Context, key string, fn func(string)) error {
cancel, err := mod.SubscribeKey(key, fn)
if err != nil {
return err
}
go func() {
<-ctx.Done()
cancel()
}()
return nil
}
func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error) {
var ids []int64
for _, prefix := range prefixes {
_, err = mod.makeRequest(kv.CmdSubscribePrefix, map[string]any{"prefix": prefix})
if err != nil {
return err, nil
return nil, err
}
ids = append(ids, mod.client.SetPrefixSubCallback(prefix, fn))
}
return nil, func() {
return func() {
for _, id := range ids {
mod.client.UnsetCallback(id)
}
}
}, nil
}
func (mod *LocalDBClient) SubscribeKey(key string, fn func(string)) (err error, cancelFn CancelFunc) {
func (mod *LocalDBClient) SubscribeKey(key string, fn func(string)) (cancelFn CancelFunc, err error) {
_, err = mod.makeRequest(kv.CmdSubscribeKey, map[string]any{"key": key})
if err != nil {
return err, nil
return nil, err
}
id := mod.client.SetKeySubCallback(key, func(changedKey string, value string) {
if key != changedKey {
@ -101,9 +145,9 @@ func (mod *LocalDBClient) SubscribeKey(key string, fn func(string)) (err error,
}
fn(value)
})
return nil, func() {
return func() {
mod.client.UnsetCallback(id)
}
}, nil
}
func (mod *LocalDBClient) GetJSON(key string, dst any) error {

View File

@ -1,11 +1,12 @@
package database
import (
"encoding/json"
"errors"
"testing"
"time"
jsoniter "github.com/json-iterator/go"
kv "github.com/strimertul/kilovolt/v11"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
)
func TestLocalDBClientPutKey(t *testing.T) {
@ -58,7 +59,7 @@ func TestLocalDBClientPutJSON(t *testing.T) {
}
var testStored test
err = jsoniter.ConfigFastest.UnmarshalFromString(stored, &testStored)
err = json.Unmarshal([]byte(stored), &testStored)
if err != nil {
t.Fatal(err)
}
@ -105,13 +106,13 @@ func TestLocalDBClientPutJSONBulk(t *testing.T) {
}
var testStored1 test
err = jsoniter.ConfigFastest.UnmarshalFromString(keys["test"], &testStored1)
err = json.Unmarshal([]byte(keys["test"]), &testStored1)
if err != nil {
t.Fatal(err)
}
var testStored2 test
err = jsoniter.ConfigFastest.UnmarshalFromString(keys["test2"], &testStored2)
err = json.Unmarshal([]byte(keys["test2"]), &testStored2)
if err != nil {
t.Fatal(err)
}
@ -161,7 +162,6 @@ func TestLocalDBClientGetJSON(t *testing.T) {
A string
B int
}
testStruct := test{
A: "test",
B: 42,
@ -169,12 +169,12 @@ func TestLocalDBClientGetJSON(t *testing.T) {
// Store a key directly in the store
key := "test"
byt, err := jsoniter.ConfigFastest.MarshalToString(testStruct)
byt, err := json.Marshal(testStruct)
if err != nil {
t.Fatal(err)
}
err = store.Set(key, byt)
err = store.Set(key, string(byt))
if err != nil {
t.Fatal(err)
}
@ -251,7 +251,7 @@ func TestLocalDBClientRemoveKey(t *testing.T) {
_, err = store.Get(key)
if err == nil {
t.Fatal("expected key to be removed")
} else if err != kv.ErrorKeyNotFound {
} else if !errors.Is(err, kv.ErrorKeyNotFound) {
t.Fatalf("expected key to be removed, got %s", err)
}
}
@ -263,7 +263,7 @@ func TestLocalDBClientSubscribeKey(t *testing.T) {
// Subscribe to a key using the local client
key := "test"
ch := make(chan string, 1)
err, cancel := client.SubscribeKey(key, func(newValue string) {
cancel, err := client.SubscribeKey(key, func(newValue string) {
ch <- newValue
})
if err != nil {
@ -294,7 +294,7 @@ func TestLocalDBClientSubscribePrefix(t *testing.T) {
// Subscribe to a prefix using the local client
prefix := "test"
ch := make(chan string, 1)
err, cancel := client.SubscribePrefix(func(newKey, newValue string) {
cancel, err := client.SubscribePrefix(func(_, newValue string) {
ch <- newValue
}, prefix)
if err != nil {

View File

@ -6,15 +6,12 @@ import (
"os"
"path/filepath"
kv "github.com/strimertul/kilovolt/v11"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/utils"
)
// DatabaseDriver is a driver wrapping a supported database
type DatabaseDriver interface {
// Driver is a driver wrapping a supported database
type Driver interface {
Hub() *kv.Hub
Close() error
Import(map[string]string) error
@ -46,16 +43,15 @@ func getDatabaseDriverName(ctx *cli.Context) string {
return string(file)
}
func GetDatabaseDriver(ctx *cli.Context) (DatabaseDriver, error) {
func GetDatabaseDriver(ctx *cli.Context) (Driver, error) {
name := getDatabaseDriverName(ctx)
dbDirectory := ctx.String("database-dir")
logger := ctx.Context.Value(utils.ContextLogger).(*zap.Logger)
switch name {
case "badger":
return nil, cli.Exit("Badger is not supported anymore as a database driver", 64)
case "pebble":
db, err := NewPebble(dbDirectory, logger)
db, err := NewPebble(dbDirectory)
if err != nil {
return nil, cli.Exit(err.Error(), 64)
}

View File

@ -1,27 +1,27 @@
package database
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
pebbledriver "git.sr.ht/~ashkeel/kilovolt-driver-pebble"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
"git.sr.ht/~ashkeel/strimertul/utils"
pebble_driver "git.sr.ht/~ashkeel/kilovolt-driver-pebble"
"github.com/cockroachdb/pebble"
kv "github.com/strimertul/kilovolt/v11"
"go.uber.org/zap"
)
type PebbleDatabase struct {
db *pebble.DB
hub *kv.Hub
logger *zap.Logger
logger *slog.Logger
}
// NewPebble creates a new database driver instance with an underlying Pebble database
func NewPebble(directory string, logger *zap.Logger) (*PebbleDatabase, error) {
func NewPebble(directory string) (*PebbleDatabase, error) {
db, err := pebble.Open(directory, &pebble.Options{})
if err != nil {
return nil, fmt.Errorf("could not open DB: %w", err)
@ -34,9 +34,8 @@ func NewPebble(directory string, logger *zap.Logger) (*PebbleDatabase, error) {
}
p := &PebbleDatabase{
db: db,
hub: nil,
logger: logger,
db: db,
hub: nil,
}
return p, nil
@ -44,7 +43,9 @@ func NewPebble(directory string, logger *zap.Logger) (*PebbleDatabase, error) {
func (p *PebbleDatabase) Hub() *kv.Hub {
if p.hub == nil {
p.hub, _ = kv.NewHub(pebble_driver.NewPebbleBackend(p.db, true), kv.HubOptions{}, p.logger)
p.hub, _ = kv.NewHub(pebbledriver.NewPebbleBackend(p.db, true), kv.HubOptions{
Logger: p.logger,
})
}
return p.hub
}
@ -95,13 +96,13 @@ func (p *PebbleDatabase) Restore(file io.Reader) error {
func (p *PebbleDatabase) Backup(file io.Writer) error {
snapshot := p.db.NewSnapshot()
defer utils.Close(snapshot, p.logger)
defer utils.Close(snapshot)
iter, err := snapshot.NewIter(&pebble.IterOptions{})
if err != nil {
return err
}
defer utils.Close(iter, p.logger)
defer utils.Close(iter)
out := make(map[string]string)
for iter.First(); iter.Valid(); iter.Next() {

View File

@ -3,23 +3,20 @@ package database
import (
"testing"
kv "github.com/strimertul/kilovolt/v11"
"go.uber.org/zap/zaptest"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
)
func CreateInMemoryLocalClient(t *testing.T) (*LocalDBClient, kv.Driver) {
logger := zaptest.NewLogger(t)
// Create in-memory store and hub
inMemoryStore := kv.MakeBackend()
hub, err := kv.NewHub(inMemoryStore, kv.HubOptions{}, logger)
hub, err := kv.NewHub(inMemoryStore, kv.HubOptions{})
if err != nil {
t.Fatal(err)
}
go hub.Run()
// Create local client
client, err := NewLocalClient(hub, logger)
client, err := NewLocalClient(hub)
if err != nil {
t.Fatal(err)
}

View File

@ -1,14 +1,14 @@
package main
import (
"encoding/json"
"os"
"git.sr.ht/~ashkeel/strimertul/docs"
jsoniter "github.com/json-iterator/go"
)
func main() {
enc := jsoniter.ConfigFastest.NewEncoder(os.Stdout)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(docs.Keys)
}

View File

@ -3,7 +3,7 @@ package docs
import (
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
"git.sr.ht/~ashkeel/strimertul/loyalty"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/doc"
"git.sr.ht/~ashkeel/strimertul/utils"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
@ -25,12 +25,12 @@ func addKeys(keyMap interfaces.KeyMap) {
func init() {
// Put all enums here
utils.MergeMap(Enums, twitch.Enums)
utils.MergeMap(Enums, doc.Enums)
utils.MergeMap(Enums, enums)
// Put all keys here
addKeys(strimertulKeys)
addKeys(twitch.Keys)
addKeys(doc.Keys)
addKeys(loyalty.Keys)
addKeys(webserver.Keys)
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"type": "module",
"dependencies": {
"@fontsource/space-mono": "^5.0.17",
"@fontsource/space-mono": "^5.0.18",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/react-alert-dialog": "^1.0.5",
@ -18,27 +18,27 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-toolbar": "^1.0.4",
"@redux-devtools/extension": "^3.2.5",
"@reduxjs/toolkit": "^1.9.7",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "^2.2.1",
"@stitches/react": "^1.2.8",
"@strimertul/kilovolt-client": "^8.0.0",
"@types/node": "^20.8.9",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"@vitejs/plugin-react": "^4.1.0",
"i18next": "^23.6.0",
"inter-ui": "^3.19.3",
"@types/node": "^20.11.20",
"@types/react": "^18.2.58",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"i18next": "^23.10.0",
"inter-ui": "^4.0.2",
"normalize.css": "^8.0.1",
"postcss-import": "^15.1.0",
"postcss-import": "^16.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^13.3.1",
"react-redux": "^8.1.3",
"react-router-dom": "^6.17.0",
"redux-thunk": "^2.4.2",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-tsconfig-paths": "^4.2.1"
"react-i18next": "^14.0.5",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1",
"redux-thunk": "^3.1.0",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-tsconfig-paths": "^4.3.1"
},
"scripts": {
"start": "vite",
@ -49,15 +49,15 @@
"last 2 Chrome version"
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react-refresh": "^0.4.3",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "^3.0.3",
"rimraf": "^5.0.5"
}

View File

@ -1 +1 @@
fc1fa70c3a8ac4cc66b38592b8aaab6b
894313fcc17ff294db3c3171003cb162

View File

@ -4,6 +4,7 @@ import { HashRouter } from 'react-router-dom';
import { StrictMode } from 'react';
import 'inter-ui/inter.css';
import 'inter-ui/inter-variable.css';
import '@fontsource/space-mono/index.css';
import 'normalize.css/normalize.css';
import './locale/setup';

View File

@ -1,3 +1,6 @@
import { GetTwitchAuthURL } from '@wailsapp/go/main/App';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
export interface TwitchCredentials {
access_token: string;
refresh_token: string;
@ -57,3 +60,12 @@ export async function checkTwitchKeys(
throw new Error(`API test call failed: ${err.message}`);
}
}
/**
* Open the user's browser to authenticate with Twitch
* @param target What's the target of the authentication (stream/chat account)
*/
export async function startAuthFlow(target: string) {
const url = await GetTwitchAuthURL(target);
BrowserOpenURL(url);
}

View File

@ -24,9 +24,9 @@
},
"twitch": {
"configuration": "Configuration",
"bot-commands": "Chat commands",
"bot-timers": "Chat timers",
"bot-alerts": "Chat alerts"
"chat-commands": "Chat commands",
"chat-timers": "Chat timers",
"chat-alerts": "Chat alerts"
},
"loyalty": {
"configuration": "Configuration",
@ -66,20 +66,19 @@
"apiguide-4": "Once made, create a <1>New Secret</1>, then copy both fields below and save!",
"app-client-id": "App Client ID",
"app-client-secret": "App Client Secret",
"subtitle": "Twitch integration with streams including chat bot and API access. If you stream on Twitch, you definitely want this on.",
"subtitle": "Twitch integration with streams including chat interactions and API access. If you stream on Twitch, you definitely want this on.",
"api-subheader": "Application info",
"api-configuration": "API access",
"eventsub": "Events",
"bot-settings": "Bot settings",
"enable-bot": "Enable Twitch bot",
"bot-channel": "Twitch channel",
"bot-username": "Twitch account username",
"bot-oauth": "Authorization token",
"bot-oauth-note": "You can get this by logging in with the bot account and going here: <1>https://twitchapps.com/tmi/</1>",
"bot-info-header": "Bot account info",
"bot-settings-copy": "A bot can interact with chat messages and provide extra events for the platform (chat events, some notifications) but requires access to a Twitch account. You can use your own or make a new one (if enabled on your main account, you can re-use the same email for your second account!)",
"bot-chat-header": "Chat settings",
"bot-chat-history": "How many messages to keep in history (0 to disable)",
"chat-settings": "Chat settings",
"chat": {
"header": "Chat settings",
"cooldown-tip": "Global chat cooldown for commands (in seconds)",
"default-user": "Currently using stream account, use the button above to authenticate with a different account.",
"chat-account": "Chat account",
"clear-button": "Revert to default account",
"account-copy": "You can use a different account for repling to chat commands and writing alerts instead of your channel one. To do so, click the button below and authenticate and authorize using your secondary account."
},
"events": {
"loading-data": "Querying user data from Twitch APIs…",
"authenticated-as": "Authenticated as",
@ -103,11 +102,10 @@
"app-oauth-redirect-url": "OAuth Redirect URLs",
"test-button": "Test connection",
"test-failed": "Test failed: \"{{error}}\". Check your app client IDs and secret!",
"test-succeeded": "Test succeeded!",
"bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)"
"test-succeeded": "Test succeeded!"
},
"botcommands": {
"title": "Bot commands",
"title": "Chat commands",
"desc": "Define custom chat commands to set up autoresponders, counters, etc.",
"add-button": "New command",
"search-placeholder": "Search command by name",
@ -135,12 +133,12 @@
"streamer": "Streamer only"
},
"remove-command-title": "Remove command {{name}}?",
"no-commands": "Chatbot has no commands configured",
"no-commands": "There are no commands configured",
"command-already-in-use": "Command name already in use",
"command-invalid-format": "The response template contains errors"
},
"bottimers": {
"title": "Bot timers",
"title": "Chat timers",
"desc": "Define reminders such as checking out your social media or ongoing events",
"add-button": "New timer",
"search-placeholder": "Search timer by name",
@ -286,19 +284,22 @@
},
"quick-links": "Useful links",
"link-user-guide": "User guide",
"link-api": "API reference"
"link-api": "API reference",
"problems": {
"eventsub-scope": "{{APPNAME}} needs new permissions in your Twitch app to work correctly.<br/> Click <a>here</a> to re-authenticate."
}
},
"onboarding": {
"welcome-header": "Welcome to {{APPNAME}}",
"welcome-continue-button": "Get started",
"skip-button": "Skip onboarding",
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
"welcome-p2": "Heads up: if you're used to other platforms, this unfortunately will require some more work on your end.",
"welcome-p2": "Heads up: if you're used to other tools for streaming, unfortunately this one will require some more work from your end.",
"sections": {
"landing": "Welcome",
"twitch-config": "Twitch integration",
"twitch-events": "Twitch events",
"twitch-bot": "Twitch bot",
"twitch-bot": "Twitch chat",
"done": "All done!"
},
"twitch-p1": "To set-up Twitch you will need to create an application on the Developer portal. Follow the instructions below or click the button at the bottom to skip this step.",
@ -308,7 +309,7 @@
"twitch-ev-p3": "If the above shows your account name and picture correctly, you're all set! Click the button below to complete Twitch integration.",
"twitch-complete": "Complete Twitch integration",
"done-header": "You're all set!",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the bot).",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the chat integrations).",
"done-p2": "If you have questions or issues, please reach out at any of these places:",
"done-button": "Complete onboarding",
"done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.",
@ -452,9 +453,9 @@
"dialog-title": "Application logs",
"levelFilter": "Filter per log severity",
"level": {
"info": "Info",
"warn": "Warning",
"error": "Error"
"INFO": "Info",
"WARN": "Warning",
"ERROR": "Error"
},
"copy-to-clipboard": "Copy to clipboard",
"copied": "Copied!",

View File

@ -36,9 +36,9 @@
"copy-to-clipboard": "Copia negli appunti",
"levelFilter": "Filtra per livello",
"level": {
"error": "Errore",
"info": "Info",
"warn": "Avvertimento"
"ERROR": "Errore",
"INFO": "Info",
"WARN": "Avvertimento"
},
"toggle-details": "Mostra dettagli"
},
@ -74,9 +74,9 @@
"extensions": "Estensioni"
},
"twitch": {
"bot-alerts": "Avvisi in chat",
"bot-commands": "Comandi bot",
"bot-timers": "Timer bot",
"chat-alerts": "Avvisi in chat",
"chat-commands": "Comandi chat",
"chat-timers": "Timer chat",
"configuration": "Configurazione"
}
},
@ -169,7 +169,10 @@
},
"link-api": "Documentazione API",
"link-user-guide": "Guida utente",
"quick-links": "Link utili"
"quick-links": "Link utili",
"problems": {
"eventsub-scope": "{{APPNAME}} necessita di nuove autorizzazioni nella tua app Twitch per funzionare correttamente.<br/> Fai clic <a>qui</a> per autenticarti nuovamente."
}
},
"debug": {
"big-ass-warning": "L'utilizzo di questa pagina può danneggiare gravemente il tuo database. \nSpero tu sappia cosa stai facendo!",
@ -289,7 +292,7 @@
"welcome-continue-button": "Cominciamo",
"welcome-header": "Benvenuto su {{APPNAME}}",
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento.",
"welcome-p2": "Giusto una cosa: se sei abituato ad altre piattaforme, stavolta toccherà un po' più lavoro da parte tua!",
"welcome-p2": "Giusto una cosa: se sei abituato ad altri strumenti di questo tipo, stavolta toccherà un po' più lavoro da parte tua!",
"sections": {
"done": "Pronti a partire!",
"landing": "Benvenuto",
@ -329,20 +332,19 @@
"app-category": "Categoria",
"app-client-secret": "Segreto client",
"app-oauth-redirect-url": "Reindirizzamento URL OAuth",
"bot-channel": "Canale Twitch",
"bot-chat-header": "Impostazioni chat",
"bot-chat-history": "Quanti messaggi tenere nello storico (0 per disabilitare)",
"bot-info-header": "Informazioni account del bot",
"bot-oauth": "Token di autorizzazione",
"bot-oauth-note": "Puoi ottenerlo accedendo con l'account del bot e andando qui: <1>https://twitchapps.com/tmi/</1>",
"bot-settings": "Impostazioni bot",
"bot-settings-copy": "Un bot può interagire con i messaggi in chat e scriverci per avvertimenti ed altre funzionalità ma richiede l'accesso ad un account Twitch. \nPuoi usare il tuo account o crearne uno apposta (se abilitato sul tuo account principale, puoi riutilizzare la stessa email per un secondo account!)",
"bot-username": "Nome utente dell'account Twitch",
"chat-settings": "Impostazioni chat",
"enable": "Abilita integrazione Twitch",
"enable-bot": "Abilita bot per Twitch",
"eventsub": "Eventi",
"subtitle": "Integrazione con stream su Twitch, incluso chat bot e accesso API. \nSe usi Twitch come piattaforma di streaming, lo vorrai sicuramente.",
"title": "Configurazione Twitch",
"chat": {
"cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)",
"chat-account": "Account chat",
"header": "Impostazioni chat",
"default-user": "Utilizzando l'account principale, usa il pulsante qui sopra per autenticarti con un account diverso per le funzionalità di chat.",
"clear-button": "Torna ad usare l'account principale",
"account-copy": "Puoi utilizzare un account diverso per rispondendere ai comandi della chat e invia notifiche al posto di quello del tuo canale. \nPer fare ciò, fai clic sul pulsante in basso e autentica e autorizza l'utilizzo del tuo account secondario."
},
"events": {
"auth-button": "Autenticati via Twitch",
"auth-message": "Fai clic sul pulsante qui sotto per autorizzare {{APPNAME}} ad accedere a notifiche del tuo account Twitch:",
@ -364,8 +366,7 @@
},
"test-button": "Test connessione",
"test-failed": "Test fallito: \"{{error}}\". \nControlla ID e segreto client dell'app!",
"test-succeeded": "Test riuscito!",
"bot-chat-cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)"
"test-succeeded": "Test riuscito!"
},
"uiconfig": {
"language": "Lingua",

View File

@ -1,6 +1,5 @@
/* eslint-disable no-param-reassign */
import {
AnyAction,
AsyncThunk,
CaseReducer,
createAction,
@ -8,6 +7,7 @@ import {
createSlice,
Dispatch,
PayloadAction,
UnknownAction,
} from '@reduxjs/toolkit';
import KilovoltWS, { KilovoltMessage } from '@strimertul/kilovolt-client';
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
@ -16,9 +16,19 @@ import { delay } from '~/lib/time';
import {
APIState,
ConnectionStatus,
HTTPConfig,
LoyaltyPointsEntry,
LoyaltyRedeem,
LoyaltyStorage,
TwitchChatConfig,
TwitchConfig,
TwitchChatCustomCommands,
TwitchChatTimersConfig,
TwitchChatAlertsConfig,
LoyaltyConfig,
LoyaltyReward,
LoyaltyGoal,
UISettings,
} from './types';
import { ThunkConfig } from '..';
@ -47,7 +57,7 @@ function makeGetSetThunks<T>(key: string) {
// Re-load value from KV
// Need to do type fuckery to avoid cyclic redundancy
// (unless there's a better way that I'm missing)
void dispatch(getter() as unknown as AnyAction);
void dispatch(getter() as unknown as UnknownAction);
}
}
return result;
@ -145,88 +155,77 @@ export const modules = {
'http/config',
(state) => state.moduleConfigs?.httpConfig,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.moduleConfigs.httpConfig = payload;
state.moduleConfigs.httpConfig = payload as HTTPConfig;
},
),
twitchConfig: makeModule(
'twitch/config',
(state) => state.moduleConfigs?.twitchConfig,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.moduleConfigs.twitchConfig = payload;
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
},
),
twitchBotConfig: makeModule(
'twitch/bot-config',
(state) => state.moduleConfigs?.twitchBotConfig,
twitchChatConfig: makeModule(
'twitch/chat/config',
(state) => state.moduleConfigs?.twitchChatConfig,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.moduleConfigs.twitchBotConfig = payload;
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
},
),
twitchBotCommands: makeModule(
'twitch/bot-custom-commands',
(state) => state.twitchBot?.commands,
twitchChatCommands: makeModule(
'twitch/chat/custom-commands',
(state) => state.twitchChat?.commands,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.twitchBot.commands = payload;
state.twitchChat.commands = payload as TwitchChatCustomCommands;
},
),
twitchBotTimers: makeModule(
'twitch/bot-modules/timers/config',
(state) => state.twitchBot?.timers,
twitchChatTimers: makeModule(
'twitch/timers/config',
(state) => state.twitchChat?.timers,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.twitchBot.timers = payload;
state.twitchChat.timers = payload as TwitchChatTimersConfig;
},
),
twitchBotAlerts: makeModule(
'twitch/bot-modules/alerts/config',
(state) => state.twitchBot?.alerts,
twitchChatAlerts: makeModule(
'twitch/alerts/config',
(state) => state.twitchChat?.alerts,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.twitchBot.alerts = payload;
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
},
),
loyaltyConfig: makeModule(
'loyalty/config',
(state) => state.moduleConfigs?.loyaltyConfig,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.moduleConfigs.loyaltyConfig = payload;
state.moduleConfigs.loyaltyConfig = payload as LoyaltyConfig;
},
),
loyaltyRewards: makeModule(
loyaltyRewardsKey,
(state) => state.loyalty.rewards,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.loyalty.rewards = payload;
state.loyalty.rewards = payload as LoyaltyReward[];
},
),
loyaltyGoals: makeModule(
'loyalty/goals',
(state) => state.loyalty.goals,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.loyalty.goals = payload;
state.loyalty.goals = payload as LoyaltyGoal[];
},
),
loyaltyRedeemQueue: makeModule(
'loyalty/redeem-queue',
(state) => state.loyalty.redeemQueue,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.loyalty.redeemQueue = payload;
state.loyalty.redeemQueue = payload as LoyaltyRedeem[];
},
),
uiConfig: makeModule(
'ui/settings',
(state) => state.uiConfig,
(state, { payload }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.uiConfig = payload;
state.uiConfig = payload as UISettings;
},
),
};
@ -270,7 +269,7 @@ const initialState: APIState = {
goals: null,
redeemQueue: null,
},
twitchBot: {
twitchChat: {
commands: null,
timers: null,
alerts: null,
@ -278,7 +277,7 @@ const initialState: APIState = {
moduleConfigs: {
httpConfig: null,
twitchConfig: null,
twitchBotConfig: null,
twitchChatConfig: null,
loyaltyConfig: null,
},
uiConfig: null,

View File

@ -12,16 +12,11 @@ export interface HTTPConfig {
export interface TwitchConfig {
enabled: boolean;
enable_bot: boolean;
api_client_id: string;
api_client_secret: string;
}
export interface TwitchBotConfig {
username: string;
oauth: string;
channel: string;
chat_history: number;
export interface TwitchChatConfig {
command_cooldown: number;
}
@ -36,7 +31,7 @@ export const accessLevels = [
export type AccessLevelType = (typeof accessLevels)[number];
export type ReplyType = 'chat' | 'reply' | 'whisper' | 'announce';
export interface TwitchBotCustomCommand {
export interface TwitchChatCustomCommand {
description: string;
access_level: AccessLevelType;
response: string;
@ -44,9 +39,9 @@ export interface TwitchBotCustomCommand {
enabled: boolean;
}
type TwitchBotCustomCommands = Record<string, TwitchBotCustomCommand>;
export type TwitchChatCustomCommands = Record<string, TwitchChatCustomCommand>;
interface LoyaltyConfig {
export interface LoyaltyConfig {
enabled: boolean;
currency: string;
points: {
@ -57,7 +52,7 @@ interface LoyaltyConfig {
banlist: string[];
}
export interface TwitchBotTimer {
export interface TwitchChatTimer {
enabled: boolean;
name: string;
minimum_chat_activity: number;
@ -65,11 +60,11 @@ export interface TwitchBotTimer {
messages: string[];
}
interface TwitchBotTimersConfig {
timers: Record<string, TwitchBotTimer>;
export interface TwitchChatTimersConfig {
timers: Record<string, TwitchChatTimer>;
}
interface TwitchBotAlertsConfig {
export interface TwitchChatAlertsConfig {
follow: {
enabled: boolean;
messages: string[];
@ -176,15 +171,15 @@ export interface APIState {
goals: LoyaltyGoal[];
redeemQueue: LoyaltyRedeem[];
};
twitchBot: {
commands: TwitchBotCustomCommands;
timers: TwitchBotTimersConfig;
alerts: TwitchBotAlertsConfig;
twitchChat: {
commands: TwitchChatCustomCommands;
timers: TwitchChatTimersConfig;
alerts: TwitchChatAlertsConfig;
};
moduleConfigs: {
httpConfig: HTTPConfig;
twitchConfig: TwitchConfig;
twitchBotConfig: TwitchBotConfig;
twitchChatConfig: TwitchChatConfig;
loyaltyConfig: LoyaltyConfig;
};
uiConfig: UISettings;

View File

@ -1,6 +1,6 @@
import { configureStore } from '@reduxjs/toolkit';
import { EqualityFn, useDispatch, useSelector } from 'react-redux';
import thunkMiddleware from 'redux-thunk';
import { thunk } from 'redux-thunk';
import apiReducer from './api/reducer';
import loggingReducer from './logging/reducer';
@ -17,7 +17,7 @@ const store = configureStore({
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(thunkMiddleware),
}).concat(thunk),
devTools: true,
});

View File

@ -3,23 +3,23 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { main } from '@wailsapp/go/models';
export interface ProcessedLogEntry {
id: string;
time: Date;
caller: string;
level: string;
message: string;
data: object;
}
export function processEntry({
id,
time,
caller,
level,
message,
data,
}: main.LogEntry): ProcessedLogEntry {
return {
id,
time: new Date(time),
caller,
level,
message,
data: JSON.parse(data) as object,
@ -34,12 +34,21 @@ const initialState: LoggingState = {
messages: [],
};
const keyfn = (ev: main.LogEntry) => ev.id;
const loggingReducer = createSlice({
name: 'logging',
initialState,
reducers: {
loadedLogData(state, { payload }: PayloadAction<main.LogEntry[]>) {
state.messages = payload
const logKeys = payload.map(keyfn);
// Clean up duplicates before setting to state
const uniqueLogs = payload.filter(
(ev, pos) => logKeys.indexOf(keyfn(ev)) === pos,
);
state.messages = uniqueLogs
.map(processEntry)
.sort((a, b) => a.time.getTime() - b.time.getTime());
},

View File

@ -31,8 +31,8 @@ import { initializeServerInfo } from '~/store/server/reducer';
import LogViewer from './components/LogViewer';
import Sidebar, { RouteSection } from './components/Sidebar';
import Scrollbar from './components/utils/Scrollbar';
import TwitchBotCommandsPage from './pages/BotCommands';
import TwitchBotTimersPage from './pages/BotTimers';
import TwitchChatCommandsPage from './pages/ChatCommands';
import TwitchChatTimersPage from './pages/ChatTimers';
import ChatAlertsPage from './pages/ChatAlerts';
import Dashboard from './pages/Dashboard';
import DebugPage from './pages/Debug';
@ -42,7 +42,7 @@ import LoyaltyRewardsPage from './pages/LoyaltyRewards';
import OnboardingPage from './pages/Onboarding';
import ServerSettingsPage from './pages/ServerSettings';
import StrimertulPage from './pages/Strimertul';
import TwitchSettingsPage from './pages/TwitchSettings';
import TwitchSettingsPage from './pages/TwitchSettings/Page';
import UISettingsPage from './pages/UISettingsPage';
import ExtensionsPage from './pages/Extensions';
import { getTheme, styled } from './theme';
@ -92,18 +92,18 @@ const sections: RouteSection[] = [
icon: <MixerHorizontalIcon />,
},
{
title: 'menu.pages.twitch.bot-commands',
url: '/twitch/bot/commands',
title: 'menu.pages.twitch.chat-commands',
url: '/twitch/chat/commands',
icon: <ChatBubbleIcon />,
},
{
title: 'menu.pages.twitch.bot-timers',
url: '/twitch/bot/timers',
title: 'menu.pages.twitch.chat-timers',
url: '/twitch/chat/timers',
icon: <TimerIcon />,
},
{
title: 'menu.pages.twitch.bot-alerts',
url: '/twitch/bot/alerts',
title: 'menu.pages.twitch.chat-alerts',
url: '/twitch/chat/alerts',
icon: <FrameIcon />,
},
],
@ -277,14 +277,14 @@ export default function App(): JSX.Element {
<Route path="/extensions" element={<ExtensionsPage />} />
<Route path="/twitch/settings" element={<TwitchSettingsPage />} />
<Route
path="/twitch/bot/commands"
element={<TwitchBotCommandsPage />}
path="/twitch/chat/commands"
element={<TwitchChatCommandsPage />}
/>
<Route
path="/twitch/bot/timers"
element={<TwitchBotTimersPage />}
path="/twitch/chat/timers"
element={<TwitchChatTimersPage />}
/>
<Route path="/twitch/bot/alerts" element={<ChatAlertsPage />} />
<Route path="/twitch/chat/alerts" element={<ChatAlertsPage />} />
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
<Route path="/loyalty/rewards" element={<LoyaltyRewardsPage />} />

View File

@ -46,12 +46,12 @@ const LogBubble = styled('div', {
},
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
backgroundColor: '$yellow6',
color: '$yellow11',
},
error: {
ERROR: {
backgroundColor: '$red6',
color: '$red11',
},
@ -60,12 +60,12 @@ const LogBubble = styled('div', {
});
const emptyFilter = {
info: false,
warn: false,
error: false,
INFO: false,
WARN: false,
ERROR: false,
};
type LogLevel = keyof typeof emptyFilter;
const levels: LogLevel[] = ['info', 'warn', 'error'];
const levels: LogLevel[] = ['INFO', 'WARN', 'ERROR'];
function isSupportedLevel(level: string): level is LogLevel {
return (levels as string[]).includes(level);
@ -89,7 +89,7 @@ const LevelToggle = styled(MultiToggleItem, {
},
variants: {
level: {
info: {
INFO: {
backgroundColor: '$gray4',
[`.${lightMode} &`]: {
backgroundColor: '$gray2',
@ -112,7 +112,7 @@ const LevelToggle = styled(MultiToggleItem, {
},
},
},
warn: {
WARN: {
backgroundColor: '$yellow4',
[`.${lightMode} &`]: {
backgroundColor: '$yellow2',
@ -135,7 +135,7 @@ const LevelToggle = styled(MultiToggleItem, {
},
},
},
error: {
ERROR: {
backgroundColor: '$red4',
[`.${lightMode} &`]: {
backgroundColor: '$red2',
@ -175,11 +175,11 @@ const LogEntryContainer = styled('div', {
fontSize: '0.9em',
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
backgroundColor: '$yellow4',
},
error: {
ERROR: {
backgroundColor: '$red6',
},
},
@ -199,12 +199,12 @@ const LogTime = styled('div', {
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
color: '$yellow11',
backgroundColor: '$yellow6',
},
error: {
ERROR: {
color: '$red11',
backgroundColor: '$red7',
},
@ -230,13 +230,13 @@ const LogActions = styled('div', {
},
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
'& a:hover': {
color: '$yellow11',
},
},
error: {
ERROR: {
'& a:hover': {
color: '$red11',
},
@ -249,7 +249,7 @@ const LogDetails = styled('div', {
gridColumn: '2/4',
display: 'flex',
flexWrap: 'wrap',
gap: '1rem',
gap: '0.5rem 1rem',
fontSize: '0.8em',
color: '$gray11',
backgroundColor: '$gray3',
@ -258,11 +258,11 @@ const LogDetails = styled('div', {
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
backgroundColor: '$yellow3',
},
error: {
ERROR: {
backgroundColor: '$red4',
},
},
@ -276,11 +276,11 @@ const LogDetailKey = styled('div', {
color: '$teal10',
variants: {
level: {
info: {},
warn: {
INFO: {},
WARN: {
color: '$yellow11',
},
error: {
ERROR: {
color: '$red11',
},
},
@ -430,10 +430,7 @@ function LogDialog({ initialFilter }: LogDialogProps) {
>
<LogEntriesContainer>
{filtered.reverse().map((entry) => (
<LogItem
key={entry.caller + entry.time.getTime().toString()}
data={entry}
/>
<LogItem key={entry.id} data={entry} />
))}
</LogEntriesContainer>
</Scrollbar>

View File

@ -0,0 +1,80 @@
import { GetTwitchLoggedUser } from '@wailsapp/go/main/App';
import { helix } from '@wailsapp/go/models';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '~/store';
import { TextBlock, styled } from '../theme';
interface SyncError {
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
});
const TwitchName = styled('p', { fontWeight: 'bold' });
interface TwitchUserBlockProps {
authKey: string;
noUserMessage: string;
}
export default function TwitchUserBlock({
authKey,
noUserMessage,
}: TwitchUserBlockProps) {
const { t } = useTranslation();
const [user, setUser] = useState<helix.User | SyncError>(null);
const kv = useAppSelector((state) => state.api.client);
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser(authKey);
setUser(res);
} catch (e) {
setUser({ ok: false, error: (e as Error).message });
}
};
useEffect(() => {
// Get user info
void getUserInfo();
const onKeyChange = () => {
void getUserInfo();
};
void kv.subscribeKey(authKey, onKeyChange);
return () => {
void kv.unsubscribeKey(authKey, onKeyChange);
};
}, []);
if (user !== null) {
if ('id' in user) {
return (
<TwitchUser>
<TextBlock>
{t('pages.twitch-settings.events.authenticated-as')}
</TextBlock>
<TwitchPic
src={user.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{user.display_name}</TwitchName>
</TwitchUser>
);
}
return <span>{noUserMessage}</span>;
}
return <i>{t('pages.twitch-settings.events.loading-data')}</i>;
}

View File

@ -25,7 +25,7 @@ import SaveButton from '../components/forms/SaveButton';
export default function ChatAlertsPage(): React.ReactElement {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchBotAlerts);
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchChatAlerts);
const status = useStatus(loadStatus.save);
return (
@ -63,7 +63,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.follow?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: {
...alerts.follow,
@ -93,7 +93,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.follow?.enabled ?? false}
onChange={(messages) => {
dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: { ...alerts.follow, messages },
}),
@ -109,7 +109,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.subscription?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: {
...alerts.subscription,
@ -139,7 +139,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.subscription?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: { ...alerts.subscription, messages },
}),
@ -156,7 +156,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.gift_sub?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: {
...alerts.gift_sub,
@ -186,7 +186,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.gift_sub?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: { ...alerts.gift_sub, messages },
}),
@ -203,7 +203,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.raid?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: {
...alerts.raid,
@ -233,7 +233,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.raid?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: { ...alerts.raid, messages },
}),
@ -250,7 +250,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.cheer?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: {
...alerts.cheer,
@ -280,7 +280,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.cheer?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: { ...alerts.cheer, messages },
}),

View File

@ -8,7 +8,7 @@ import {
accessLevels,
AccessLevelType,
ReplyType,
TwitchBotCustomCommand,
TwitchChatCustomCommand,
} from '~/store/api/types';
import { TestCommandTemplate } from '@wailsapp/go/main/App';
import AlertContent from '../components/AlertContent';
@ -137,7 +137,7 @@ const ACLIndicator = styled('span', {
interface CommandItemProps {
name: string;
item: TwitchBotCustomCommand;
item: TwitchChatCustomCommand;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
@ -212,7 +212,7 @@ function CommandItem({
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchBotCustomCommand };
| { kind: 'edit'; name: string; item: TwitchChatCustomCommand };
function CommandDialog({
kind,
@ -222,10 +222,10 @@ function CommandDialog({
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchBotCustomCommand;
onSubmit?: (name: string, item: TwitchBotCustomCommand) => void;
item?: TwitchChatCustomCommand;
onSubmit?: (name: string, item: TwitchChatCustomCommand) => void;
}) {
const [commands] = useModule(modules.twitchBotCommands);
const [commands] = useModule(modules.twitchChatCommands);
const [commandName, setCommandName] = useState(name ?? '');
const [description, setDescription] = useState(item?.description ?? '');
const [responseType, setResponseType] = useState(
@ -376,8 +376,8 @@ function CommandDialog({
);
}
export default function TwitchBotCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchBotCommands);
export default function TwitchChatCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchChatCommands);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
@ -385,7 +385,7 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
const filterLC = filter.toLowerCase();
const setCommand = (newName: string, data: TwitchBotCustomCommand): void => {
const setCommand = (newName: string, data: TwitchChatCustomCommand): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { TwitchBotTimer } from '~/store/api/types';
import { TwitchChatTimer } from '~/store/api/types';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Interval from '../components/forms/Interval';
@ -108,7 +108,7 @@ function humanTime(t: TFunction<'translation'>, secs: number): string {
interface TimerItemProps {
name: string;
item: TwitchBotTimer;
item: TwitchChatTimer;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
@ -182,7 +182,7 @@ function TimerItem({
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchBotTimer };
| { kind: 'edit'; name: string; item: TwitchChatTimer };
function TimerDialog({
kind,
@ -192,10 +192,10 @@ function TimerDialog({
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchBotTimer;
onSubmit?: (name: string, item: TwitchBotTimer) => void;
item?: TwitchChatTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void;
}) {
const [timerConfig] = useModule(modules.twitchBotTimers);
const [timerConfig] = useModule(modules.twitchChatTimers);
const [timerName, setName] = useState(name ?? '');
const [messages, setMessages] = useState(item?.messages ?? ['']);
const [minDelay, setMinDelay] = useState(item?.minimum_delay ?? 300);
@ -308,8 +308,8 @@ function TimerDialog({
);
}
export default function TwitchBotTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers);
export default function TwitchChatTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
@ -317,7 +317,7 @@ export default function TwitchBotTimersPage(): React.ReactElement {
const filterLC = filter.toLowerCase();
const setTimer = (newName: string, data: TwitchBotTimer): void => {
const setTimer = (newName: string, data: TwitchChatTimer): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(

View File

@ -1,4 +1,9 @@
import { CircleIcon, InfoCircledIcon, UpdateIcon } from '@radix-ui/react-icons';
import {
CircleIcon,
ExclamationTriangleIcon,
InfoCircledIcon,
UpdateIcon,
} from '@radix-ui/react-icons';
import { Trans, useTranslation } from 'react-i18next';
import {
EventSubNotification,
@ -8,7 +13,19 @@ import {
import { useLiveKey, useModule } from '~/lib/react';
import { useAppDispatch, useAppSelector } from '~/store';
import { modules } from '~/store/api/reducer';
import { PageContainer, SectionHeader, styled, TextBlock } from '../theme';
import * as HoverCard from '@radix-ui/react-hover-card';
import { useEffect, useState } from 'react';
import { main } from '@wailsapp/go/models';
import { GetProblems, GetTwitchAuthURL } from '@wailsapp/go/main/App';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
import {
PageContainer,
SectionHeader,
styled,
TextBlock,
theme,
TooltipContent,
} from '../theme';
import BrowserLink from '../components/BrowserLink';
import Scrollbar from '../components/utils/Scrollbar';
import RevealLink from '../components/utils/RevealLink';
@ -125,23 +142,27 @@ const supportedMessages: EventSubNotificationType[] = [
EventSubNotificationType.SubscriptionGifted,
];
const eventSubKeyFunction = (ev: EventSubNotification) =>
`${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(
ev.event,
)}`;
function TwitchEvent({ data }: { data: EventSubNotification }) {
const { t } = useTranslation();
const client = useAppSelector((state) => state.api.client);
const replay = () => {
void client.putJSON('twitch/ev/eventsub-event', {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
});
void client.putJSON(
`twitch/ev/eventsub-event/${data.subscription.type}`,
data,
);
};
let content: JSX.Element | string;
const message = unwrapEvent(data);
let date = data.date ? new Date(data.date) : null;
let date = data.date
? new Date(data.date)
: new Date(data.subscription.created_at);
switch (message.type) {
case EventSubNotificationType.Followed: {
content = (
@ -345,24 +366,33 @@ function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
const { t } = useTranslation();
return (
<>
<SectionHeader>
{t('pages.dashboard.twitch-events.header')}
<a
style={{ marginLeft: '10px' }}
title={t('pages.dashboard.twitch-events.warning')}
>
<InfoCircledIcon />
</a>
</SectionHeader>
<HoverCard.Root>
<HoverCard.Trigger asChild>
<SectionHeader>
{t('pages.dashboard.twitch-events.header')}
<a style={{ marginLeft: '10px' }}>
<InfoCircledIcon />
</a>
</SectionHeader>
</HoverCard.Trigger>
<HoverCard.Portal>
<TooltipContent>
{t('pages.dashboard.twitch-events.warning')}
</TooltipContent>
</HoverCard.Portal>
</HoverCard.Root>
<Scrollbar vertical={true} viewport={{ maxHeight: '250px' }}>
<EventListContainer>
{events
.filter((ev) => supportedMessages.includes(ev.subscription.type))
.sort((a, b) =>
a.date && b.date ? Date.parse(b.date) - Date.parse(a.date) : 0,
a.date && b.date
? Date.parse(b.date) - Date.parse(a.date)
: Date.parse(b.subscription.created_at) -
Date.parse(a.subscription.created_at),
)
.map((ev) => (
<TwitchEvent key={`${ev.subscription.id}-${ev.date}`} data={ev} />
<TwitchEvent key={eventSubKeyFunction(ev)} data={ev} />
))}
</EventListContainer>
</Scrollbar>
@ -407,10 +437,51 @@ function TwitchStreamStatus({ info }: { info: StreamInfo }) {
function TwitchSection() {
const { t } = useTranslation();
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
// const twitchActivity = useLiveKey<StreamInfo[]>('twitch/chat-activity');
const twitchEvents = useLiveKey<EventSubNotification[]>(
'twitch/eventsub-history',
);
const kv = useAppSelector((state) => state.api.client);
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
const keyfn = (ev: EventSubNotification) => JSON.stringify(ev);
const addTwitchEvents = (events: EventSubNotification[]) => {
setTwitchEvents((currentEvents) => {
const allEvents = currentEvents.concat(events);
const eventKeys = allEvents.map(keyfn);
// Clean up duplicates before setting to state
const updatedEvents = allEvents.filter(
(ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos,
);
return updatedEvents;
});
};
const loadRecentEvents = async () => {
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
const events = Object.values(keymap)
.map((value) => JSON.parse(value) as EventSubNotification[])
.flat();
addTwitchEvents(events);
};
useEffect(() => {
void loadRecentEvents();
const onKeyChange = (value: string) => {
const event = JSON.parse(value) as EventSubNotification;
if (!supportedMessages.includes(event.subscription.type)) {
return;
}
void addTwitchEvents([event]);
};
void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
return () => {
void kv.unsubscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
};
}, []);
return (
<>
@ -427,10 +498,95 @@ function TwitchSection() {
);
}
const ProblemBlock = styled('div', {
border: '2px solid $gray6',
padding: '0.5rem 1rem',
borderRadius: theme.borderRadius.toolbar,
variants: {
severity: {
warn: {
borderColor: '$yellow6',
backgroundColor: '$yellow3',
color: '$yellow12',
svg: {
color: '$yellow11',
},
},
},
},
display: 'flex',
gap: '1rem',
alignItems: 'center',
lineHeight: '1.4',
svg: {
marginTop: '0.25rem',
},
a: {
cursor: 'pointer',
},
});
function ProblemList() {
const [problems, setProblems] = useState<main.Problem[]>([]);
const { t } = useTranslation();
const kv = useAppSelector((state) => state.api.client);
useEffect(() => {
void GetProblems().then(setProblems);
}, []);
const reauthenticate = async () => {
// Wait for re-auth so we can clear the banner
const onKeyChange = () => {
void GetProblems().then(setProblems);
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
};
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
const url = await GetTwitchAuthURL('stream');
BrowserOpenURL(url);
};
return (
<>
{problems.map((p) => {
switch (p.id) {
case 'twitch:eventsub_scope':
return (
<ProblemBlock severity="warn">
<ExclamationTriangleIcon
style={{ width: 'auto', minWidth: '40px', height: '40px' }}
/>
<header>
<Trans
t={t}
i18nKey={'pages.dashboard.problems.eventsub-scope'}
components={{
a: (
<a
onClick={() => {
void reauthenticate();
}}
></a>
),
}}
/>
</header>
</ProblemBlock>
);
default:
return null;
}
})}
</>
);
}
export default function Dashboard(): React.ReactElement {
const { t } = useTranslation();
return (
<PageContainer>
<ProblemList />
<TwitchSection />
<SectionHeader>{t('pages.dashboard.quick-links')}</SectionHeader>
<UsefulLinksMenu>

View File

@ -26,6 +26,8 @@ import extensionsReducer, {
startExtension,
stopExtension,
} from '~/store/extensions/reducer';
import { useModule } from '~/lib/react';
import { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Loading from '../components/Loading';
@ -37,6 +39,7 @@ import {
DialogActions,
Field,
FlexRow,
getTheme,
InputBox,
Label,
MultiButton,
@ -220,7 +223,7 @@ function ExtensionListItem(props: ExtensionListItemProps) {
}}
showCancel={false}
>
<code>{props.error.toString()}</code>
<code>{props.error.message}</code>
</AlertContent>
</Alert>
) : null}
@ -583,6 +586,7 @@ function ExtensionEditor() {
}
export default function ExtensionsPage(): React.ReactElement {
const [uiConfig] = useModule(modules.uiConfig);
const { t } = useTranslation();
const extensions = useAppSelector((state) => state.extensions);
const dispatch = useAppDispatch();
@ -619,12 +623,20 @@ export default function ExtensionsPage(): React.ReactElement {
};
if (!extensions.ready) {
const theme = getTheme(
uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark',
);
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<Loading size="fill" message={t('pages.extensions.loading')} />
<Loading
theme={theme}
size="fill"
message={t('pages.extensions.loading')}
/>
</PageContainer>
);
}

View File

@ -11,7 +11,11 @@ import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useModule } from '~/lib/react';
import { checkTwitchKeys, TwitchCredentials } from '~/lib/twitch';
import {
checkTwitchKeys,
startAuthFlow,
TwitchCredentials,
} from '~/lib/twitch';
import { languages } from '~/locale/languages';
import { RootState, useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
@ -42,6 +46,7 @@ import {
themes,
} from '../theme';
import { Alert } from '../theme/alert';
import TwitchUserBlock from '../components/TwitchUserBlock';
const Container = styled('div', {
display: 'flex',
@ -428,68 +433,12 @@ function TwitchIntegrationStep() {
);
}
interface SyncError {
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
});
const TwitchName = styled('p', { fontWeight: 'bold' });
function TwitchEventsStep() {
const { t } = useTranslation();
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const [botConfig, setBotConfig] = useModule(modules.twitchBotConfig);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const [authKeys, setAuthKeys] = useState<TwitchCredentials>(null);
const kv = useSelector((state: RootState) => state.api.client);
const dispatch = useAppDispatch();
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser();
setUserStatus(res);
} catch (e) {
console.error(e);
setUserStatus({ ok: false, error: (e as Error).message });
}
};
const startAuthFlow = async () => {
const url = await GetTwitchAuthURL();
BrowserOpenURL(url);
};
const finishStep = async () => {
if ('id' in userStatus) {
// Set bot config to sane defaults
await dispatch(
setTwitchConfig({
...twitchConfig,
enable_bot: true,
}),
);
await dispatch(
setBotConfig({
...botConfig,
username: userStatus.login,
oauth: `oauth:${authKeys.access_token}`,
channel: userStatus.login,
chat_history: 5,
}),
);
}
await dispatch(
setUiConfig({
...uiConfig,
@ -499,57 +448,6 @@ function TwitchEventsStep() {
);
};
useEffect(() => {
// Get user info
void getUserInfo();
const onKeyChange = (newValue: string) => {
setAuthKeys(JSON.parse(newValue) as TwitchCredentials);
void getUserInfo();
};
void kv.getKey('twitch/auth-keys').then((auth) => {
if (auth) {
setAuthKeys(JSON.parse(auth) as TwitchCredentials);
}
});
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
return () => {
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
};
}, []);
let userBlock = <i>{t('pages.twitch-settings.events.loading-data')}</i>;
if (userStatus !== null) {
if ('id' in userStatus) {
userBlock = (
<>
<TwitchUser>
<TextBlock>
{t('pages.twitch-settings.events.authenticated-as')}
</TextBlock>
<TwitchPic
src={userStatus.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{userStatus.display_name}</TwitchName>
</TwitchUser>
<TextBlock>{t('pages.onboarding.twitch-ev-p3')}</TextBlock>
<Button
variation={'primary'}
onClick={() => {
void finishStep();
}}
>
{t('pages.onboarding.twitch-complete')}
</Button>
</>
);
} else {
userBlock = <span>{t('pages.twitch-settings.events.err-no-user')}</span>;
}
}
return (
<div>
<TextBlock>{t('pages.onboarding.twitch-ev-p1')}</TextBlock>
@ -558,7 +456,7 @@ function TwitchEventsStep() {
<Button
variation="primary"
onClick={() => {
void startAuthFlow();
void startAuthFlow('stream');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
@ -567,7 +465,19 @@ function TwitchEventsStep() {
<SectionHeader>
{t('pages.twitch-settings.events.current-status')}
</SectionHeader>
{userBlock}
<TwitchUserBlock
authKey="twitch/auth-keys"
noUserMessage={t('pages.twitch-settings.events.err-no-user')}
/>
<TextBlock>{t('pages.onboarding.twitch-ev-p3')}</TextBlock>
<Button
variation={'primary'}
onClick={() => {
void finishStep();
}}
>
{t('pages.onboarding.twitch-complete')}
</Button>
</div>
);
}

View File

@ -1,551 +0,0 @@
import { CheckIcon, ExternalLinkIcon } from '@radix-ui/react-icons';
import { GetTwitchAuthURL, GetTwitchLoggedUser } from '@wailsapp/go/main/App';
import { helix } from '@wailsapp/go/models';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import eventsubTests from '~/data/eventsub-tests';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch, useAppSelector } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { checkTwitchKeys } from '~/lib/twitch';
import BrowserLink from '../components/BrowserLink';
import DefinitionTable from '../components/DefinitionTable';
import RevealLink from '../components/utils/RevealLink';
import SaveButton from '../components/forms/SaveButton';
import {
Button,
ButtonGroup,
Checkbox,
CheckboxIndicator,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
PasswordInputBox,
SectionHeader,
styled,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../theme';
import AlertContent from '../components/AlertContent';
import { Alert } from '../theme/alert';
const StepList = styled('ul', {
lineHeight: '1.5',
listStyleType: 'none',
listStylePosition: 'outside',
});
const Step = styled('li', {
marginBottom: '0.5rem',
paddingLeft: '1rem',
'&::marker': {
color: '$teal11',
content: '▧',
display: 'inline-block',
marginLeft: '-0.5rem',
},
});
function TwitchBotSettings() {
const [botConfig, setBotConfig, loadStatus] = useModule(
modules.twitchBotConfig,
);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [revealBotToken, setRevealBotToken] = useState(false);
const active = twitchConfig?.enable_bot ?? false;
const disabled = !active || status?.type === 'pending';
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
void dispatch(setBotConfig(botConfig));
ev.preventDefault();
}}
>
<TextBlock>{t('pages.twitch-settings.bot-settings-copy')}</TextBlock>
<Field>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
enable_bot: !!ev,
}),
)
}
id="enable-bot"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable-bot">
{t('pages.twitch-settings.enable-bot')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-channel">
{t('pages.twitch-settings.bot-channel')}
</Label>
<InputBox
type="text"
id="bot-channel"
required={active}
disabled={disabled}
value={botConfig?.channel ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
channel: ev.target.value,
}),
)
}
/>
</Field>
<SectionHeader>
{t('pages.twitch-settings.bot-info-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-username">
{t('pages.twitch-settings.bot-username')}
</Label>
<InputBox
type="text"
id="bot-username"
required={active}
disabled={disabled}
value={botConfig?.username ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
username: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-oauth">
{t('pages.twitch-settings.bot-oauth')}
<RevealLink value={revealBotToken} setter={setRevealBotToken} />
</Label>
<PasswordInputBox
reveal={revealBotToken}
id="bot-oauth"
required={active}
disabled={disabled}
value={botConfig?.oauth ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
oauth: ev.target.value,
}),
)
}
/>
<FieldNote>
<Trans i18nKey="pages.twitch-settings.bot-oauth-note">
<BrowserLink href="https://twitchapps.com/tmi/">
https://twitchapps.com/tmi/
</BrowserLink>
</Trans>
</FieldNote>
</Field>
<SectionHeader>
{t('pages.twitch-settings.bot-chat-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-history')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={active}
disabled={disabled}
defaultValue={botConfig?.chat_history}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
chat_history: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-cooldown-tip')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={active}
disabled={disabled}
defaultValue={botConfig ? botConfig.command_cooldown ?? 2 : undefined}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
}
type TestResult = { open: boolean; error?: Error };
function TwitchAPISettings() {
const { t } = useTranslation();
const [httpConfig] = useModule(modules.httpConfig);
const [twitchConfig, setTwitchConfig, loadStatus] = useModule(
modules.twitchConfig,
);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const [revealClientSecret, setRevealClientSecret] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
const checkCredentials = async () => {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(
twitchConfig.api_client_id,
twitchConfig.api_client_secret,
);
setTestResult({ open: true });
} catch (e: unknown) {
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
};
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
ev.preventDefault();
}}
>
<SectionHeader spacing={'none'}>
{t('pages.twitch-settings.api-subheader')}
</SectionHeader>
<TextBlock>{t('pages.twitch-settings.apiguide-1')}</TextBlock>
<StepList>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-2">
{' '}
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
https://dev.twitch.tv/console/apps/create
</BrowserLink>
</Trans>
</Step>
<Step>
{t('pages.twitch-settings.apiguide-3')}
<DefinitionTable
entries={{
[t('pages.twitch-settings.app-oauth-redirect-url')]: `http://${
httpConfig?.bind.indexOf(':') > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ':4337'}`
}/twitch/callback`,
[t('pages.twitch-settings.app-category')]: 'Broadcasting Suite',
}}
/>
</Step>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-4">
{'str1 '}
<b>str2</b>
</Trans>
</Step>
</StepList>
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
<Label htmlFor="clientid">
{t('pages.twitch-settings.app-client-id')}
</Label>
<InputBox
type="text"
id="clientid"
placeholder={t('pages.twitch-settings.app-client-id')}
required={true}
value={twitchConfig?.api_client_id ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_id: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="clientsecret">
{t('pages.twitch-settings.app-client-secret')}
<RevealLink
value={revealClientSecret}
setter={setRevealClientSecret}
/>
</Label>
<PasswordInputBox
reveal={revealClientSecret}
id="clientsecret"
placeholder={t('pages.twitch-settings.app-client-secret')}
required={true}
value={twitchConfig?.api_client_secret ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_secret: ev.target.value,
}),
)
}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
onClick={() => {
void checkCredentials();
}}
disabled={testing}
>
{t('pages.twitch-settings.test-button')}
</Button>
</ButtonGroup>
<Alert
defaultOpen={false}
open={testResult.open}
onOpenChange={(val: boolean) => {
setTestResult({ ...testResult, open: val });
}}
>
<AlertContent
variation={testResult.error ? 'danger' : 'default'}
description={
testResult.error
? t('pages.twitch-settings.test-failed', {
error: testResult.error.message,
})
: t('pages.twitch-settings.test-succeeded')
}
actionText={t('form-actions.ok')}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
}
interface SyncError {
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
});
const TwitchName = styled('p', { fontWeight: 'bold' });
function TwitchEventSubSettings() {
const { t } = useTranslation();
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const kv = useAppSelector((state) => state.api.client);
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser();
setUserStatus(res);
} catch (e) {
console.error(e);
setUserStatus({ ok: false, error: (e as Error).message });
}
};
const startAuthFlow = async () => {
const url = await GetTwitchAuthURL();
BrowserOpenURL(url);
};
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON('twitch/ev/eventsub-event', {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
});
};
useEffect(() => {
// Get user info
void getUserInfo();
const onKeyChange = () => {
void getUserInfo();
};
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
return () => {
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
};
}, []);
let userBlock = <i>{t('pages.twitch-settings.events.loading-data')}</i>;
if (userStatus !== null) {
if ('id' in userStatus) {
userBlock = (
<>
<TwitchUser>
<TextBlock>
{t('pages.twitch-settings.events.authenticated-as')}
</TextBlock>
<TwitchPic
src={userStatus.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{userStatus.display_name}</TwitchName>
</TwitchUser>
</>
);
} else {
userBlock = <span>{t('pages.twitch-settings.events.err-no-user')}</span>;
}
}
return (
<>
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
<Button
variation="primary"
onClick={() => {
void startAuthFlow();
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
<SectionHeader>
{t('pages.twitch-settings.events.current-status')}
</SectionHeader>
{userBlock}
<SectionHeader>
{t('pages.twitch-settings.events.sim-events')}
</SectionHeader>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
}
export default function TwitchSettingsPage(): React.ReactElement {
const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const dispatch = useAppDispatch();
const active = twitchConfig?.enabled ?? false;
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.twitch-settings.title')}</PageTitle>
<TextBlock>{t('pages.twitch-settings.subtitle')}</TextBlock>
<Field css={{ paddingTop: '1rem' }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setTwitchConfig({
...twitchConfig,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable">{t('pages.twitch-settings.enable')}</Label>
</FlexRow>
</Field>
</PageHeader>
<div style={{ display: active ? '' : 'none' }}>
<TabContainer defaultValue="api-config">
<TabList>
<TabButton value="api-config">
{t('pages.twitch-settings.api-configuration')}
</TabButton>
<TabButton value="eventsub">
{t('pages.twitch-settings.eventsub')}
</TabButton>
<TabButton value="bot-settings">
{t('pages.twitch-settings.bot-settings')}
</TabButton>
</TabList>
<TabContent value="api-config">
<TwitchAPISettings />
</TabContent>
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="bot-settings">
<TwitchBotSettings />
</TabContent>
</TabContainer>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,85 @@
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import {
Checkbox,
CheckboxIndicator,
Field,
FlexRow,
Label,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../../theme';
import TwitchAPISettings from './TwitchAPISettings';
import TwitchEventSubSettings from './TwitchEventSubSettings';
import TwitchChatSettings from './TwitchChatSettings';
export default function TwitchSettingsPage(): React.ReactElement {
const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const dispatch = useAppDispatch();
const active = twitchConfig?.enabled ?? false;
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.twitch-settings.title')}</PageTitle>
<TextBlock>{t('pages.twitch-settings.subtitle')}</TextBlock>
<Field css={{ paddingTop: '1rem' }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setTwitchConfig({
...twitchConfig,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable">{t('pages.twitch-settings.enable')}</Label>
</FlexRow>
</Field>
</PageHeader>
<div style={{ display: active ? '' : 'none' }}>
<TabContainer defaultValue="api-config">
<TabList>
<TabButton value="api-config">
{t('pages.twitch-settings.api-configuration')}
</TabButton>
<TabButton value="eventsub">
{t('pages.twitch-settings.eventsub')}
</TabButton>
<TabButton value="chat-settings">
{t('pages.twitch-settings.chat-settings')}
</TabButton>
</TabList>
<TabContent value="api-config">
<TwitchAPISettings />
</TabContent>
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="chat-settings">
<TwitchChatSettings />
</TabContent>
</TabContainer>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,195 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { checkTwitchKeys } from '~/lib/twitch';
import BrowserLink from '../../components/BrowserLink';
import DefinitionTable from '../../components/DefinitionTable';
import RevealLink from '../../components/utils/RevealLink';
import SaveButton from '../../components/forms/SaveButton';
import {
Button,
ButtonGroup,
Field,
InputBox,
Label,
PasswordInputBox,
SectionHeader,
styled,
TextBlock,
} from '../../theme';
import AlertContent from '../../components/AlertContent';
import { Alert } from '../../theme/alert';
const StepList = styled('ul', {
lineHeight: '1.5',
listStyleType: 'none',
listStylePosition: 'outside',
});
const Step = styled('li', {
marginBottom: '0.5rem',
paddingLeft: '1rem',
'&::marker': {
color: '$teal11',
content: '▧',
display: 'inline-block',
marginLeft: '-0.5rem',
},
});
type TestResult = { open: boolean; error?: Error };
export default function TwitchAPISettings() {
const { t } = useTranslation();
const [httpConfig] = useModule(modules.httpConfig);
const [twitchConfig, setTwitchConfig, loadStatus] = useModule(
modules.twitchConfig,
);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const [revealClientSecret, setRevealClientSecret] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
const checkCredentials = async () => {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(
twitchConfig.api_client_id,
twitchConfig.api_client_secret,
);
setTestResult({ open: true });
} catch (e: unknown) {
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
};
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
ev.preventDefault();
}}
>
<SectionHeader spacing={'none'}>
{t('pages.twitch-settings.api-subheader')}
</SectionHeader>
<TextBlock>{t('pages.twitch-settings.apiguide-1')}</TextBlock>
<StepList>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-2">
{' '}
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
https://dev.twitch.tv/console/apps/create
</BrowserLink>
</Trans>
</Step>
<Step>
{t('pages.twitch-settings.apiguide-3')}
<DefinitionTable
entries={{
[t('pages.twitch-settings.app-oauth-redirect-url')]: `http://${
httpConfig?.bind.indexOf(':') > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ':4337'}`
}/twitch/callback`,
[t('pages.twitch-settings.app-category')]: 'Broadcasting Suite',
}}
/>
</Step>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-4">
{'str1 '}
<b>str2</b>
</Trans>
</Step>
</StepList>
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
<Label htmlFor="clientid">
{t('pages.twitch-settings.app-client-id')}
</Label>
<InputBox
type="text"
id="clientid"
placeholder={t('pages.twitch-settings.app-client-id')}
required={true}
value={twitchConfig?.api_client_id ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_id: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="clientsecret">
{t('pages.twitch-settings.app-client-secret')}
<RevealLink
value={revealClientSecret}
setter={setRevealClientSecret}
/>
</Label>
<PasswordInputBox
reveal={revealClientSecret}
id="clientsecret"
placeholder={t('pages.twitch-settings.app-client-secret')}
required={true}
value={twitchConfig?.api_client_secret ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_secret: ev.target.value,
}),
)
}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
onClick={() => {
void checkCredentials();
}}
disabled={testing}
>
{t('pages.twitch-settings.test-button')}
</Button>
</ButtonGroup>
<Alert
defaultOpen={false}
open={testResult.open}
onOpenChange={(val: boolean) => {
setTestResult({ ...testResult, open: val });
}}
>
<AlertContent
variation={testResult.error ? 'danger' : 'default'}
description={
testResult.error
? t('pages.twitch-settings.test-failed', {
error: testResult.error.message,
})
: t('pages.twitch-settings.test-succeeded')
}
actionText={t('form-actions.ok')}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
}

View File

@ -0,0 +1,95 @@
import { useTranslation } from 'react-i18next';
import { useLiveKeyString, useModule, useStatus } from '~/lib/react';
import { useAppDispatch, useAppSelector } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { startAuthFlow } from '~/lib/twitch';
import TwitchUserBlock from '~/ui/components/TwitchUserBlock';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import SaveButton from '../../components/forms/SaveButton';
import {
Button,
Field,
FlexRow,
InputBox,
Label,
SectionHeader,
TextBlock,
} from '../../theme';
export default function TwitchChatSettings() {
const [chatConfig, setChatConfig, loadStatus] = useModule(
modules.twitchChatConfig,
);
const kv = useAppSelector((state) => state.api.client);
const authKey = 'twitch/chat/chatter-account';
const authKeyValue = useLiveKeyString('twitch/chat/chatter-account');
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const disabled = status?.type === 'pending';
return (
<form
onSubmit={(ev) => {
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<SectionHeader spacing={'none'}>
{t('pages.twitch-settings.chat.chat-account')}
</SectionHeader>
<TextBlock>{t('pages.twitch-settings.chat.account-copy')}</TextBlock>
<FlexRow align="left" spacing="1" css={{ marginBottom: '1rem' }}>
<Button
variation="primary"
type="button"
onClick={() => {
void startAuthFlow('chat');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
{authKeyValue && (
<Button
variation="danger"
type="button"
onClick={() => {
kv.deleteKey(authKey);
}}
>
{t('pages.twitch-settings.chat.clear-button')}
</Button>
)}
</FlexRow>
<TwitchUserBlock
authKey={'twitch/chat/chatter-account'}
noUserMessage={t('pages.twitch-settings.chat.default-user')}
/>
<SectionHeader>{t('pages.twitch-settings.chat.header')}</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.chat.cooldown-tip')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={true}
disabled={disabled}
defaultValue={
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
}

View File

@ -0,0 +1,60 @@
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import eventsubTests from '~/data/eventsub-tests';
import { useAppSelector } from '~/store';
import { startAuthFlow } from '~/lib/twitch';
import { Button, ButtonGroup, SectionHeader, TextBlock } from '../../theme';
import TwitchUserBlock from '../../components/TwitchUserBlock';
export default function TwitchEventSubSettings() {
const { t } = useTranslation();
const kv = useAppSelector((state) => state.api.client);
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON(`twitch/ev/eventsub-event/${event}`, {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
date: new Date().toISOString(),
});
};
return (
<>
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
<Button
variation="primary"
onClick={() => {
void startAuthFlow('stream');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
<SectionHeader>
{t('pages.twitch-settings.events.current-status')}
</SectionHeader>
<TwitchUserBlock
authKey={'twitch/auth-keys'}
noUserMessage={t('pages.twitch-settings.events.err-no-user')}
/>
<SectionHeader>
{t('pages.twitch-settings.events.sim-events')}
</SectionHeader>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
}

View File

@ -24,7 +24,7 @@ export const globalStyles = globalCss({
padding: 0,
fontFamily: "'Inter', 'system-ui', sans-serif",
'@supports (font-variation-settings: normal)': {
fontFamily: "'Inter var', 'system-ui', sans-serif",
fontFamily: "'InterVariable', 'system-ui', sans-serif",
},
},
a: {

View File

@ -1,5 +1,6 @@
/* eslint-disable import/prefer-default-export */
import { Content as HoverCardContent } from '@radix-ui/react-hover-card';
import { styled, theme } from './theme';
export const FlexRow = styled('div', {
@ -26,3 +27,15 @@ export const FlexRow = styled('div', {
},
},
});
export const TooltipContent = styled(HoverCardContent, {
borderRadius: 6,
display: 'flex',
padding: '0.5rem',
gap: '0.5rem',
flexDirection: 'column',
border: '2px solid $gray6',
backgroundColor: '$gray2',
alignItems: 'flex-start',
boxShadow: '0px 5px 20px rgba(0,0,0,0.4)',
});

View File

@ -2,6 +2,7 @@
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
import {docs} from '../models';
import {log} from '../models';
import {helix} from '../models';
export function AuthenticateKVClient(arg1:string):Promise<void>;
@ -14,11 +15,13 @@ export function GetDocumentation():Promise<{[key: string]: docs.KeyObject}>;
export function GetKilovoltBind():Promise<string>;
export function GetLastLogs():Promise<Array<main.LogEntry>>;
export function GetLastLogs():Promise<Array<log.Entry>>;
export function GetTwitchAuthURL():Promise<string>;
export function GetProblems():Promise<Array<main.Problem>>;
export function GetTwitchLoggedUser():Promise<helix.User>;
export function GetTwitchAuthURL(arg1:string):Promise<string>;
export function GetTwitchLoggedUser(arg1:string):Promise<helix.User>;
export function IsFatalError():Promise<boolean>;

View File

@ -26,12 +26,16 @@ export function GetLastLogs() {
return window['go']['main']['App']['GetLastLogs']();
}
export function GetTwitchAuthURL() {
return window['go']['main']['App']['GetTwitchAuthURL']();
export function GetProblems() {
return window['go']['main']['App']['GetProblems']();
}
export function GetTwitchLoggedUser() {
return window['go']['main']['App']['GetTwitchLoggedUser']();
export function GetTwitchAuthURL(arg1) {
return window['go']['main']['App']['GetTwitchAuthURL'](arg1);
}
export function GetTwitchLoggedUser(arg1) {
return window['go']['main']['App']['GetTwitchLoggedUser'](arg1);
}
export function IsFatalError() {

View File

@ -54,6 +54,31 @@ export namespace helix {
}
export namespace log {
export class Entry {
id: string;
time: string;
level: string;
message: string;
data: string;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
this.data = source["data"];
}
}
}
export namespace main {
export class BackupInfo {
@ -72,24 +97,18 @@ export namespace main {
this.size = source["size"];
}
}
export class LogEntry {
caller: string;
time: string;
level: string;
message: string;
data: string;
export class Problem {
id: string;
details: any;
static createFrom(source: any = {}) {
return new LogEntry(source);
return new Problem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.caller = source["caller"];
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
this.data = source["data"];
this.id = source["id"];
this.details = source["details"];
}
}
export class VersionInfo {

89
go.mod
View File

@ -1,32 +1,27 @@
module git.sr.ht/~ashkeel/strimertul
go 1.21
go 1.22
require (
git.sr.ht/~ashkeel/containers v0.3.6
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.2.4
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.3.4
git.sr.ht/~ashkeel/kilovolt/v12 v12.0.3
github.com/Masterminds/sprig/v3 v3.2.3
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
github.com/cockroachdb/pebble v0.0.0-20231102162011-844f0582c2eb
github.com/gempir/go-twitch-irc/v4 v4.0.0
github.com/gorilla/websocket v1.5.0
github.com/cockroachdb/pebble v1.1.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/json-iterator/go v1.1.12
github.com/nicklaw5/helix/v2 v2.25.2
github.com/postfinance/single v0.0.2
github.com/strimertul/kilovolt/v11 v11.0.1
github.com/urfave/cli/v2 v2.25.7
github.com/wailsapp/wails/v2 v2.6.0
go.uber.org/zap v1.26.0
github.com/nicklaw5/helix/v2 v2.28.1
github.com/samber/slog-multi v1.0.2
github.com/urfave/cli/v2 v2.27.1
github.com/wailsapp/wails/v2 v2.8.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
//replace github.com/nicklaw5/helix/v2 => github.com/ashkeel/helix/v2 v2.20.0-ws
require (
github.com/DataDog/zstd v1.5.5 // indirect
github.com/DataDog/zstd v1.4.5 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
@ -35,55 +30,55 @@ require (
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/getsentry/sentry-go v0.25.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/getsentry/sentry-go v0.18.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang-jwt/jwt/v4 v4.0.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/klauspost/compress v1.17.2 // indirect
github.com/klauspost/compress v1.15.15 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/echo/v4 v4.11.2 // indirect
github.com/labstack/echo/v4 v4.10.2 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/gosod v1.0.3 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/client_golang v1.12.0 // indirect
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/tkrajina/go-reflector v0.5.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.9 // indirect
github.com/wailsapp/go-webview2 v1.0.10 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // 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
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
nhooyr.io/websocket v1.8.10 // indirect
)

572
go.sum
View File

@ -1,103 +1,232 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.sr.ht/~ashkeel/containers v0.3.6 h1:+umWlQGKhLxGQlaEUt/F6rBZGpeBd1T01fM3wro+qTY=
git.sr.ht/~ashkeel/containers v0.3.6/go.mod h1:i2KocnJfRH0FwfgPi4nw7/ehYLEoLlP3iwdDoBeVdME=
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.2.4 h1:8QdCTq8HOHEM4wCZVXlvnUJ8dogSnzZTNzsjkj0vE0Y=
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.2.4/go.mod h1:VTObrB2pDpjT6JhcSwf4D5RWUy5udFPpAKrOgOXh6hs=
github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ=
github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.3.4 h1:Yi1SqnHBHOMqdKAK7PpWt97vyq6JTytlxCU+Q8JfJ/g=
git.sr.ht/~ashkeel/kilovolt-driver-pebble v1.3.4/go.mod h1:O3o7hvyVwH+AFFk7vWU3YN86dKIGrstii2NJv34MgZc=
git.sr.ht/~ashkeel/kilovolt/v12 v12.0.3 h1:ag9MK/qVLbT5Vq4faJyMg5iUHP+XoRZTHyHgs0lKGak=
git.sr.ht/~ashkeel/kilovolt/v12 v12.0.3/go.mod h1:dRSJpl6ZXNoTAF3pTMC4AO7MLkRgzQqaZYR8S5a46TI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DDy1/hEQWrYlBy+g0EuwT70sBHg65SeUc4=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4=
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
github.com/cockroachdb/errors v1.11.1 h1:xSEW75zKaKCWzR3OfxXUxgrk/NtT4G1MiOv5lWZazG8=
github.com/cockroachdb/errors v1.11.1/go.mod h1:8MUxA3Gi6b25tYlFEBGLf+D8aISL+M4MIpiWMSNRfxw=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v0.0.0-20231102162011-844f0582c2eb h1:6Po+YYKT5B5ZXN0wd2rwFBaebM0LufPf8p4zxOd48Kg=
github.com/cockroachdb/pebble v0.0.0-20231102162011-844f0582c2eb/go.mod h1:acMRUGd/BK8AUmQNK3spUCCGzFLZU2bSST3NMXSq2Kc=
github.com/cockroachdb/pebble v1.1.0 h1:pcFh8CdCIt2kmEpK0OIatq67Ln9uGDYY3d5XnE0LJG4=
github.com/cockroachdb/pebble v1.1.0/go.mod h1:sEHm5NOXxyiAoKWhoFxT8xMgd/f3RA6qUqQ1BXKrh2E=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gempir/go-twitch-irc/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8=
github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
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/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE=
github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
@ -105,172 +234,429 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nicklaw5/helix/v2 v2.25.2 h1:diGnmRnUpNk8vYVM1vOjUo5PGZnr8atbkUYT78T6evc=
github.com/nicklaw5/helix/v2 v2.25.2/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nicklaw5/helix/v2 v2.28.1 h1:bLVKMrZ0MiSgCLB3nsi7+OrhognsIusqvNL4XFoRG0A=
github.com/nicklaw5/helix/v2 v2.28.1/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/postfinance/single v0.0.2 h1:YLjLxxeGDnsW93oK4CxxOFSVOOiBi1OyoK4ZTl5biJw=
github.com/postfinance/single v0.0.2/go.mod h1:OYWUsdMIZK9eQyZYpAsMHN+j6+jXJ6RFUNqIWH0oC5U=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/slog-multi v1.0.2 h1:6BVH9uHGAsiGkbbtQgAOQJMpKgV8unMrHhhJaw+X1EQ=
github.com/samber/slog-multi v1.0.2/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/strimertul/kilovolt/v11 v11.0.1 h1:fV33Z3b168LeSROzzV0dZcI2Jptg3TvF2FbEGxfEVGI=
github.com/strimertul/kilovolt/v11 v11.0.1/go.mod h1:PjhGVWb74lB8dXSGWA7GmVSbZAoGV/WGGmjS2Zz/UBg=
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.9 h1:lrU+q0cf1wgLdR69rN+ZnRtMJNaJRrcQ4ELxoO7/xjs=
github.com/wailsapp/go-webview2 v1.0.9/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/go-webview2 v1.0.10 h1:PP5Hug6pnQEAhfRzLCoOh2jJaPdrqeRgJKZhyYyDV/w=
github.com/wailsapp/go-webview2 v1.0.10/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.6.0 h1:EyH0zR/EO6dDiqNy8qU5spaXDfkluiq77xrkabPYD4c=
github.com/wailsapp/wails/v2 v2.6.0/go.mod h1:WBG9KKWuw0FKfoepBrr/vRlyTmHaMibWesK3yz6nNiM=
github.com/wailsapp/wails/v2 v2.8.1 h1:KAudNjlFaiXnDfFEfSNoLoibJ1ovoutSrJ8poerTPW0=
github.com/wailsapp/wails/v2 v2.8.1/go.mod h1:EFUGWkUX3KofO4fmKR/GmsLy3HhPH7NbyOEaMt8lBF0=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
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.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.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-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

21
log/attr.go Normal file
View File

@ -0,0 +1,21 @@
package log
import (
"fmt"
"log/slog"
"runtime"
)
func Error(err error) slog.Attr {
return ErrorSkip(err, 2)
}
func ErrorSkip(err error, skip int) slog.Attr {
pc, filename, line, _ := runtime.Caller(skip)
return slog.Group("error",
slog.String("message", err.Error()),
slog.String("file", fmt.Sprintf("%s@%d", filename, line)),
slog.String("func", runtime.FuncForPC(pc).Name()),
)
}

24
log/context.go Normal file
View File

@ -0,0 +1,24 @@
package log
import (
"context"
"log/slog"
)
type ContextKey string
const (
ContextLogger ContextKey = "logger"
)
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, ContextLogger, logger)
}
func GetLogger(ctx context.Context) *slog.Logger {
logger, ok := ctx.Value(ContextLogger).(*slog.Logger)
if !ok {
return slog.Default()
}
return logger
}

146
log/setup.go Normal file
View File

@ -0,0 +1,146 @@
package log
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math/rand"
"os"
"time"
"git.sr.ht/~ashkeel/containers/sync"
slogmulti "github.com/samber/slog-multi"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
History = 50
Filename = "strimertul.log"
PanicFilename = "strimertul-panic.log"
)
var (
LastLogs = sync.NewSlice[Entry]()
IncomingLogs = make(chan Entry, 100)
)
func Init(level slog.Level) {
logStorage := NewLogStorage(level)
fileLogger := &lumberjack.Logger{
Filename: Filename,
MaxSize: 20,
MaxBackups: 3,
MaxAge: 28,
}
consoleHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
fileHandler := slog.NewJSONHandler(fileLogger, &slog.HandlerOptions{
AddSource: true,
Level: level,
})
logger := slog.New(slogmulti.Fanout(consoleHandler, fileHandler, logStorage))
slog.SetDefault(logger)
}
type Entry struct {
ID string `json:"id"`
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Data string `json:"data"`
}
type Storage struct {
minLevel slog.Level
attrs []slog.Attr
group string
}
func (core *Storage) Enabled(_ context.Context, level slog.Level) bool {
return level >= core.minLevel
}
func (core *Storage) Handle(_ context.Context, record slog.Record) error {
attributes := flatAttributeMap(record)
attrJSON, _ := json.Marshal(attributes)
// Generate unique log ID
id := fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int31())
logEntry := Entry{
ID: id,
Time: record.Time.Format(time.RFC3339),
Level: record.Level.String(),
Message: record.Message,
Data: string(attrJSON),
}
LastLogs.Push(logEntry)
if LastLogs.Size() > History {
LastLogs.Splice(0, 1)
}
IncomingLogs <- logEntry
return nil
}
func flatAttributeMap(record slog.Record) map[string]any {
attributes := map[string]any{}
flatAttributes := func(attr slog.Attr) bool {
type attrToParse struct {
Prefix string
Attr slog.Attr
}
remaining := []attrToParse{{"", attr}}
for len(remaining) > 0 {
var current attrToParse
current, remaining = remaining[0], remaining[1:]
switch current.Attr.Value.Kind() {
case slog.KindGroup:
for _, subAttr := range current.Attr.Value.Group() {
remaining = append(remaining, attrToParse{
Prefix: current.Attr.Key + ".",
Attr: subAttr,
})
}
default:
attributes[current.Prefix+current.Attr.Key] = current.Attr.Value.Any()
}
}
return true
}
record.Attrs(flatAttributes)
return attributes
}
func (core *Storage) WithAttrs(attrs []slog.Attr) slog.Handler {
return &Storage{
minLevel: core.minLevel,
attrs: append(core.attrs, attrs...),
group: core.group,
}
}
func (core *Storage) WithGroup(name string) slog.Handler {
return &Storage{
minLevel: core.minLevel,
attrs: core.attrs,
group: name,
}
}
func NewLogStorage(level slog.Level) *Storage {
return &Storage{
minLevel: level,
}
}
func ParseLogFields(data map[string]any) []any {
fields := []any{}
for k, v := range data {
fields = append(fields, k, v)
}
return fields
}

View File

@ -1,112 +0,0 @@
package main
import (
"os"
"time"
"git.sr.ht/~ashkeel/containers/sync"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
const LogHistory = 50
var (
logger *zap.Logger
lastLogs *sync.Slice[LogEntry]
incomingLogs chan LogEntry
)
func initLogger(level zapcore.Level) {
lastLogs = sync.NewSlice[LogEntry]()
incomingLogs = make(chan LogEntry, 100)
logStorage := NewLogStorage(level)
consoleLogger := zapcore.NewCore(
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
zapcore.Lock(os.Stderr),
level,
)
fileLogger := zapcore.NewCore(
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
zapcore.AddSync(&lumberjack.Logger{
Filename: logFilename,
MaxSize: 20,
MaxBackups: 3,
MaxAge: 28,
}),
level,
)
core := zapcore.NewTee(
consoleLogger,
fileLogger,
logStorage,
)
logger = zap.New(core, zap.AddCaller())
}
type LogEntry struct {
Caller string `json:"caller"`
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Data string `json:"data"`
}
type LogStorage struct {
zapcore.LevelEnabler
fields []zapcore.Field
encoder zapcore.Encoder
}
func NewLogStorage(enabler zapcore.LevelEnabler) *LogStorage {
return &LogStorage{
LevelEnabler: enabler,
encoder: zapcore.NewJSONEncoder(zap.NewDevelopmentEncoderConfig()),
}
}
func (core *LogStorage) With(fields []zapcore.Field) zapcore.Core {
clone := *core
clone.fields = append(clone.fields, fields...)
return &clone
}
func (core *LogStorage) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if core.Enabled(entry.Level) {
return checked.AddCore(entry, core)
}
return checked
}
func (core *LogStorage) Write(entry zapcore.Entry, fields []zapcore.Field) error {
buf, err := core.encoder.EncodeEntry(entry, append(core.fields, fields...))
if err != nil {
return err
}
logEntry := LogEntry{
Caller: entry.Caller.String(),
Time: entry.Time.Format(time.RFC3339),
Level: entry.Level.String(),
Message: entry.Message,
Data: buf.String(),
}
lastLogs.Push(logEntry)
if lastLogs.Size() > LogHistory {
lastLogs.Splice(0, 1)
}
incomingLogs <- logEntry
return nil
}
func (core *LogStorage) Sync() error {
return nil
}
func parseAsFields(fields map[string]any) (result []zapcore.Field) {
for k, v := range fields {
result = append(result, zap.Any(k, v))
}
return
}

371
loyalty/chat.go Normal file
View File

@ -0,0 +1,371 @@
package loyalty
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
type twitchIntegration struct {
ctx context.Context
manager *Manager
module *chat.Module
logger *slog.Logger
activeUsers *sync.Map[string, bool]
}
func setupTwitchIntegration(ctx context.Context, m *Manager, mod *chat.Module) *twitchIntegration {
li := &twitchIntegration{
ctx: ctx,
manager: m,
module: mod,
logger: log.GetLogger(ctx),
activeUsers: sync.NewMap[string, bool](),
}
// Add loyalty-based commands
mod.RegisterCommand(commandRedeem, chat.Command{
Description: "Redeem a reward with loyalty points",
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
AccessLevel: chat.ALTEveryone,
Handler: li.cmdRedeemReward,
Enabled: true,
})
mod.RegisterCommand(commandBalance, chat.Command{
Description: "See your current point balance",
Usage: commandBalance,
AccessLevel: chat.ALTEveryone,
Handler: li.cmdBalance,
Enabled: true,
})
mod.RegisterCommand(commandGoals, chat.Command{
Description: "Check currently active community goals",
Usage: commandGoals,
AccessLevel: chat.ALTEveryone,
Handler: li.cmdGoalList,
Enabled: true,
})
mod.RegisterCommand(commandContribute, chat.Command{
Description: "Contribute points to a community goal",
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
AccessLevel: chat.ALTEveryone,
Handler: li.cmdContributeGoal,
Enabled: true,
})
// Setup handler for adding points over time
go func() {
config := li.manager.Config.Get()
// Stop handler if loyalty system is disabled or there is no valid point interval
if !config.Enabled || config.Points.Interval <= 0 {
return
}
for {
// Wait for next poll
select {
case <-li.ctx.Done():
return
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
}
// If stream is confirmed offline, don't give points away!
var streamInfos []helix.Stream
err := m.db.GetJSON(twitch.StreamInfoKey, &streamInfos)
if err != nil {
li.logger.Error("Error retrieving stream info", log.Error(err))
continue
}
if len(streamInfos) < 1 {
continue
}
// Get user list
users := mod.GetChatters()
// Iterate for each user in the list
pointsToGive := make(map[string]int64)
for _, user := range users {
// Check if user is blocked
if li.manager.IsBanned(user) {
continue
}
// Check if user was active (chatting) for the bonus dingus
award := config.Points.Amount
if li.IsActive(user) {
award += config.Points.ActivityBonus
}
// Add to point pool if already on it, otherwise initialize
pointsToGive[user] = award
}
li.ResetActivity()
// If changes were made, save the pool!
if len(users) > 0 {
err := li.manager.GivePoints(pointsToGive)
if err != nil {
li.logger.Error("Error awarding loyalty points to user", log.Error(err))
}
}
}
}()
li.logger.Info("Loyalty system integration with Twitch is ready")
return li
}
func (li *twitchIntegration) Close() {
li.module.UnregisterCommand(commandRedeem)
li.module.UnregisterCommand(commandBalance)
li.module.UnregisterCommand(commandGoals)
li.module.UnregisterCommand(commandContribute)
}
func (li *twitchIntegration) HandleMessage(message helix.EventSubChannelChatMessageEvent) {
li.activeUsers.SetKey(message.ChatterUserLogin, true)
}
func (li *twitchIntegration) IsActive(user string) bool {
active, ok := li.activeUsers.GetKey(user)
return ok && active
}
func (li *twitchIntegration) ResetActivity() {
li.activeUsers = sync.NewMap[string, bool]()
}
func (li *twitchIntegration) cmdBalance(message helix.EventSubChannelChatMessageEvent) {
// Get user balance
balance := li.manager.GetPoints(message.ChatterUserLogin)
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("You have %d %s!", balance, li.manager.Config.Get().Currency),
ReplyTo: message.MessageID,
})
}
func (li *twitchIntegration) cmdRedeemReward(message helix.EventSubChannelChatMessageEvent) {
parts := strings.Fields(message.Message.Text)
if len(parts) < 2 {
return
}
redeemID := parts[1]
// Find reward
reward := li.manager.GetReward(redeemID)
if reward.ID == "" {
return
}
// Reward not active, return early
if !reward.Enabled {
return
}
// Get user balance
balance := li.manager.GetPoints(message.ChatterUserLogin)
config := li.manager.Config.Get()
// Check if user can afford the reward
if balance-reward.Price < 0 {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("I'm sorry but you cannot afford this (have %d %s, need %d)", balance, config.Currency, reward.Price),
ReplyTo: message.MessageID,
})
return
}
text := ""
if len(parts) > 2 {
text = strings.Join(parts[2:], " ")
}
// Perform redeem
if err := li.manager.PerformRedeem(Redeem{
Username: message.ChatterUserLogin,
DisplayName: message.ChatterUserName,
When: time.Now(),
Reward: reward,
RequestText: text,
}); err != nil {
if errors.Is(err, ErrRedeemInCooldown) {
nextAvailable := li.manager.GetRewardCooldown(reward.ID)
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("That reward is in cooldown (available in %s)",
time.Until(nextAvailable).Truncate(time.Second)),
ReplyTo: message.MessageID,
})
return
}
li.logger.Error("Error while performing redeem", log.Error(err))
return
}
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)",
message.ChatterUserName, reward.Name, li.manager.GetPoints(message.ChatterUserLogin), config.Currency),
})
}
func (li *twitchIntegration) cmdGoalList(message helix.EventSubChannelChatMessageEvent) {
goals := li.manager.Goals.Get()
if len(goals) < 1 {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "There are no active community goals right now :(!",
ReplyTo: message.MessageID,
})
return
}
msg := "Current goals: "
for _, goal := range goals {
if !goal.Enabled {
continue
}
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, li.manager.Config.Get().Currency, goal.ID)
}
msg += " Contribute with <!contribute POINTS GOALID>"
li.module.WriteMessage(chat.WriteMessageRequest{
Message: msg,
})
}
func (li *twitchIntegration) cmdContributeGoal(message helix.EventSubChannelChatMessageEvent) {
goals := li.manager.Goals.Get()
// Set defaults if user doesn't provide them
points := int64(100)
goalIndex := -1
hasGoals := false
// Get first unreached goal for default
for index, goal := range goals {
if !goal.Enabled {
continue
}
hasGoals = true
if goal.Contributed < goal.TotalGoal {
goalIndex = index
break
}
}
// Do we not have any goal we can contribute to? Hooray I guess?
if goalIndex < 0 {
if hasGoals {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "All active community goals have been reached already! NewRecord",
ReplyTo: message.MessageID,
})
} else {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "There are no active community goals right now :(!",
ReplyTo: message.MessageID,
})
}
return
}
// Parse parameters if provided
parts := strings.Fields(message.Message.Text)
if len(parts) > 1 {
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
if newPoints <= 0 {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "Nice try SoBayed",
ReplyTo: message.MessageID,
})
return
}
points = newPoints
}
if len(parts) > 2 {
found := false
goalID := parts[2]
// Find Goal index
for index, goal := range goals {
if !goal.Enabled {
continue
}
if goal.ID == goalID {
goalIndex = index
found = true
break
}
}
// Invalid goal ID provided
if !found {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "I couldn't find that goal ID :(",
ReplyTo: message.MessageID,
})
return
}
}
}
// Get goal
selectedGoal := goals[goalIndex]
// Check if goal was reached already
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "This goal was already reached! ヾ(•ω•`)o",
ReplyTo: message.MessageID,
})
return
}
// Add points to goal
points, err := li.manager.PerformContribution(selectedGoal, message.ChatterUserLogin, points)
if err != nil {
li.logger.Error("Error while contributing to goal", log.Error(err))
return
}
if points == 0 {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: "Sorry but you're broke",
ReplyTo: message.MessageID,
})
return
}
selectedGoal = li.manager.Goals.Get()[goalIndex]
config := li.manager.Config.Get()
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.ChatterUserName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency),
})
// Check if goal was reached!
if newRemaining <= 0 {
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name),
Announce: true,
})
}
}

View File

@ -2,22 +2,21 @@ package loyalty
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/utils"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
twitchclient "git.sr.ht/~ashkeel/strimertul/twitch/client"
"git.sr.ht/~ashkeel/strimertul/utils"
)
var json = jsoniter.ConfigFastest
var (
ErrRedeemNotFound = errors.New("redeem not found")
ErrRedeemInCooldown = errors.New("redeem is on cooldown")
@ -31,36 +30,34 @@ type Manager struct {
Rewards *sync.Slice[Reward]
Goals *sync.Slice[Goal]
Queue *sync.Slice[Redeem]
db *database.LocalDBClient
logger *zap.Logger
db database.Database
logger *slog.Logger
cooldowns map[string]time.Time
banlist map[string]bool
activeUsers *sync.Map[string, bool]
twitchManager *twitch.Manager
ctx context.Context
cancelFn context.CancelFunc
cancelSub database.CancelFunc
restartTwitchHandler chan struct{}
twitchManager *twitchclient.Manager
twitchIntegration *twitchIntegration
}
func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logger *zap.Logger) (*Manager, error) {
ctx, cancelFn := context.WithCancel(context.Background())
func NewManager(ctx context.Context, db database.Database, twitchManager *twitchclient.Manager) (*Manager, error) {
loyaltyContext, cancelFn := context.WithCancel(ctx)
loyalty := &Manager{
Config: sync.NewRWSync(Config{Enabled: false}),
Rewards: sync.NewSlice[Reward](),
Goals: sync.NewSlice[Goal](),
Queue: sync.NewSlice[Redeem](),
logger: logger,
Config: sync.NewRWSync(Config{Enabled: false}),
Rewards: sync.NewSlice[Reward](),
Goals: sync.NewSlice[Goal](),
Queue: sync.NewSlice[Redeem](),
logger: log.GetLogger(ctx),
db: db,
points: sync.NewMap[string, PointsEntry](),
cooldowns: make(map[string]time.Time),
banlist: make(map[string]bool),
activeUsers: sync.NewMap[string, bool](),
twitchManager: twitchManager,
ctx: ctx,
ctx: loyaltyContext,
cancelFn: cancelFn,
restartTwitchHandler: make(chan struct{}),
twitchManager: twitchManager,
}
// Get data from DB
var config Config
@ -111,7 +108,7 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
for k, v := range points {
var entry PointsEntry
err := json.UnmarshalFromString(v, &entry)
err := json.Unmarshal([]byte(v), &entry)
if err != nil {
return nil, err
}
@ -120,20 +117,28 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
}
// SubscribePrefix for changes
err, loyalty.cancelSub = db.SubscribePrefix(loyalty.update, "loyalty/")
loyalty.cancelSub, err = db.SubscribePrefix(loyalty.update, "loyalty/")
if err != nil {
logger.Error("Could not setup loyalty reload subscription", zap.Error(err))
loyalty.logger.Error("Could not setup loyalty reload subscription", log.Error(err))
}
loyalty.SetBanList(config.BanList)
// Setup twitch integration
loyalty.SetupTwitch()
// Start twitch handler
twitchClient := twitchManager.Client()
if twitchClient != nil && twitchClient.Chat != nil {
loyalty.twitchIntegration = setupTwitchIntegration(loyaltyContext, loyalty, twitchClient.Chat)
}
return loyalty, nil
}
func (m *Manager) Close() error {
// Disable twitch integration
if m.twitchIntegration != nil {
m.twitchIntegration.Close()
}
// Stop subscription
if m.cancelSub != nil {
m.cancelSub()
@ -142,9 +147,6 @@ func (m *Manager) Close() error {
// Send cancellation
m.cancelFn()
// Teardown twitch integration
m.StopTwitch()
return nil
}
@ -157,8 +159,6 @@ func (m *Manager) update(key, value string) {
if err == nil {
m.SetBanList(m.Config.Get().BanList)
m.restartTwitchHandler <- struct{}{}
m.StopTwitch()
m.SetupTwitch()
}
case GoalsKey:
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
@ -168,13 +168,13 @@ func (m *Manager) update(key, value string) {
err = utils.LoadJSONToWrapped[[]Redeem](value, m.Queue)
case CreateRedeemRPC:
var redeem Redeem
err = json.UnmarshalFromString(value, &redeem)
err = json.Unmarshal([]byte(value), &redeem)
if err == nil {
err = m.AddRedeem(redeem)
}
case RemoveRedeemRPC:
var redeem Redeem
err = json.UnmarshalFromString(value, &redeem)
err = json.Unmarshal([]byte(value), &redeem)
if err == nil {
err = m.RemoveRedeem(redeem)
}
@ -184,15 +184,15 @@ func (m *Manager) update(key, value string) {
// User point changed
case strings.HasPrefix(key, PointsPrefix):
var entry PointsEntry
err = json.UnmarshalFromString(value, &entry)
err = json.Unmarshal([]byte(value), &entry)
user := key[len(PointsPrefix):]
m.points.SetKey(user, entry)
}
}
if err != nil {
m.logger.Error("Subscribe error: invalid JSON received on key", zap.Error(err), zap.String("key", key))
slog.Error("Subscribe error: invalid JSON received on key", slog.String("key", key), log.Error(err))
} else {
m.logger.Debug("Updated key", zap.String("key", key))
slog.Debug("Updated key", slog.String("key", key))
}
}
@ -368,3 +368,15 @@ func (m *Manager) Equals(c utils.Comparable) bool {
}
return false
}
func (m *Manager) SetBanList(banned []string) {
m.banlist = make(map[string]bool)
for _, usr := range banned {
m.banlist[usr] = true
}
}
func (m *Manager) IsBanned(user string) bool {
banned, ok := m.banlist[user]
return ok && banned
}

View File

@ -32,7 +32,7 @@ func NewPrediction(teams []string, bettingTime time.Duration) *Prediction {
}
}
func (p *Prediction) AddBet(who string, teamId uint, amount uint64) error {
func (p *Prediction) AddBet(who string, teamID uint, amount uint64) error {
_, ok := p.Bets[who]
if ok {
return ErrPAlreadyBet
@ -40,7 +40,7 @@ func (p *Prediction) AddBet(who string, teamId uint, amount uint64) error {
p.Bets[who] = PredictionBet{
Amount: amount,
Team: teamId,
Team: teamID,
}
return nil
}

View File

@ -1,360 +0,0 @@
package loyalty
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
"go.uber.org/zap"
)
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
func (m *Manager) SetupTwitch() {
bot := m.twitchManager.Client().Bot
if bot == nil {
m.logger.Warn("Twitch bot is offline or not configured, could not setup commands")
return
}
// Add loyalty-based commands
bot.RegisterCommand(commandRedeem, twitch.BotCommand{
Description: "Redeem a reward with loyalty points",
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdRedeemReward,
Enabled: true,
})
bot.RegisterCommand(commandBalance, twitch.BotCommand{
Description: "See your current point balance",
Usage: commandBalance,
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdBalance,
Enabled: true,
})
bot.RegisterCommand(commandGoals, twitch.BotCommand{
Description: "Check currently active community goals",
Usage: commandGoals,
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdGoalList,
Enabled: true,
})
bot.RegisterCommand(commandContribute, twitch.BotCommand{
Description: "Contribute points to a community goal",
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdContributeGoal,
Enabled: true,
})
// Setup message handler for tracking user activity
bot.OnMessage.Add(m)
// Setup handler for adding points over time
go func() {
config := m.Config.Get()
// Stop handler if loyalty system is disabled or there is no valid point interval
if !config.Enabled || config.Points.Interval <= 0 {
return
}
for {
// Wait for next poll
select {
case <-m.ctx.Done():
return
case <-m.restartTwitchHandler:
return
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
}
client := m.twitchManager.Client()
// If stream is confirmed offline, don't give points away!
isOnline := client.IsLive()
if !isOnline {
continue
}
// Get user list
cursor := ""
var users []string
for {
userClient, err := client.GetUserClient(false)
if err != nil {
m.logger.Error("Could not get user api client for list of chatters", zap.Error(err))
return
}
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
BroadcasterID: client.User.ID,
ModeratorID: client.User.ID,
First: "1000",
After: cursor,
})
if err != nil {
m.logger.Error("Could not retrieve list of chatters", zap.Error(err))
return
}
for _, user := range res.Data.Chatters {
users = append(users, user.UserLogin)
}
cursor = res.Data.Pagination.Cursor
if cursor == "" {
break
}
}
// Iterate for each user in the list
pointsToGive := make(map[string]int64)
for _, user := range users {
// Check if user is blocked
if m.IsBanned(user) {
continue
}
// Check if user was active (chatting) for the bonus dingus
award := config.Points.Amount
if m.IsActive(user) {
award += config.Points.ActivityBonus
}
// Add to point pool if already on it, otherwise initialize
pointsToGive[user] = award
}
m.ResetActivity()
// If changes were made, save the pool!
if len(users) > 0 {
err := m.GivePoints(pointsToGive)
if err != nil {
m.logger.Error("Error awarding loyalty points to user", zap.Error(err))
}
}
}
}()
m.logger.Info("Loyalty system integration with Twitch is ready")
}
func (m *Manager) StopTwitch() {
bot := m.twitchManager.Client().Bot
if bot != nil {
bot.RemoveCommand(commandRedeem)
bot.RemoveCommand(commandBalance)
bot.RemoveCommand(commandGoals)
bot.RemoveCommand(commandContribute)
// Remove message handler
bot.OnMessage.Remove(m)
}
}
func (m *Manager) HandleBotMessage(message irc.PrivateMessage) {
m.activeUsers.SetKey(message.User.Name, true)
}
func (m *Manager) SetBanList(banned []string) {
m.banlist = make(map[string]bool)
for _, usr := range banned {
m.banlist[usr] = true
}
}
func (m *Manager) IsBanned(user string) bool {
banned, ok := m.banlist[user]
return ok && banned
}
func (m *Manager) IsActive(user string) bool {
active, ok := m.activeUsers.GetKey(user)
return ok && active
}
func (m *Manager) ResetActivity() {
m.activeUsers = sync.NewMap[string, bool]()
}
func (m *Manager) cmdBalance(bot *twitch.Bot, message irc.PrivateMessage) {
// Get user balance
balance := m.GetPoints(message.User.Name)
bot.Client.Say(message.Channel, fmt.Sprintf("%s: You have %d %s!", message.User.DisplayName, balance, m.Config.Get().Currency))
}
func (m *Manager) cmdRedeemReward(bot *twitch.Bot, message irc.PrivateMessage) {
parts := strings.Fields(message.Message)
if len(parts) < 2 {
return
}
redeemID := parts[1]
// Find reward
reward := m.GetReward(redeemID)
if reward.ID == "" {
return
}
// Reward not active, return early
if !reward.Enabled {
return
}
// Get user balance
balance := m.GetPoints(message.User.Name)
config := m.Config.Get()
// Check if user can afford the reward
if balance-reward.Price < 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("I'm sorry %s but you cannot afford this (have %d %s, need %d)", message.User.DisplayName, balance, config.Currency, reward.Price))
return
}
text := ""
if len(parts) > 2 {
text = strings.Join(parts[2:], " ")
}
// Perform redeem
if err := m.PerformRedeem(Redeem{
Username: message.User.Name,
DisplayName: message.User.DisplayName,
When: time.Now(),
Reward: reward,
RequestText: text,
}); err != nil {
switch err {
case ErrRedeemInCooldown:
nextAvailable := m.GetRewardCooldown(reward.ID)
bot.Client.Say(message.Channel, fmt.Sprintf("%s: That reward is in cooldown (available in %s)", message.User.DisplayName,
time.Until(nextAvailable).Truncate(time.Second)))
default:
m.logger.Error("Error while performing redeem", zap.Error(err))
}
return
}
bot.Client.Say(message.Channel, fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, m.GetPoints(message.User.Name), config.Currency))
}
func (m *Manager) cmdGoalList(bot *twitch.Bot, message irc.PrivateMessage) {
goals := m.Goals.Get()
if len(goals) < 1 {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
return
}
msg := "Current goals: "
for _, goal := range goals {
if !goal.Enabled {
continue
}
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, m.Config.Get().Currency, goal.ID)
}
msg += " Contribute with <!contribute POINTS GOALID>"
bot.Client.Say(message.Channel, msg)
}
func (m *Manager) cmdContributeGoal(bot *twitch.Bot, message irc.PrivateMessage) {
goals := m.Goals.Get()
// Set defaults if user doesn't provide them
points := int64(100)
goalIndex := -1
hasGoals := false
// Get first unreached goal for default
for index, goal := range goals {
if !goal.Enabled {
continue
}
hasGoals = true
if goal.Contributed < goal.TotalGoal {
goalIndex = index
break
}
}
// Do we not have any goal we can contribute to? Hooray I guess?
if goalIndex < 0 {
if hasGoals {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: All active community goals have been reached already! NewRecord", message.User.DisplayName))
} else {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
}
return
}
// Parse parameters if provided
parts := strings.Fields(message.Message)
if len(parts) > 1 {
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
if newPoints <= 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("Nice try %s SoBayed", message.User.DisplayName))
return
}
points = newPoints
}
if len(parts) > 2 {
found := false
goalID := parts[2]
// Find Goal index
for index, goal := range goals {
if !goal.Enabled {
continue
}
if goal.ID == goalID {
goalIndex = index
found = true
break
}
}
// Invalid goal ID provided
if !found {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: I couldn't find that goal ID :(", message.User.DisplayName))
return
}
}
}
// Get goal
selectedGoal := goals[goalIndex]
// Check if goal was reached already
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: This goal was already reached! ヾ(•ω•`)o", message.User.DisplayName))
return
}
// Add points to goal
points, err := m.PerformContribution(selectedGoal, message.User.Name, points)
if err != nil {
m.logger.Error("Error while contributing to goal", zap.Error(err))
return
}
if points == 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: Sorry but you're broke", message.User.DisplayName))
return
}
selectedGoal = m.Goals.Get()[goalIndex]
config := m.Config.Get()
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
bot.Client.Say(message.Channel, fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.User.DisplayName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency))
// Check if goal was reached!
// TODO Replace this with sub from loyalty system or something?
if newRemaining <= 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name))
}
}

72
main.go
View File

@ -4,40 +4,37 @@ import (
"context"
"embed"
"fmt"
"log"
corelog "log"
"log/slog"
_ "net/http/pprof"
"os"
"runtime/debug"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/utils"
"github.com/apenwarr/fixconsole"
jsoniter "github.com/json-iterator/go"
"github.com/urfave/cli/v2"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/runtime"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"git.sr.ht/~ashkeel/strimertul/utils"
)
var json = jsoniter.ConfigFastest
const devVersionMarker = "v0.0.0-UNKNOWN"
var appVersion = "v0.0.0-UNKNOWN"
var appVersion = devVersionMarker
const (
crashReportURL = "https://crash.strimertul.stream/upload"
logFilename = "strimertul.log"
panicFilename = "strimertul-panic.log"
)
//go:embed frontend/dist
var frontend embed.FS
func main() {
err := fixconsole.FixConsoleIfNeeded()
if err != nil {
log.Fatal(err)
if err := fixconsole.FixConsoleIfNeeded(); err != nil {
corelog.Fatal(err)
}
var panicLog *os.File
@ -86,38 +83,40 @@ func main() {
},
Before: func(ctx *cli.Context) error {
// Initialize logger with global flags
level, err := zapcore.ParseLevel(ctx.String("log-level"))
if err != nil {
level = zapcore.InfoLevel
level := slog.LevelInfo
if err := level.UnmarshalText([]byte(ctx.String("log-level"))); err != nil {
return cli.Exit(fmt.Sprintf("Invalid log level: %s", err), 1)
}
initLogger(level)
log.Init(level)
// Create file for panics
panicLog, err = os.OpenFile(panicFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o666)
var err error
panicLog, err = os.OpenFile(log.PanicFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o666)
if err != nil {
logger.Warn("Could not create panic log", zap.Error(err))
slog.Warn("Could not create panic log", log.Error(err))
} else {
utils.RedirectStderr(panicLog)
}
zap.RedirectStdLog(logger)()
ctx.Context = context.WithValue(ctx.Context, utils.ContextLogger, logger)
return nil
},
After: func(ctx *cli.Context) error {
if panicLog != nil {
utils.Close(panicLog, logger)
// For development builds, force crash dumps
if isDev() {
debug.SetTraceback("crash")
}
_ = logger.Sync()
return nil
},
After: func(_ *cli.Context) error {
if panicLog != nil {
utils.Close(panicLog)
}
return nil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
corelog.Fatal(err)
}
}
@ -135,6 +134,10 @@ func cliMain(ctx *cli.Context) error {
AssetServer: &assetserver.Options{
Assets: frontend,
},
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "d1272ae3-765a-4768-97cb-3203b788e7c5",
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
},
EnableDefaultContextMenu: true,
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
@ -160,13 +163,18 @@ func cliMain(ctx *cli.Context) error {
return nil
}
func warnOnError(err error, text string, fields ...zap.Field) {
func warnOnError(err error, text string, fields ...any) {
if err != nil {
fields = append(fields, zap.Error(err))
logger.Warn(text, fields...)
fields = append(fields, log.Error(err))
slog.Warn(text, fields...)
}
}
func fatalError(err error, text string) error {
return cli.Exit(fmt.Errorf("%s: %w", text, err), 1)
}
// isDev checks if the running code is a development version
func isDev() bool {
return appVersion == devVersionMarker
}

23
migrations/common.go Normal file
View File

@ -0,0 +1,23 @@
package migrations
import (
"errors"
"git.sr.ht/~ashkeel/strimertul/database"
)
func renameKey(db database.Database, oldKey, newKey string, ignoreMissing bool) error {
value, err := db.GetKey(oldKey)
if err != nil {
if ignoreMissing && errors.Is(err, database.ErrEmptyKey) {
return nil
}
return err
}
if err = db.PutKey(newKey, value); err != nil {
return err
}
return db.RemoveKey(oldKey)
}

71
migrations/migration.go Normal file
View File

@ -0,0 +1,71 @@
package migrations
import (
"bytes"
"errors"
"fmt"
"log/slog"
"strconv"
"git.sr.ht/~ashkeel/strimertul/database"
)
const (
SchemaKey = "strimertul/schema-version"
SchemaVersion = 4
)
// GetCurrentSchemaVersion returns the current schema version
// from the database, or 0 if it's not set (v3.x.x or earlier)
func GetCurrentSchemaVersion(db database.Database) (int, error) {
versionStr, err := db.GetKey(SchemaKey)
if err != nil {
if errors.Is(err, database.ErrEmptyKey) {
return 0, nil
}
}
if versionStr == "" {
return 0, nil
}
return strconv.Atoi(versionStr)
}
func Run(driver database.Driver, db database.Database, logger *slog.Logger) (err error) {
// Make a backup of the database
var buffer bytes.Buffer
if err = driver.Backup(&buffer); err != nil {
return fmt.Errorf("failed to backup database: %w", err)
}
// Restore the backup if an error occurs
defer func() {
if err != nil {
restoreErr := driver.Restore(bytes.NewReader(buffer.Bytes()))
if restoreErr != nil {
logger.Error("Failed to restore database from backup", "error", restoreErr)
}
}
}()
// Get the current schema version
var currentVersion int
currentVersion, err = GetCurrentSchemaVersion(db)
if err != nil {
return
}
// Migrate from v3.x.x to v4.0.0
if currentVersion < 4 {
if err = migrateToV4(db, logger); err != nil {
return
}
}
// Set the new schema version
if err = db.PutKey(SchemaKey, strconv.Itoa(SchemaVersion)); err != nil {
return
}
return
}

91
migrations/v3_v4.go Normal file
View File

@ -0,0 +1,91 @@
package migrations
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
"github.com/nicklaw5/helix/v2"
)
func migrateToV4(db database.Database, logger *slog.Logger) error {
logger.Info("Migrating database from v3 to v4")
// Rename keys that have no schema changes
for oldKey, newKey := range map[string]string{
"twitch/bot-modules/timers/config": timers.ConfigKey,
"twitch/bot-modules/alerts/config": alerts.ConfigKey,
"twitch/bot-custom-commands": chat.CustomCommandsKey,
} {
if err := renameKey(db, oldKey, newKey, true); err != nil {
return fmt.Errorf("failed to rename key '%s' to '%s': %w", oldKey, newKey, err)
}
}
// Clear old event keys and IRC-related keys
for _, key := range []string{
"twitch/chat-activity",
"twitch/chat-history",
"twitch/@send-chat-message",
"twitch/bot/@send-message",
"twitch/ev/chat-message",
"twitch/ev/eventsub-event",
} {
if err := db.RemoveKey(key); err != nil {
return fmt.Errorf("failed to remove key '%s': %w", key, err)
}
}
// Migrate bot config to chat config
var botConfig struct {
CommandCooldown int `json:"command_cooldown"`
}
if err := db.GetJSON("twitch/bot-config", &botConfig); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
botConfig.CommandCooldown = 0
} else {
return fmt.Errorf("failed to get bot config: %w", err)
}
}
if err := db.PutJSON(chat.ConfigKey, chat.Config{
CommandCooldown: botConfig.CommandCooldown,
}); err != nil {
return fmt.Errorf("failed to put chat config to new key: %w", err)
}
// Migrate eventsub history to their new keys
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Event json.RawMessage `json:"event"`
}
var eventsubHistory []eventSubNotification
if err := db.GetJSON("twitch/eventsub-history", &eventsubHistory); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return fmt.Errorf("failed to get eventsub history: %w", err)
}
eventsubHistory = []eventSubNotification{}
}
eventsubHistoryMap := make(map[string][]eventSubNotification)
for _, notification := range eventsubHistory {
key := eventsub.HistoryKeyPrefix + notification.Subscription.Type
eventsubHistoryMap[key] = append(eventsubHistoryMap[key], notification)
}
for key, notifications := range eventsubHistoryMap {
if err := db.PutJSON(key, notifications); err != nil {
return fmt.Errorf("failed to put eventsub history to new key '%s': %w", key, err)
}
}
// Clear old eventsub history key
if err := db.RemoveKey("twitch/eventsub-history"); err != nil {
return fmt.Errorf("failed to remove key 'twitch/eventsub-history': %w", err)
}
return nil
}

43
problem.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
"log/slog"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/twitch"
)
type ProblemID string
const (
ProblemIDTwitchNoApp = "twitch:app_missing"
ProblemIDEventSubNoAuth = "twitch:eventsub_no_auth"
ProblemIDEventSubScope = "twitch:eventsub_scope"
)
type Problem struct {
ID ProblemID `json:"id"`
Details any `json:"details"`
}
func (a *App) GetProblems() (problems []Problem) {
problems = []Problem{}
if a.twitchManager != nil {
client := a.twitchManager.Client()
if client != nil {
// Check if the app needs to be authorized again
scopesMatch, err := twitch.CheckScopes(client.DB)
if err != nil {
slog.Warn("Could not check scopes for problems", log.Error(err))
} else {
if !scopesMatch {
problems = append(problems, Problem{
ID: ProblemIDEventSubScope,
Details: nil,
})
}
}
}
}
return
}

3
strimertul.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
CD /D %~dp0
C:\projects\strimertul\strimertul\build\bin\strimertul.exe

69
twitch/alerts/config.go Normal file
View File

@ -0,0 +1,69 @@
package alerts
import (
"encoding/json"
"github.com/nicklaw5/helix/v2"
)
const ConfigKey = "twitch/alerts/config"
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Challenge string `json:"challenge"`
Event json.RawMessage `json:"event" desc:"Event payload, as JSON object"`
}
type subscriptionVariation struct {
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
}
type giftSubVariation struct {
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
}
type raidVariation struct {
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
}
type cheerVariation struct {
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
}
type Config struct {
Follow struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
} `json:"follow"`
Subscription struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []subscriptionVariation `json:"variations"`
} `json:"subscription"`
GiftSub struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []giftSubVariation `json:"variations"`
} `json:"gift_sub"`
Raid struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []raidVariation `json:"variations"`
} `json:"raid"`
Cheer struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []cheerVariation `json:"variations"`
} `json:"cheer"`
}

211
twitch/alerts/events.go Normal file
View File

@ -0,0 +1,211 @@
package alerts
import (
"encoding/json"
"math/rand"
"text/template"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"github.com/nicklaw5/helix/v2"
)
func (m *Module) onEventSubEvent(_ string, value string) {
var ev eventSubNotification
if err := json.Unmarshal([]byte(value), &ev); err != nil {
m.logger.Warn("Error parsing webhook payload", log.Error(err))
return
}
switch ev.Subscription.Type {
case helix.EventSubTypeChannelFollow:
// Only process if we care about follows
if !m.Config.Follow.Enabled {
return
}
// Parse as a follow event
var followEv helix.EventSubChannelFollowEvent
if err := json.Unmarshal(ev.Event, &followEv); err != nil {
m.logger.Warn("Error parsing follow event", log.Error(err))
return
}
// Pick a random message
messageID := rand.Intn(len(m.Config.Follow.Messages))
// Pick compiled template or fallback to plain text
tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]
if !ok {
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Follow.Messages[messageID],
Announce: m.Config.Follow.Announce,
})
return
}
m.writeTemplate(tpl, &followEv, m.Config.Follow.Announce)
// Compile template and send
case helix.EventSubTypeChannelRaid:
// Only process if we care about raids
if !m.Config.Raid.Enabled {
return
}
// Parse as raid event
var raidEv helix.EventSubChannelRaidEvent
if err := json.Unmarshal(ev.Event, &raidEv); err != nil {
m.logger.Warn("Error parsing raid event", log.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Raid.Messages))
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Raid.Messages[messageID],
Announce: m.Config.Raid.Announce,
})
return
}
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
if len(m.Config.Raid.Variations) > 0 {
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
return *variation.MinViewers
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
}
// Compile template and send
m.writeTemplate(tpl, &raidEv, m.Config.Raid.Announce)
case helix.EventSubTypeChannelCheer:
// Only process if we care about bits
if !m.Config.Cheer.Enabled {
return
}
// Parse as cheer event
var cheerEv helix.EventSubChannelCheerEvent
if err := json.Unmarshal(ev.Event, &cheerEv); err != nil {
m.logger.Warn("Error parsing cheer event", log.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Cheer.Messages))
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Cheer.Messages[messageID],
Announce: m.Config.Cheer.Announce,
})
return
}
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
if len(m.Config.Cheer.Variations) > 0 {
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
return *variation.MinAmount
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
}
// Compile template and send
m.writeTemplate(tpl, &cheerEv, m.Config.Cheer.Announce)
case helix.EventSubTypeChannelSubscription:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscribeEvent
if err := json.Unmarshal(ev.Event, &subEv); err != nil {
m.logger.Warn("Error parsing new subscription event", log.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionMessage:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscriptionMessageEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.logger.Warn("Error parsing returning subscription event", log.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionGift:
// Only process if we care about gifted subs
if !m.Config.GiftSub.Enabled {
return
}
// Parse as gift event
var giftEv helix.EventSubChannelSubscriptionGiftEvent
if err := json.Unmarshal(ev.Event, &giftEv); err != nil {
m.logger.Warn("Error parsing subscription gifted event", log.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.GiftSub.Messages[messageID],
Announce: m.Config.GiftSub.Announce,
})
return
}
// If we have variations, loop through all the available variations and pick the one with the highest minimum cumulative total that are met
if len(m.Config.GiftSub.Variations) > 0 {
if giftEv.IsAnonymous {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.IsAnonymous != nil && *variation.IsAnonymous {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
} else if giftEv.CumulativeTotal > 0 {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
return *variation.MinCumulative
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
}
}
// Compile template and send
m.writeTemplate(tpl, &giftEv, m.Config.GiftSub.Announce)
}
}
func (m *Module) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
if messages != nil {
messageID := rand.Intn(len(messages))
// Make sure the template is valid
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
return temp
}
}
return tpl
}
// For variations, some variations are better than others, this function returns the best one
// by using a provided score function. The score is 0 or less if the variation is not valid,
// and 1 or more if it is valid. The variation with the highest score is returned.
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
var best T
var bestScore int
for _, variation := range variations {
score := filterFunc(variation)
if score > bestScore {
best = variation
bestScore = score
}
}
return best
}

135
twitch/alerts/mixed.go Normal file
View File

@ -0,0 +1,135 @@
package alerts
import (
"math/rand"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
type subMixedEvent struct {
UserID string
UserLogin string
UserName string
BroadcasterUserID string
BroadcasterUserLogin string
BroadcasterUserName string
Tier string
IsGift bool
CumulativeMonths int
StreakMonths int
DurationMonths int
Message helix.EventSubMessage
}
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
func (m *Module) addMixedEvent(event any) {
switch sub := event.(type) {
case helix.EventSubChannelSubscribeEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.IsGift = sub.IsGift
m.pendingSubs[sub.UserID] = ev
return
}
m.pendingSubs[sub.UserID] = subMixedEvent{
UserID: sub.UserID,
UserLogin: sub.UserLogin,
UserName: sub.UserName,
BroadcasterUserID: sub.BroadcasterUserID,
BroadcasterUserLogin: sub.BroadcasterUserLogin,
BroadcasterUserName: sub.BroadcasterUserName,
Tier: sub.Tier,
IsGift: sub.IsGift,
}
go func() {
// Wait a bit to make sure we aggregate all events
time.Sleep(time.Second * 3)
m.processPendingSub(sub.UserID)
}()
case helix.EventSubChannelSubscriptionMessageEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.StreakMonths = sub.StreakMonths
ev.DurationMonths = sub.DurationMonths
ev.CumulativeMonths = sub.CumulativeMonths
ev.Message = sub.Message
return
}
m.pendingSubs[sub.UserID] = subMixedEvent{
UserID: sub.UserID,
UserLogin: sub.UserLogin,
UserName: sub.UserName,
BroadcasterUserID: sub.BroadcasterUserID,
BroadcasterUserLogin: sub.BroadcasterUserLogin,
BroadcasterUserName: sub.BroadcasterUserName,
Tier: sub.Tier,
StreakMonths: sub.StreakMonths,
DurationMonths: sub.DurationMonths,
CumulativeMonths: sub.CumulativeMonths,
Message: sub.Message,
}
go func() {
// Wait a bit to make sure we aggregate all events
time.Sleep(time.Second * 3)
m.processPendingSub(sub.UserID)
}()
}
}
func (m *Module) processPendingSub(user string) {
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
sub, ok := m.pendingSubs[user]
defer delete(m.pendingSubs, user)
if !ok {
// Somehow it's gone? Return early
return
}
// One last check in case config changed
if !m.Config.Subscription.Enabled {
return
}
// Assign random message
messageID := rand.Intn(len(m.Config.Subscription.Messages))
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Subscription.Messages[messageID],
Announce: m.Config.Subscription.Announce,
})
return
}
// Check for variations, either by streak or gifted
if sub.IsGift {
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.IsGifted != nil && *variation.IsGifted {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
} else if sub.DurationMonths > 0 {
// Get variation with the highest minimum streak that's met
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
return sub.DurationMonths
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
}
m.writeTemplate(tpl, sub, m.Config.Subscription.Announce)
}

86
twitch/alerts/module.go Normal file
View File

@ -0,0 +1,86 @@
package alerts
import (
"context"
"encoding/json"
"log/slog"
"sync"
"text/template"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
templater "git.sr.ht/~ashkeel/strimertul/twitch/template"
)
type (
templateCache map[string]*template.Template
templateCacheMap map[templateType]templateCache
)
type templateType string
const (
templateTypeSubscription templateType = "subscription"
templateTypeFollow templateType = "follow"
templateTypeRaid templateType = "raid"
templateTypeCheer templateType = "cheer"
templateTypeGift templateType = "gift"
)
type Module struct {
Config Config
ctx context.Context
db database.Database
logger *slog.Logger
templater templater.Engine
templates templateCacheMap
pendingMux sync.Mutex
pendingSubs map[string]subMixedEvent
}
func Setup(ctx context.Context, db database.Database, logger *slog.Logger, templater templater.Engine) *Module {
mod := &Module{
ctx: ctx,
db: db,
logger: logger,
templater: templater,
pendingMux: sync.Mutex{},
pendingSubs: make(map[string]subMixedEvent),
}
// Load config from database
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
logger.Debug("Config load error", log.Error(err))
mod.Config = Config{}
// Save empty config
err = db.PutJSON(ConfigKey, mod.Config)
if err != nil {
logger.Warn("Could not save default config for bot alerts", log.Error(err))
}
}
mod.compileTemplates()
if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) {
err := json.Unmarshal([]byte(value), &mod.Config)
if err != nil {
logger.Warn("Error loading alert config", log.Error(err))
} else {
logger.Info("Reloaded alert config")
}
mod.compileTemplates()
}); err != nil {
logger.Error("Could not set-up bot alert reload subscription", log.Error(err))
}
if err := db.SubscribePrefixContext(ctx, mod.onEventSubEvent, eventsub.EventKeyPrefix); err != nil {
logger.Error("Could not setup twitch alert subscription", log.Error(err))
}
logger.Debug("Loaded bot alerts")
return mod
}

View File

@ -0,0 +1,70 @@
package alerts
import (
"bytes"
"text/template"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
func (m *Module) compileTemplates() {
// Reset caches
m.templates = templateCacheMap{
templateTypeSubscription: make(templateCache),
templateTypeFollow: make(templateCache),
templateTypeRaid: make(templateCache),
templateTypeCheer: make(templateCache),
templateTypeGift: make(templateCache),
}
// Add base templates
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
// Add variations
for _, variation := range m.Config.Subscription.Variations {
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
}
for _, variation := range m.Config.Raid.Variations {
m.addTemplatesForType(templateTypeRaid, variation.Messages)
}
for _, variation := range m.Config.Cheer.Variations {
m.addTemplatesForType(templateTypeCheer, variation.Messages)
}
for _, variation := range m.Config.GiftSub.Variations {
m.addTemplatesForType(templateTypeGift, variation.Messages)
}
}
func (m *Module) addTemplate(templateList templateCache, message string) {
tpl, err := m.templater.MakeTemplate(message)
if err != nil {
m.logger.Error("Error compiling alert template", log.Error(err))
return
}
templateList[message] = tpl
}
func (m *Module) addTemplatesForType(templateList templateType, messages []string) {
for _, message := range messages {
m.addTemplate(m.templates[templateList], message)
}
}
// writeTemplate renders the template and sends the message to the channel
func (m *Module) writeTemplate(tpl *template.Template, data interface{}, announce bool) {
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
m.logger.Error("Error executing template for bot alert", log.Error(err))
return
}
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: buf.String(),
Announce: announce,
})
}

115
twitch/api.go Normal file
View File

@ -0,0 +1,115 @@
package twitch
import (
"fmt"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/database"
)
func GetConfig(db database.Database) (Config, error) {
var config Config
if err := db.GetJSON(ConfigKey, &config); err != nil {
return Config{}, fmt.Errorf("failed to get twitch config: %w", err)
}
return config, nil
}
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
Time time.Time
}
func GetUserClient(db database.Database, keyPath string, forceRefresh bool) (*helix.Client, error) {
var authResp AuthResponse
if err := db.GetJSON(keyPath, &authResp); err != nil {
return nil, err
}
// Handle token expiration
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
// Refresh tokens
api, err := GetHelixAPI(db, "")
if err != nil {
return nil, err
}
refreshed, err := api.RefreshUserAccessToken(authResp.RefreshToken)
if err != nil {
return nil, err
}
authResp.AccessToken = refreshed.Data.AccessToken
authResp.RefreshToken = refreshed.Data.RefreshToken
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
// Save new token pair
err = db.PutJSON(keyPath, authResp)
if err != nil {
return nil, err
}
}
return GetHelixAPI(db, authResp.AccessToken)
}
func GetHelixAPI(db database.Database, userToken string) (*helix.Client, error) {
config, err := GetConfig(db)
if err != nil {
return nil, err
}
// If a user token is provided, create a user client
if userToken != "" {
return helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
UserAccessToken: userToken,
})
}
// If no user token is provided, create an app client
baseurl, err := baseURL(db)
if err != nil {
return nil, err
}
redirectURI := getRedirectURI(baseurl)
// Create Twitch client
api, err := helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
RedirectURI: redirectURI,
})
if err != nil {
return nil, err
}
// Get access token
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
return nil, err
}
// Set the access token on the client
api.SetAppAccessToken(resp.Data.AccessToken)
return api, nil
}
func baseURL(db database.Database) (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := db.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func getRedirectURI(baseurl string) string {
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
}

View File

@ -1,523 +0,0 @@
package twitch
import (
"bytes"
"math/rand"
"sync"
"text/template"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
)
const BotAlertsKey = "twitch/bot-modules/alerts/config"
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Challenge string `json:"challenge"`
Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"`
}
type subscriptionVariation struct {
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
}
type giftSubVariation struct {
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
}
type raidVariation struct {
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
}
type cheerVariation struct {
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
}
type BotAlertsConfig struct {
Follow struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
} `json:"follow"`
Subscription struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
Variations []subscriptionVariation `json:"variations"`
} `json:"subscription"`
GiftSub struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
Variations []giftSubVariation `json:"variations"`
} `json:"gift_sub"`
Raid struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
Variations []raidVariation `json:"variations"`
} `json:"raid"`
Cheer struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
Variations []cheerVariation `json:"variations"`
} `json:"cheer"`
}
type (
templateCache map[string]*template.Template
templateCacheMap map[templateType]templateCache
)
type templateType string
const (
templateTypeSubscription templateType = "subscription"
templateTypeFollow templateType = "follow"
templateTypeRaid templateType = "raid"
templateTypeCheer templateType = "cheer"
templateTypeGift templateType = "gift"
)
type BotAlertsModule struct {
Config BotAlertsConfig
bot *Bot
templates templateCacheMap
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
pendingMux sync.Mutex
pendingSubs map[string]subMixedEvent
}
func SetupAlerts(bot *Bot) *BotAlertsModule {
mod := &BotAlertsModule{
bot: bot,
pendingMux: sync.Mutex{},
pendingSubs: make(map[string]subMixedEvent),
}
// Load config from database
err := bot.api.db.GetJSON(BotAlertsKey, &mod.Config)
if err != nil {
bot.logger.Debug("Config load error", zap.Error(err))
mod.Config = BotAlertsConfig{}
// Save empty config
err = bot.api.db.PutJSON(BotAlertsKey, mod.Config)
if err != nil {
bot.logger.Warn("Could not save default config for bot alerts", zap.Error(err))
}
}
mod.compileTemplates()
err, mod.cancelAlertSub = bot.api.db.SubscribeKey(BotAlertsKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
bot.logger.Warn("Error loading alert config", zap.Error(err))
} else {
bot.logger.Info("Reloaded alert config")
}
mod.compileTemplates()
})
if err != nil {
bot.logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
}
err, mod.cancelTwitchEventSub = bot.api.db.SubscribeKey(EventSubEventKey, mod.onEventSubEvent)
if err != nil {
bot.logger.Error("Could not setup twitch alert subscription", zap.Error(err))
}
bot.logger.Debug("Loaded bot alerts")
return mod
}
func (m *BotAlertsModule) onEventSubEvent(value string) {
var ev eventSubNotification
err := json.UnmarshalFromString(value, &ev)
if err != nil {
m.bot.logger.Warn("Error parsing webhook payload", zap.Error(err))
return
}
switch ev.Subscription.Type {
case helix.EventSubTypeChannelFollow:
// Only process if we care about follows
if !m.Config.Follow.Enabled {
return
}
// Parse as a follow event
var followEv helix.EventSubChannelFollowEvent
err := json.Unmarshal(ev.Event, &followEv)
if err != nil {
m.bot.logger.Warn("Error parsing follow event", zap.Error(err))
return
}
// Pick a random message
messageID := rand.Intn(len(m.Config.Follow.Messages))
// Pick compiled template or fallback to plain text
if tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]; ok {
writeTemplate(m.bot, tpl, &followEv)
} else {
m.bot.WriteMessage(m.Config.Follow.Messages[messageID])
}
// Compile template and send
case helix.EventSubTypeChannelRaid:
// Only process if we care about raids
if !m.Config.Raid.Enabled {
return
}
// Parse as raid event
var raidEv helix.EventSubChannelRaidEvent
err := json.Unmarshal(ev.Event, &raidEv)
if err != nil {
m.bot.logger.Warn("Error parsing raid event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Raid.Messages))
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
return
}
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
if len(m.Config.Raid.Variations) > 0 {
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
return *variation.MinViewers
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
}
// Compile template and send
writeTemplate(m.bot, tpl, &raidEv)
case helix.EventSubTypeChannelCheer:
// Only process if we care about bits
if !m.Config.Cheer.Enabled {
return
}
// Parse as cheer event
var cheerEv helix.EventSubChannelCheerEvent
err := json.Unmarshal(ev.Event, &cheerEv)
if err != nil {
m.bot.logger.Warn("Error parsing cheer event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Cheer.Messages))
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
return
}
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
if len(m.Config.Cheer.Variations) > 0 {
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
return *variation.MinAmount
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
}
// Compile template and send
writeTemplate(m.bot, tpl, &cheerEv)
case helix.EventSubTypeChannelSubscription:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscribeEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.bot.logger.Warn("Error parsing new subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionMessage:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscriptionMessageEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.bot.logger.Warn("Error parsing returning subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionGift:
// Only process if we care about gifted subs
if !m.Config.GiftSub.Enabled {
return
}
// Parse as gift event
var giftEv helix.EventSubChannelSubscriptionGiftEvent
err := json.Unmarshal(ev.Event, &giftEv)
if err != nil {
m.bot.logger.Warn("Error parsing subscription gifted event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.GiftSub.Messages[messageID])
return
}
// If we have variations, loop through all the available variations and pick the one with the highest minimum cumulative total that are met
if len(m.Config.GiftSub.Variations) > 0 {
if giftEv.IsAnonymous {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.IsAnonymous != nil && *variation.IsAnonymous {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
} else if giftEv.CumulativeTotal > 0 {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
return *variation.MinCumulative
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
}
}
// Compile template and send
writeTemplate(m.bot, tpl, &giftEv)
}
}
func (m *BotAlertsModule) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
if messages != nil {
messageID := rand.Intn(len(messages))
// Make sure the template is valid
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
return temp
}
}
return tpl
}
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
func (m *BotAlertsModule) addMixedEvent(event any) {
switch sub := event.(type) {
case helix.EventSubChannelSubscribeEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.IsGift = sub.IsGift
m.pendingSubs[sub.UserID] = ev
return
}
m.pendingSubs[sub.UserID] = subMixedEvent{
UserID: sub.UserID,
UserLogin: sub.UserLogin,
UserName: sub.UserName,
BroadcasterUserID: sub.BroadcasterUserID,
BroadcasterUserLogin: sub.BroadcasterUserLogin,
BroadcasterUserName: sub.BroadcasterUserName,
Tier: sub.Tier,
IsGift: sub.IsGift,
}
go func() {
// Wait a bit to make sure we aggregate all events
time.Sleep(time.Second * 3)
m.processPendingSub(sub.UserID)
}()
case helix.EventSubChannelSubscriptionMessageEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.StreakMonths = sub.StreakMonths
ev.DurationMonths = sub.DurationMonths
ev.CumulativeMonths = sub.CumulativeMonths
ev.Message = sub.Message
return
}
m.pendingSubs[sub.UserID] = subMixedEvent{
UserID: sub.UserID,
UserLogin: sub.UserLogin,
UserName: sub.UserName,
BroadcasterUserID: sub.BroadcasterUserID,
BroadcasterUserLogin: sub.BroadcasterUserLogin,
BroadcasterUserName: sub.BroadcasterUserName,
Tier: sub.Tier,
StreakMonths: sub.StreakMonths,
DurationMonths: sub.DurationMonths,
CumulativeMonths: sub.CumulativeMonths,
Message: sub.Message,
}
go func() {
// Wait a bit to make sure we aggregate all events
time.Sleep(time.Second * 3)
m.processPendingSub(sub.UserID)
}()
}
}
func (m *BotAlertsModule) processPendingSub(user string) {
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
sub, ok := m.pendingSubs[user]
defer delete(m.pendingSubs, user)
if !ok {
// Somehow it's gone? Return early
return
}
// One last check in case config changed
if !m.Config.Subscription.Enabled {
return
}
// Assign random message
messageID := rand.Intn(len(m.Config.Subscription.Messages))
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
if !ok {
m.bot.WriteMessage(m.Config.Subscription.Messages[messageID])
return
}
// Check for variations, either by streak or gifted
if sub.IsGift {
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.IsGifted != nil && *variation.IsGifted {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
} else if sub.DurationMonths > 0 {
// Get variation with the highest minimum streak that's met
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
return sub.DurationMonths
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
}
writeTemplate(m.bot, tpl, sub)
}
// For variations, some variations are better than others, this function returns the best one
// by using a provided score function. The score is 0 or less if the variation is not valid,
// and 1 or more if it is valid. The variation with the highest score is returned.
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
var best T
var bestScore int
for _, variation := range variations {
score := filterFunc(variation)
if score > bestScore {
best = variation
bestScore = score
}
}
return best
}
func (m *BotAlertsModule) compileTemplates() {
// Reset caches
m.templates = templateCacheMap{
templateTypeSubscription: make(templateCache),
templateTypeFollow: make(templateCache),
templateTypeRaid: make(templateCache),
templateTypeCheer: make(templateCache),
templateTypeGift: make(templateCache),
}
// Add base templates
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
// Add variations
for _, variation := range m.Config.Subscription.Variations {
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
}
for _, variation := range m.Config.Raid.Variations {
m.addTemplatesForType(templateTypeRaid, variation.Messages)
}
for _, variation := range m.Config.Cheer.Variations {
m.addTemplatesForType(templateTypeCheer, variation.Messages)
}
for _, variation := range m.Config.GiftSub.Variations {
m.addTemplatesForType(templateTypeGift, variation.Messages)
}
}
func (m *BotAlertsModule) addTemplate(templateList templateCache, message string) {
tpl, err := m.bot.MakeTemplate(message)
if err != nil {
m.bot.logger.Error("Error compiling alert template", zap.Error(err))
return
}
templateList[message] = tpl
}
func (m *BotAlertsModule) addTemplatesForType(templateList templateType, messages []string) {
for _, message := range messages {
m.addTemplate(m.templates[templateList], message)
}
}
func (m *BotAlertsModule) Close() {
if m.cancelAlertSub != nil {
m.cancelAlertSub()
}
if m.cancelTwitchEventSub != nil {
m.cancelTwitchEventSub()
}
}
// writeTemplate renders the template and sends the message to the channel
func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) {
var buf bytes.Buffer
err := tpl.Execute(&buf, data)
if err != nil {
bot.logger.Error("Error executing template for bot alert", zap.Error(err))
return
}
bot.WriteMessage(buf.String())
}
type subMixedEvent struct {
UserID string
UserLogin string
UserName string
BroadcasterUserID string
BroadcasterUserLogin string
BroadcasterUserName string
Tier string
IsGift bool
CumulativeMonths int
StreakMonths int
DurationMonths int
Message helix.EventSubMessage
}

View File

@ -1,386 +0,0 @@
package twitch
import (
"errors"
"strings"
"text/template"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
)
type IRCBot interface {
Join(channel ...string)
Connect() error
Disconnect() error
Say(channel, message string)
Reply(channel, messageID, message string)
OnConnect(handler func())
OnPrivateMessage(handler func(irc.PrivateMessage))
OnUserJoinMessage(handler func(message irc.UserJoinMessage))
OnUserPartMessage(handler func(message irc.UserPartMessage))
}
type Bot struct {
Client IRCBot
Config BotConfig
api *Client
username string
logger *zap.Logger
lastMessage *sync.RWSync[time.Time]
chatHistory *sync.Slice[irc.PrivateMessage]
commands *sync.Map[string, BotCommand]
customCommands *sync.Map[string, BotCustomCommand]
customTemplates *sync.Map[string, *template.Template]
customFunctions template.FuncMap
OnConnect *utils.SyncList[BotConnectHandler]
OnMessage *utils.SyncList[BotMessageHandler]
cancelUpdateSub database.CancelFunc
cancelWritePlainRPCSub database.CancelFunc
cancelWriteRPCSub database.CancelFunc
// Module specific vars
Timers *BotTimerModule
Alerts *BotAlertsModule
}
type BotConnectHandler interface {
utils.Comparable
HandleBotConnect()
}
type BotMessageHandler interface {
utils.Comparable
HandleBotMessage(message irc.PrivateMessage)
}
func (b *Bot) Migrate(old *Bot) {
utils.MergeSyncMap(b.commands, old.commands)
// Get registered commands and handlers from old bot
b.OnConnect.Copy(old.OnConnect)
b.OnMessage.Copy(old.OnMessage)
}
func newBot(api *Client, config BotConfig) *Bot {
// Create client
client := irc.NewClient(config.Username, config.Token)
return newBotWithClient(client, api, config)
}
func newBotWithClient(client IRCBot, api *Client, config BotConfig) *Bot {
bot := &Bot{
Client: client,
Config: config,
username: strings.ToLower(config.Username), // Normalize username
logger: api.logger,
api: api,
lastMessage: sync.NewRWSync(time.Now()),
commands: sync.NewMap[string, BotCommand](),
customCommands: sync.NewMap[string, BotCustomCommand](),
customTemplates: sync.NewMap[string, *template.Template](),
chatHistory: sync.NewSlice[irc.PrivateMessage](),
OnConnect: utils.NewSyncList[BotConnectHandler](),
OnMessage: utils.NewSyncList[BotMessageHandler](),
}
client.OnConnect(bot.onConnectHandler)
client.OnPrivateMessage(bot.onMessageHandler)
client.OnUserJoinMessage(bot.onJoinHandler)
client.OnUserPartMessage(bot.onPartHandler)
bot.Client.Join(config.Channel)
bot.setupFunctions()
// Load modules
bot.Timers = SetupTimers(bot)
bot.Alerts = SetupAlerts(bot)
// Load custom commands
var customCommands map[string]BotCustomCommand
err := api.db.GetJSON(CustomCommandsKey, &customCommands)
if err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]BotCustomCommand)
} else {
bot.logger.Error("Failed to load custom commands", zap.Error(err))
}
}
bot.customCommands.Set(customCommands)
err = bot.updateTemplates()
if err != nil {
bot.logger.Error("Failed to parse custom commands", zap.Error(err))
}
err, bot.cancelUpdateSub = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
err, bot.cancelWritePlainRPCSub = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
err, bot.cancelWriteRPCSub = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
return bot
}
func (b *Bot) onJoinHandler(message irc.UserJoinMessage) {
if strings.ToLower(message.User) == b.username {
b.logger.Info("Twitch bot joined channel", zap.String("channel", message.Channel))
} else {
b.logger.Debug("User joined channel", zap.String("channel", message.Channel), zap.String("username", message.User))
}
}
func (b *Bot) onPartHandler(message irc.UserPartMessage) {
if strings.ToLower(message.User) == b.username {
b.logger.Info("Twitch bot left channel", zap.String("channel", message.Channel))
} else {
b.logger.Debug("User left channel", zap.String("channel", message.Channel), zap.String("username", message.User))
}
}
func (b *Bot) onMessageHandler(message irc.PrivateMessage) {
for _, handler := range b.OnMessage.Items() {
if handler != nil {
handler.HandleBotMessage(message)
}
}
// Ignore messages for a while or twitch will get mad!
if time.Now().Before(b.lastMessage.Get().Add(time.Second * time.Duration(b.Config.CommandCooldown))) {
b.logger.Debug("Message received too soon, ignoring")
return
}
lowercaseMessage := strings.TrimSpace(strings.ToLower(message.Message))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
// Run through supported commands
for cmd, data := range b.commands.Copy() {
if !data.Enabled {
continue
}
if !strings.HasPrefix(lowercaseMessage, cmd) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != cmd {
continue
}
go data.Handler(b, message)
b.lastMessage.Set(time.Now())
}
}
// Run through custom commands
for cmd, data := range b.customCommands.Copy() {
if !data.Enabled {
continue
}
lc := strings.ToLower(cmd)
if !strings.HasPrefix(lowercaseMessage, lc) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != lc {
continue
}
go cmdCustom(b, cmd, data, message)
b.lastMessage.Set(time.Now())
}
err := b.api.db.PutJSON(ChatEventKey, message)
if err != nil {
b.logger.Warn("Could not save chat message to key", zap.String("key", ChatEventKey), zap.Error(err))
}
if b.Config.ChatHistory > 0 {
history := b.chatHistory.Get()
if len(history) >= b.Config.ChatHistory {
history = history[len(history)-b.Config.ChatHistory+1:]
}
b.chatHistory.Set(append(history, message))
err = b.api.db.PutJSON(ChatHistoryKey, b.chatHistory.Get())
if err != nil {
b.logger.Warn("Could not save message to chat history", zap.Error(err))
}
}
if b.Timers != nil {
go b.Timers.OnMessage(message)
}
}
func (b *Bot) onConnectHandler() {
for _, handler := range b.OnConnect.Items() {
if handler != nil {
handler.HandleBotConnect()
}
}
}
func (b *Bot) Close() error {
if b.cancelUpdateSub != nil {
b.cancelUpdateSub()
}
if b.cancelWriteRPCSub != nil {
b.cancelWriteRPCSub()
}
if b.cancelWritePlainRPCSub != nil {
b.cancelWritePlainRPCSub()
}
if b.Timers != nil {
b.Timers.Close()
}
if b.Alerts != nil {
b.Alerts.Close()
}
return b.Client.Disconnect()
}
func (b *Bot) updateCommands(value string) {
err := utils.LoadJSONToWrapped[map[string]BotCustomCommand](value, b.customCommands)
if err != nil {
b.logger.Error("Failed to decode new custom commands", zap.Error(err))
return
}
// Recreate templates
if err := b.updateTemplates(); err != nil {
b.logger.Error("Failed to update custom commands templates", zap.Error(err))
return
}
}
func (b *Bot) handleWritePlainMessageRPC(value string) {
b.Client.Say(b.Config.Channel, value)
}
func (b *Bot) handleWriteMessageRPC(value string) {
var request WriteMessageRequest
err := json.Unmarshal([]byte(value), &request)
if err != nil {
b.logger.Warn("Failed to decode write message request", zap.Error(err))
return
}
if request.ReplyTo != nil && *request.ReplyTo != "" {
b.Client.Reply(b.Config.Channel, *request.ReplyTo, request.Message)
return
}
if request.WhisperTo != nil && *request.WhisperTo != "" {
client, err := b.api.GetUserClient(false)
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: b.api.User.ID,
ToUserID: *request.WhisperTo,
Message: request.Message,
})
if reply.Error != "" {
b.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
b.logger.Error("Failed to send whisper", zap.Error(err))
}
return
}
if request.Announce {
client, err := b.api.GetUserClient(false)
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: b.api.User.ID,
ModeratorID: b.api.User.ID,
Message: request.Message,
})
if reply.Error != "" {
b.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
b.logger.Error("Failed to send announcement", zap.Error(err))
}
return
}
b.Client.Say(b.Config.Channel, request.Message)
}
func (b *Bot) updateTemplates() error {
b.customTemplates.Set(make(map[string]*template.Template))
for cmd, tmpl := range b.customCommands.Copy() {
tpl, err := b.MakeTemplate(tmpl.Response)
if err != nil {
return err
}
b.customTemplates.SetKey(cmd, tpl)
}
return nil
}
func (b *Bot) Connect() {
err := b.Client.Connect()
if err != nil {
if errors.Is(err, irc.ErrClientDisconnected) {
b.logger.Info("Twitch bot connection terminated", zap.Error(err))
} else {
b.logger.Error("Twitch bot connection terminated unexpectedly", zap.Error(err))
}
}
}
func (b *Bot) WriteMessage(message string) {
b.Client.Say(b.Config.Channel, message)
}
func (b *Bot) RegisterCommand(trigger string, command BotCommand) {
b.commands.SetKey(trigger, command)
}
func (b *Bot) RemoveCommand(trigger string) {
b.commands.DeleteKey(trigger)
}
func getUserAccessLevel(user irc.User) AccessLevelType {
// Check broadcaster
if _, ok := user.Badges["broadcaster"]; ok {
return ALTStreamer
}
// Check mods
if _, ok := user.Badges["moderator"]; ok {
return ALTModerators
}
// Check VIP
if _, ok := user.Badges["vip"]; ok {
return ALTVIP
}
// Check subscribers
if _, ok := user.Badges["subscriber"]; ok {
return ALTSubscribers
}
return ALTEveryone
}
func defaultBotConfig() BotConfig {
return BotConfig{
CommandCooldown: 2,
}
}

View File

@ -1,181 +0,0 @@
package twitch
import (
"math/rand"
"time"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
)
const BotTimersKey = "twitch/bot-modules/timers/config"
type BotTimersConfig struct {
Timers map[string]BotTimer `json:"timers" desc:"List of timers as a dictionary"`
}
type BotTimer struct {
// Whether the timer is enabled
Enabled bool `json:"enabled" desc:"Enable the timer"`
// Timer name (must be unique)
Name string `json:"name" desc:"Timer name (must be unique)"`
// Minimum chat messages in the last 5 minutes for timer to trigger
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
// Minimum amount of time (in seconds) that needs to pass before it triggers again
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
// Messages to write (randomly chosen)
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
}
const AverageMessageWindow = 5
type BotTimerModule struct {
Config BotTimersConfig
bot *Bot
lastTrigger *sync.Map[string, time.Time]
messages *sync.Slice[int]
cancelTimerSub database.CancelFunc
}
func SetupTimers(bot *Bot) *BotTimerModule {
mod := &BotTimerModule{
bot: bot,
lastTrigger: sync.NewMap[string, time.Time](),
messages: sync.NewSlice[int](),
}
// Fill messages with zero values
// (This can probably be done faster)
for i := 0; i < AverageMessageWindow; i += 1 {
mod.messages.Push(0)
}
// Load config from database
err := bot.api.db.GetJSON(BotTimersKey, &mod.Config)
if err != nil {
bot.logger.Debug("Config load error", zap.Error(err))
mod.Config = BotTimersConfig{
Timers: make(map[string]BotTimer),
}
// Save empty config
err = bot.api.db.PutJSON(BotTimersKey, mod.Config)
if err != nil {
bot.logger.Warn("Could not save default config for bot timers", zap.Error(err))
}
}
err, mod.cancelTimerSub = bot.api.db.SubscribeKey(BotTimersKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
bot.logger.Debug("Error reloading timer config", zap.Error(err))
} else {
bot.logger.Info("Reloaded timer config")
}
})
if err != nil {
bot.logger.Error("Could not set-up timer reload subscription", zap.Error(err))
}
bot.logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
// Start goroutine for clearing message counters and running timers
go mod.runTimers()
return mod
}
func (m *BotTimerModule) runTimers() {
for {
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
currentTime := time.Now()
nextTick := currentTime.Round(time.Minute).Add(time.Minute)
timeUntilNextTick := nextTick.Sub(currentTime)
time.Sleep(timeUntilNextTick)
err := m.bot.api.db.PutJSON(ChatActivityKey, m.messages.Get())
if err != nil {
m.bot.logger.Warn("Error saving chat activity", zap.Error(err))
}
// Calculate activity
activity := m.currentChatActivity()
// Reset timer
index := time.Now().Minute() % AverageMessageWindow
messages := m.messages.Get()
messages[index] = 0
m.messages.Set(messages)
// Run timers
for name, timer := range m.Config.Timers {
m.ProcessTimer(name, timer, activity)
}
}
}
func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int) {
// Must be enabled
if !timer.Enabled {
return
}
// Check if enough time has passed
lastTriggeredTime, ok := m.lastTrigger.GetKey(name)
if !ok {
// If it's the first time we're checking it, start the cooldown
lastTriggeredTime = time.Now()
m.lastTrigger.SetKey(name, lastTriggeredTime)
}
minDelay := timer.MinimumDelay
if minDelay < 60 {
minDelay = 60
}
now := time.Now()
if now.Sub(lastTriggeredTime) < time.Duration(minDelay)*time.Second {
return
}
// Make sure chat activity is high enough
if activity < timer.MinimumChatActivity {
return
}
// Pick a random message
message := timer.Messages[rand.Intn(len(timer.Messages))]
// Write message to chat
m.bot.WriteMessage(message)
// Update last trigger
m.lastTrigger.SetKey(name, now)
}
func (m *BotTimerModule) Close() {
if m.cancelTimerSub != nil {
m.cancelTimerSub()
}
}
func (m *BotTimerModule) currentChatActivity() int {
total := 0
for _, v := range m.messages.Get() {
total += v
}
return total
}
func (m *BotTimerModule) OnMessage(message irc.PrivateMessage) {
index := message.Time.Minute() % AverageMessageWindow
m.messages.SetIndex(index, 1)
}

106
twitch/chat/commands.go Normal file
View File

@ -0,0 +1,106 @@
package chat
import (
"bytes"
"log/slog"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/log"
)
var accessLevels = map[AccessLevelType]int{
ALTEveryone: 0,
ALTSubscribers: 1,
ALTVIP: 2,
ALTModerators: 3,
ALTStreamer: 999,
}
type CommandHandler func(message helix.EventSubChannelChatMessageEvent)
type Command struct {
Description string
Usage string
AccessLevel AccessLevelType
Handler CommandHandler
Enabled bool
}
func getUserAccessLevel(badges []helix.EventSubChatBadge) AccessLevelType {
// Read badges
var broadcaster, moderator, vip, subscriber bool
for _, badge := range badges {
switch badge.SetID {
case "broadcaster":
broadcaster = true
case "moderator":
moderator = true
case "vip":
vip = true
case "subscriber":
subscriber = true
}
}
switch {
case broadcaster:
return ALTStreamer
case moderator:
return ALTModerators
case vip:
return ALTVIP
case subscriber:
return ALTSubscribers
default:
return ALTEveryone
}
}
func cmdCustom(mod *Module, cmd string, data CustomCommand, message helix.EventSubChannelChatMessageEvent) {
// Check access level
accessLevel := getUserAccessLevel(message.Badges)
// Ensure that access level is high enough
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
return
}
var buf bytes.Buffer
tpl, ok := mod.customTemplates.GetKey(cmd)
if !ok {
return
}
if err := tpl.Execute(&buf, message); err != nil {
mod.logger.Error("Failed to execute custom command template", log.Error(err))
return
}
var request WriteMessageRequest
switch data.ResponseType {
case ResponseTypeDefault, ResponseTypeChat:
request = WriteMessageRequest{
Message: buf.String(),
}
case ResponseTypeReply:
request = WriteMessageRequest{
Message: buf.String(),
ReplyTo: message.MessageID,
}
case ResponseTypeWhisper:
request = WriteMessageRequest{
Message: buf.String(),
WhisperTo: message.ChatterUserID,
}
case ResponseTypeAnnounce:
request = WriteMessageRequest{
Message: buf.String(),
Announce: true,
}
default:
mod.logger.Error("Unknown response type", slog.String("type", string(data.ResponseType)))
}
WriteMessage(mod.db, mod.logger, request)
}

8
twitch/chat/config.go Normal file
View File

@ -0,0 +1,8 @@
package chat
const ConfigKey = "twitch/chat/config"
type Config struct {
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}

46
twitch/chat/data.go Normal file
View File

@ -0,0 +1,46 @@
package chat
const (
ActivityKey = "twitch/chat/activity"
CustomCommandsKey = "twitch/chat/custom-commands"
WriteMessageRPC = "twitch/chat/@send-message"
CustomAccountKey = "twitch/chat/chatter-account"
)
type ResponseType string
const (
ResponseTypeDefault ResponseType = ""
ResponseTypeChat ResponseType = "chat"
ResponseTypeWhisper ResponseType = "whisper"
ResponseTypeReply ResponseType = "reply"
ResponseTypeAnnounce ResponseType = "announce"
)
type AccessLevelType string
const (
ALTEveryone AccessLevelType = "everyone"
ALTSubscribers AccessLevelType = "subscriber"
ALTVIP AccessLevelType = "vip"
ALTModerators AccessLevelType = "moderators"
ALTStreamer AccessLevelType = "streamer"
)
// CustomCommand is a definition of a custom command of the chatbot
type CustomCommand struct {
// Command description
Description string `json:"description" desc:"Command description"`
// Minimum access level needed to use the command
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
// Response template (in Go templating format)
Response string `json:"response" desc:"Response template (in Go templating format)"`
// Is the command enabled?
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
// How to respond to the user
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
}

303
twitch/chat/module.go Normal file
View File

@ -0,0 +1,303 @@
package chat
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
textTemplate "text/template"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/template"
"git.sr.ht/~ashkeel/strimertul/utils"
)
type Module struct {
Config Config
streamerAPI *helix.Client
ctx context.Context
db database.Database
api *helix.Client
streamer helix.User
user helix.User
logger *slog.Logger
templater template.Engine
lastMessage *sync.RWSync[time.Time]
commands *sync.Map[string, Command]
customCommands *sync.Map[string, CustomCommand]
customTemplates *sync.Map[string, *textTemplate.Template]
customFunctions textTemplate.FuncMap
}
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *slog.Logger, templater template.Engine) *Module {
mod := &Module{
ctx: ctx,
db: db,
streamerAPI: api,
api: api,
streamer: user,
user: user,
logger: logger,
templater: templater,
lastMessage: sync.NewRWSync(time.Now()),
commands: sync.NewMap[string, Command](),
customCommands: sync.NewMap[string, CustomCommand](),
customTemplates: sync.NewMap[string, *textTemplate.Template](),
customFunctions: make(textTemplate.FuncMap),
}
// Get config
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
mod.Config = Config{
CommandCooldown: 2,
}
} else {
logger.Error("Failed to load chat module config", log.Error(err))
}
}
// Set custom user (and set hook to reload when it changes)
mod.setCustomUser()
if err := db.SubscribeKeyContext(ctx, CustomAccountKey, func(value string) {
mod.setCustomUser()
}); err != nil {
logger.Error("Could not subscribe to custom account changes", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil {
logger.Error("Could not subscribe to chat messages", log.Error(err))
}
// Load custom commands
var customCommands map[string]CustomCommand
if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]CustomCommand)
} else {
logger.Error("Failed to load custom commands", log.Error(err))
}
}
mod.customCommands.Set(customCommands)
if err := mod.updateTemplates(); err != nil {
logger.Error("Failed to parse custom commands", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, CustomCommandsKey, mod.updateCommands); err != nil {
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, WriteMessageRPC, mod.handleWriteMessageRPC); err != nil {
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
}
return mod
}
func (mod *Module) setCustomUser() {
customUserClient, customUserInfo, err := GetCustomUser(mod.db)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
mod.logger.Error("Failed to get custom user, falling back to streamer account", log.Error(err))
}
customUserClient = mod.streamerAPI
customUserInfo = mod.streamer
}
mod.api = customUserClient
mod.user = customUserInfo
}
func (mod *Module) onChatMessage(newValue string) {
var chatMessage struct {
Event helix.EventSubChannelChatMessageEvent `json:"event"`
}
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
mod.logger.Error("Failed to decode incoming chat message", log.Error(err))
return
}
// TODO Command cooldown logic here!
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Event.Message.Text))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
// Run through supported commands
for cmd, data := range mod.commands.Copy() {
if !data.Enabled {
continue
}
if !strings.HasPrefix(lowercaseMessage, cmd) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != cmd {
continue
}
go data.Handler(chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
}
// Run through custom commands
for cmd, data := range mod.customCommands.Copy() {
if !data.Enabled {
continue
}
lc := strings.ToLower(cmd)
if !strings.HasPrefix(lowercaseMessage, lc) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != lc {
continue
}
go cmdCustom(mod, cmd, data, chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
}
func (mod *Module) handleWriteMessageRPC(value string) {
var request WriteMessageRequest
if err := json.Unmarshal([]byte(value), &request); err != nil {
mod.logger.Warn("Failed to decode write message request", log.Error(err))
return
}
if request.Announce {
resp, err := mod.api.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: mod.streamer.ID,
ModeratorID: mod.user.ID,
Message: request.Message,
})
if err != nil {
mod.logger.Error("Failed to send announcement", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send announcement", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
}
return
}
if request.WhisperTo != "" {
resp, err := mod.api.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: mod.user.ID,
ToUserID: request.WhisperTo,
Message: request.Message,
})
if err != nil {
mod.logger.Error("Failed to send whisper", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send whisper", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
}
return
}
resp, err := mod.api.SendChatMessage(&helix.SendChatMessageParams{
BroadcasterID: mod.streamer.ID,
SenderID: mod.user.ID,
Message: request.Message,
ReplyParentMessageID: request.ReplyTo,
})
if err != nil {
mod.logger.Error("Failed to send chat message", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send chat message", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
}
}
func (mod *Module) updateCommands(value string) {
err := utils.LoadJSONToWrapped[map[string]CustomCommand](value, mod.customCommands)
if err != nil {
mod.logger.Error("Failed to decode new custom commands", log.Error(err))
return
}
// Recreate templates
if err := mod.updateTemplates(); err != nil {
mod.logger.Error("Failed to update custom commands templates", log.Error(err))
return
}
}
func (mod *Module) updateTemplates() error {
mod.customTemplates.Set(make(map[string]*textTemplate.Template))
for cmd, tmpl := range mod.customCommands.Copy() {
tpl, err := mod.templater.MakeTemplate(tmpl.Response)
if err != nil {
return err
}
mod.customTemplates.SetKey(cmd, tpl)
}
return nil
}
func (mod *Module) WriteMessage(request WriteMessageRequest) {
WriteMessage(mod.db, mod.logger, request)
}
func (mod *Module) RegisterCommand(name string, command Command) {
mod.commands.SetKey(name, command)
}
func (mod *Module) UnregisterCommand(name string) {
mod.commands.DeleteKey(name)
}
func (mod *Module) GetChatters() (users []string) {
cursor := ""
for {
userClient, err := twitch.GetUserClient(mod.db, twitch.AuthKey, false)
if err != nil {
slog.Error("Could not get user api client for list of chatters", log.Error(err))
return
}
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
BroadcasterID: mod.user.ID,
ModeratorID: mod.user.ID,
First: "1000",
After: cursor,
})
if err != nil {
mod.logger.Error("Could not retrieve list of chatters", log.Error(err))
return
}
for _, user := range res.Data.Chatters {
users = append(users, user.UserLogin)
}
cursor = res.Data.Pagination.Cursor
if cursor == "" {
return
}
}
}
func GetCustomUser(db database.Database) (*helix.Client, helix.User, error) {
userClient, err := twitch.GetUserClient(db, CustomAccountKey, true)
if err != nil {
return nil, helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return nil, helix.User{}, err
}
if len(users.Data.Users) < 1 {
return nil, helix.User{}, errors.New("no users found")
}
return userClient, users.Data.Users[0], nil
}

23
twitch/chat/write.go Normal file
View File

@ -0,0 +1,23 @@
package chat
import (
"log/slog"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/log"
)
// WriteMessageRequest is an RPC to send a chat message with extra options
type WriteMessageRequest struct {
Message string `json:"message" desc:"Chat message to send"`
ReplyTo string `json:"reply_to,omitempty" desc:"If specified, send as reply to a message ID"`
WhisperTo string `json:"whisper_to,omitempty" desc:"If specified, send as whisper to user ID"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
}
func WriteMessage(db database.Database, logger *slog.Logger, m WriteMessageRequest) {
err := db.PutJSON(WriteMessageRPC, m)
if err != nil {
logger.Error("Failed to write chat message", log.Error(err))
}
}

View File

@ -1,123 +0,0 @@
package twitch
import (
"fmt"
"net/http"
"time"
"github.com/nicklaw5/helix/v2"
)
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
Time time.Time
}
func (c *Client) GetAuthorizationURL() string {
if c.API == nil {
return "twitch-not-configured"
}
return c.API.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code",
Scopes: []string{"bits:read channel:read:subscriptions channel:read:redemptions channel:read:polls channel:read:predictions channel:read:hype_train user_read chat:read chat:edit channel:moderate whispers:read whispers:edit moderator:read:chatters moderator:read:followers user:manage:whispers moderator:manage:announcements"},
})
}
func (c *Client) GetUserClient(forceRefresh bool) (*helix.Client, error) {
var authResp AuthResponse
err := c.db.GetJSON(AuthKey, &authResp)
if err != nil {
return nil, err
}
// Handle token expiration
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
// Refresh tokens
refreshed, err := c.API.RefreshUserAccessToken(authResp.RefreshToken)
if err != nil {
return nil, err
}
authResp.AccessToken = refreshed.Data.AccessToken
authResp.RefreshToken = refreshed.Data.RefreshToken
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
// Save new token pair
err = c.db.PutJSON(AuthKey, authResp)
if err != nil {
return nil, err
}
}
config := c.Config.Get()
return helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
UserAccessToken: authResp.AccessToken,
})
}
func (c *Client) GetLoggedUser() (helix.User, error) {
if c.User.ID != "" {
return c.User, nil
}
client, err := c.GetUserClient(false)
if err != nil {
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
}
users, err := client.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
}
if len(users.Data.Users) < 1 {
return helix.User{}, fmt.Errorf("no users found")
}
c.User = users.Data.Users[0]
return c.User, nil
}
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Get code from params
code := req.URL.Query().Get("code")
if code == "" {
// TODO Nice error page
http.Error(w, "missing code", http.StatusBadRequest)
return
}
// Exchange code for access/refresh tokens
userTokenResponse, err := c.API.RequestUserAccessToken(code)
if err != nil {
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
return
}
err = c.db.PutJSON(AuthKey, AuthResponse{
AccessToken: userTokenResponse.Data.AccessToken,
RefreshToken: userTokenResponse.Data.RefreshToken,
ExpiresIn: userTokenResponse.Data.ExpiresIn,
Scope: userTokenResponse.Data.Scopes,
Time: time.Now(),
})
if err != nil {
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
}
type RefreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope []string `json:"scope"`
}
func getRedirectURI(baseurl string) string {
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
}

View File

@ -1,268 +0,0 @@
package twitch
import (
"fmt"
"time"
"git.sr.ht/~ashkeel/strimertul/utils"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
func (c *Client) eventSubLoop(userClient *helix.Client) {
endpoint := websocketEndpoint
var err error
var connection *websocket.Conn
for endpoint != "" {
endpoint, connection, err = c.connectWebsocket(endpoint, connection, userClient)
if err != nil {
c.logger.Error("EventSub websocket read error", zap.Error(err))
}
}
if connection != nil {
utils.Close(connection, c.logger)
}
}
func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error) {
for {
messageType, messageData, err := connection.ReadMessage()
if err != nil {
wsErr <- err
close(recv)
close(wsErr)
return
}
if messageType != websocket.TextMessage {
continue
}
recv <- messageData
}
}
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, userClient *helix.Client) (string, *websocket.Conn, error) {
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
c.logger.Error("Could not establish a connection to the EventSub websocket", zap.Error(err))
return "", nil, err
}
received := make(chan []byte, 10)
wsErr := make(chan error, 1)
go readLoop(connection, received, wsErr)
for {
// Wait for next message or closing/error
var messageData []byte
select {
case <-c.ctx.Done():
return "", nil, nil
case err = <-wsErr:
return url, nil, err // Return the endpoint so we can reconnect
case messageData = <-received:
}
var wsMessage EventSubWebsocketMessage
err = json.Unmarshal(messageData, &wsMessage)
if err != nil {
c.logger.Error("Error decoding EventSub message", zap.Error(err))
continue
}
reconnectURL, err, done := c.processMessage(wsMessage, oldConnection, userClient)
if done {
return reconnectURL, connection, err
}
}
}
func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnection *websocket.Conn, userClient *helix.Client) (string, error, bool) {
switch wsMessage.Metadata.MessageType {
case "session_keepalive":
// Nothing to do
case "session_welcome":
var welcomeData WelcomeMessagePayload
err := json.Unmarshal(wsMessage.Payload, &welcomeData)
if err != nil {
c.logger.Error("Error decoding EventSub welcome message", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
break
}
c.logger.Info("Connection to EventSub websocket established", zap.String("session-id", welcomeData.Session.Id))
// We can only close the old connection once the new one has been established
if oldConnection != nil {
utils.Close(oldConnection, c.logger)
}
// Add subscription to websocket session
err = c.addSubscriptionsForSession(userClient, welcomeData.Session.Id)
if err != nil {
c.logger.Error("Could not add subscriptions", zap.Error(err))
break
}
case "session_reconnect":
var reconnectData WelcomeMessagePayload
err := json.Unmarshal(wsMessage.Payload, &reconnectData)
if err != nil {
c.logger.Error("Error decoding EventSub session reconnect parameters", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
break
}
c.logger.Info("EventSub websocket requested a reconnection", zap.String("session-id", reconnectData.Session.Id), zap.String("reconnect-url", reconnectData.Session.ReconnectUrl))
return reconnectData.Session.ReconnectUrl, nil, true
case "notification":
go c.processEvent(wsMessage)
case "revocation":
// TODO idk what to do here
}
return "", nil, false
}
func (c *Client) processEvent(message EventSubWebsocketMessage) {
// Check if we processed this already
if message.Metadata.MessageId != "" {
if c.eventCache.Contains(message.Metadata.MessageId) {
c.logger.Debug("Received duplicate event, ignoring", zap.String("message-id", message.Metadata.MessageId))
return
}
}
defer c.eventCache.Add(message.Metadata.MessageId, message.Metadata.MessageTimestamp)
// Decode data
var notificationData NotificationMessagePayload
err := json.Unmarshal(message.Payload, &notificationData)
if err != nil {
c.logger.Error("Error decoding EventSub notification payload", zap.String("message-type", message.Metadata.MessageType), zap.Error(err))
}
notificationData.Date = time.Now()
err = c.db.PutJSON(EventSubEventKey, notificationData)
if err != nil {
c.logger.Error("Error storing event to database", zap.String("key", EventSubEventKey), zap.Error(err))
}
var archive []NotificationMessagePayload
err = c.db.GetJSON(EventSubHistoryKey, &archive)
if err != nil {
archive = []NotificationMessagePayload{}
}
archive = append(archive, notificationData)
if len(archive) > EventSubHistorySize {
archive = archive[len(archive)-EventSubHistorySize:]
}
err = c.db.PutJSON(EventSubHistoryKey, archive)
if err != nil {
c.logger.Error("Error storing event to database", zap.String("key", EventSubHistoryKey), zap.Error(err))
}
}
func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session string) error {
if c.savedSubscriptions[session] {
// Already subscribed
return nil
}
transport := helix.EventSubTransport{
Method: "websocket",
SessionID: session,
}
for topic, version := range subscriptionVersions {
sub, err := userClient.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: topic,
Version: version,
Status: "enabled",
Transport: transport,
Condition: topicCondition(topic, c.User.ID),
})
if sub.Error != "" || sub.ErrorMessage != "" {
c.logger.Error("EventSub Subscription error", zap.String("topic", topic), zap.String("topic-version", version), zap.String("err", sub.Error), zap.String("message", sub.ErrorMessage))
return fmt.Errorf("%s: %s", sub.Error, sub.ErrorMessage)
}
if err != nil {
return fmt.Errorf("error subscribing to %s: %w", topic, err)
}
}
c.savedSubscriptions[session] = true
return nil
}
func topicCondition(topic string, id string) helix.EventSubCondition {
switch topic {
case "channel.raid":
return helix.EventSubCondition{
ToBroadcasterUserID: id,
}
case "channel.follow":
return helix.EventSubCondition{
BroadcasterUserID: id,
ModeratorUserID: id,
}
default:
return helix.EventSubCondition{
BroadcasterUserID: id,
}
}
}
type EventSubWebsocketMessage struct {
Metadata EventSubMetadata `json:"metadata"`
Payload jsoniter.RawMessage `json:"payload"`
}
type WelcomeMessagePayload struct {
Session struct {
Id string `json:"id"`
Status string `json:"status"`
ConnectedAt time.Time `json:"connected_at"`
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
ReconnectUrl string `json:"reconnect_url,omitempty"`
} `json:"session"`
}
type NotificationMessagePayload struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Event jsoniter.RawMessage `json:"event"`
Date time.Time `json:"date,omitempty"`
}
type EventSubMetadata struct {
MessageId string `json:"message_id"`
MessageType string `json:"message_type"`
MessageTimestamp time.Time `json:"message_timestamp"`
SubscriptionType string `json:"subscription_type"`
SubscriptionVersion string `json:"subscription_version"`
}
var subscriptionVersions = map[string]string{
helix.EventSubTypeChannelUpdate: "1",
helix.EventSubTypeChannelFollow: "2",
helix.EventSubTypeChannelSubscription: "1",
helix.EventSubTypeChannelSubscriptionGift: "1",
helix.EventSubTypeChannelSubscriptionMessage: "1",
helix.EventSubTypeChannelCheer: "1",
helix.EventSubTypeChannelRaid: "1",
helix.EventSubTypeChannelPollBegin: "1",
helix.EventSubTypeChannelPollProgress: "1",
helix.EventSubTypeChannelPollEnd: "1",
helix.EventSubTypeChannelPredictionBegin: "1",
helix.EventSubTypeChannelPredictionProgress: "1",
helix.EventSubTypeChannelPredictionLock: "1",
helix.EventSubTypeChannelPredictionEnd: "1",
helix.EventSubTypeHypeTrainBegin: "1",
helix.EventSubTypeHypeTrainProgress: "1",
helix.EventSubTypeHypeTrainEnd: "1",
helix.EventSubTypeChannelPointsCustomRewardAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardUpdate: "1",
helix.EventSubTypeChannelPointsCustomRewardRemove: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionUpdate: "1",
helix.EventSubTypeStreamOnline: "1",
helix.EventSubTypeStreamOffline: "1",
}

View File

@ -1,313 +0,0 @@
package twitch
import (
"context"
"errors"
"fmt"
"time"
"git.sr.ht/~ashkeel/containers/sync"
lru "github.com/hashicorp/golang-lru/v2"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
var json = jsoniter.ConfigFastest
type Manager struct {
client *Client
cancelSubs func()
}
func NewManager(db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) {
// Get Twitch config
var config Config
if err := db.GetJSON(ConfigKey, &config); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get twitch config: %w", err)
}
config.Enabled = false
}
// Get Twitch bot config
botConfig := defaultBotConfig()
if err := db.GetJSON(BotConfigKey, &botConfig); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get bot config: %w", err)
}
config.EnableBot = false
}
// Create new client
client, err := newClient(config, db, server, logger)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
if config.EnableBot {
client.Bot = newBot(client, botConfig)
go client.Bot.Connect()
}
manager := &Manager{
client: client,
}
// Listen for client config changes
err, cancelConfigSub := db.SubscribeKey(ConfigKey, func(value string) {
var newConfig Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", zap.Error(err))
return
}
var updatedClient *Client
updatedClient, err = newClient(newConfig, db, server, logger)
if err != nil {
logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err))
return
}
err = manager.client.Close()
if err != nil {
logger.Warn("Twitch client could not close cleanly", zap.Error(err))
}
// New client works, replace old
updatedClient.Merge(manager.client)
manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration")
})
if err != nil {
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
}
// Listen for bot config changes
err, cancelBotSub := db.SubscribeKey(BotConfigKey, func(value string) {
newBotConfig := defaultBotConfig()
if err := json.UnmarshalFromString(value, &newBotConfig); err != nil {
logger.Error("Failed to decode bot config", zap.Error(err))
return
}
if manager.client.Bot != nil {
err = manager.client.Bot.Close()
if err != nil {
manager.client.logger.Warn("Failed to disconnect old bot from Twitch IRC", zap.Error(err))
}
}
if manager.client.Config.Get().EnableBot {
bot := newBot(manager.client, newBotConfig)
go bot.Connect()
manager.client.Bot = bot
} else {
manager.client.Bot = nil
}
manager.client.logger.Info("Reloaded/restarted Twitch bot")
})
if err != nil {
client.logger.Error("Could not setup twitch bot config reload subscription", zap.Error(err))
}
manager.cancelSubs = func() {
if cancelConfigSub != nil {
cancelConfigSub()
}
if cancelBotSub != nil {
cancelBotSub()
}
}
return manager, nil
}
func (m *Manager) Client() *Client {
return m.client
}
func (m *Manager) Close() error {
m.cancelSubs()
if err := m.client.Close(); err != nil {
return err
}
return nil
}
type Client struct {
Config *sync.RWSync[Config]
Bot *Bot
db *database.LocalDBClient
API *helix.Client
User helix.User
logger *zap.Logger
eventCache *lru.Cache[string, time.Time]
server *webserver.WebServer
ctx context.Context
cancel context.CancelFunc
restart chan bool
streamOnline *sync.RWSync[bool]
savedSubscriptions map[string]bool
}
func (c *Client) Merge(old *Client) {
// Copy bot instance and some params
c.streamOnline.Set(old.streamOnline.Get())
c.Bot = old.Bot
c.ensureRoute()
}
// Hacky function to deal with sync issues when restarting client
func (c *Client) ensureRoute() {
if c.Config.Get().Enabled {
c.server.RegisterRoute(CallbackRoute, c)
}
}
func newClient(config Config, db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Client, error) {
eventCache, err := lru.New[string, time.Time](128)
if err != nil {
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
}
// Create Twitch client
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
Config: sync.NewRWSync(config),
db: db,
logger: logger.With(zap.String("service", "twitch")),
restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false),
eventCache: eventCache,
savedSubscriptions: make(map[string]bool),
ctx: ctx,
cancel: cancel,
server: server,
}
baseurl, err := client.baseURL()
if err != nil {
return nil, err
}
if config.Enabled {
api, err := getHelixAPI(config, baseurl)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
client.API = api
server.RegisterRoute(CallbackRoute, client)
if userClient, err := client.GetUserClient(true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.logger.Error("Failed looking up user", zap.Error(err))
} else if len(users.Data.Users) < 1 {
client.logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.User = users.Data.Users[0]
go client.eventSubLoop(userClient)
}
} else {
client.logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll()
}
return client, nil
}
func (c *Client) runStatusPoll() {
c.logger.Info("Started polling for stream status")
for {
// Make sure we're configured and connected properly first
if !c.Config.Get().Enabled || c.Bot == nil || c.Bot.Config.Channel == "" {
continue
}
// Check if streamer is online, if possible
func() {
status, err := c.API.GetStreams(&helix.StreamsParams{
UserLogins: []string{c.Bot.Config.Channel}, // TODO Replace with something non bot dependant
})
if err != nil {
c.logger.Error("Error checking stream status", zap.Error(err))
return
} else {
c.streamOnline.Set(len(status.Data.Streams) > 0)
}
err = c.db.PutJSON(StreamInfoKey, status.Data.Streams)
if err != nil {
c.logger.Warn("Error saving stream info", zap.Error(err))
}
}()
// Wait for next poll (or cancellation)
select {
case <-c.ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}
func getHelixAPI(config Config, baseurl string) (*helix.Client, error) {
redirectURI := getRedirectURI(baseurl)
// Create Twitch client
api, err := helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
RedirectURI: redirectURI,
})
if err != nil {
return nil, err
}
// Get access token
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
return nil, err
}
// Set the access token on the client
api.SetAppAccessToken(resp.Data.AccessToken)
return api, nil
}
func (c *Client) baseURL() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.db.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func (c *Client) IsLive() bool {
return c.streamOnline.Get()
}
func (c *Client) Close() error {
c.server.UnregisterRoute(CallbackRoute)
defer c.cancel()
if c.Bot != nil {
if err := c.Bot.Close(); err != nil {
return err
}
}
return nil
}

82
twitch/client/auth.go Normal file
View File

@ -0,0 +1,82 @@
package client
import (
"fmt"
"net/http"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch"
"github.com/nicklaw5/helix/v2"
)
func (c *Client) GetLoggedUser() (helix.User, error) {
if c.User.ID != "" {
return c.User, nil
}
client, err := twitch.GetUserClient(c.DB, twitch.AuthKey, false)
if err != nil {
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
}
users, err := client.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
}
if len(users.Data.Users) < 1 {
return helix.User{}, fmt.Errorf("no users found")
}
c.User = users.Data.Users[0]
return c.User, nil
}
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Get code from params
code := req.URL.Query().Get("code")
if code == "" {
// TODO Nice error page
http.Error(w, "missing code", http.StatusBadRequest)
return
}
// Exchange code for access/refresh tokens
userTokenResponse, err := c.API.RequestUserAccessToken(code)
if err != nil {
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
return
}
// Check scope to see which credentials are we getting (stream/chat)
state := req.URL.Query().Get("state")
key := twitch.AuthKey
switch state {
case "chat":
key = chat.CustomAccountKey
}
err = c.DB.PutJSON(key, twitch.AuthResponse{
AccessToken: userTokenResponse.Data.AccessToken,
RefreshToken: userTokenResponse.Data.RefreshToken,
ExpiresIn: userTokenResponse.Data.ExpiresIn,
Scope: userTokenResponse.Data.Scopes,
Time: time.Now(),
})
if err != nil {
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
}
type RefreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope []string `json:"scope"`
}

203
twitch/client/client.go Normal file
View File

@ -0,0 +1,203 @@
package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/containers/sync"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
type Manager struct {
client *Client
}
func NewManager(ctx context.Context, db database.Database, server *webserver.WebServer) (*Manager, error) {
logger := log.GetLogger(ctx)
// Get Twitch config
var config twitch.Config
if err := db.GetJSON(twitch.ConfigKey, &config); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get twitch config: %w", err)
}
config.Enabled = false
}
// Create new client
clientContext, cancel := context.WithCancel(ctx)
client, err := newClient(clientContext, config, db, server)
if err != nil {
cancel()
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
manager := &Manager{
client: client,
}
// Listen for client config changes
if err = db.SubscribeKeyContext(ctx, twitch.ConfigKey, func(value string) {
var newConfig twitch.Config
if err := json.Unmarshal([]byte(value), &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", log.Error(err))
return
}
cancel()
var updatedClient *Client
clientContext, cancel = context.WithCancel(ctx)
updatedClient, err = newClient(clientContext, newConfig, db, server)
if err != nil {
logger.Error("Could not create twitch client with new config, keeping old", log.Error(err))
return
}
// New client works, replace old
manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration")
}); err != nil {
logger.Error("Could not setup twitch config reload subscription", log.Error(err))
}
return manager, nil
}
func (m *Manager) Client() *Client {
return m.client
}
type Client struct {
Config *sync.RWSync[twitch.Config]
DB database.Database
API *helix.Client
User helix.User
Logger *slog.Logger
Chat *chat.Module
Alerts *alerts.Module
Timers *timers.Module
eventSub *eventsub.Client
server *webserver.WebServer
ctx context.Context
restart chan bool
streamOnline *sync.RWSync[bool]
}
func newClient(ctx context.Context, config twitch.Config, db database.Database, server *webserver.WebServer) (*Client, error) {
// Create Twitch client
client := &Client{
Config: sync.NewRWSync(config),
DB: db,
Logger: slog.With(slog.String("service", "twitch")),
restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false),
ctx: ctx,
server: server,
}
if !config.Enabled {
return client, nil
}
var err error
client.API, err = twitch.GetHelixAPI(db, "")
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
server.RegisterRoute(twitch.CallbackRoute, client)
client.initializeEventSubUserFeatures()
go client.runStatusPoll()
go func() {
<-ctx.Done()
server.UnregisterRoute(twitch.CallbackRoute)
}()
return client, nil
}
func (c *Client) initializeEventSubUserFeatures() {
userClient, err := twitch.GetUserClient(c.DB, twitch.AuthKey, true)
if err != nil {
c.Logger.Warn("Twitch user not identified, this will break most features")
return
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
c.Logger.Error("Failed looking up user", log.Error(err))
return
}
if len(users.Data.Users) < 1 {
c.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
return
}
c.Logger.Info("Twitch user identified", slog.String("user", users.Data.Users[0].ID))
c.User = users.Data.Users[0]
c.eventSub, err = eventsub.Setup(c.ctx, userClient, c.User, c.DB, c.Logger)
if err != nil {
c.Logger.Error("Failed to setup EventSub", log.Error(err))
return
}
tpl := c.GetTemplateEngine()
c.Chat = chat.Setup(c.ctx, c.DB, userClient, c.User, c.Logger, tpl)
c.Alerts = alerts.Setup(c.ctx, c.DB, c.Logger, tpl)
c.Timers = timers.Setup(c.ctx, c.DB, c.Logger)
}
func (c *Client) runStatusPoll() {
c.Logger.Info("Started polling for stream status")
for {
// Make sure we're configured and connected properly first
if !c.Config.Get().Enabled {
continue
}
// Check if streamer is online, if possible
func() {
status, err := c.API.GetStreams(&helix.StreamsParams{
UserIDs: []string{c.User.ID},
})
if err != nil {
c.Logger.Error("Error checking stream status", log.Error(err))
return
}
c.streamOnline.Set(len(status.Data.Streams) > 0)
err = c.DB.PutJSON(twitch.StreamInfoKey, status.Data.Streams)
if err != nil {
c.Logger.Warn("Error saving stream info", log.Error(err))
}
}()
// Wait for next poll (or cancellation)
select {
case <-c.ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}

View File

@ -1,26 +1,26 @@
package twitch
package client
import (
"context"
"testing"
"go.uber.org/zap/zaptest"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
func TestNewClient(t *testing.T) {
logger := zaptest.NewLogger(t)
client, _ := database.CreateInMemoryLocalClient(t)
defer database.CleanupLocalClient(client)
server, err := webserver.NewServer(client, logger, webserver.DefaultServerFactory)
server, err := webserver.NewServer(context.Background(), client, webserver.DefaultServerFactory)
if err != nil {
t.Fatal(err)
}
config := Config{}
_, err = newClient(config, client, server, logger)
config := twitch.Config{}
_, err = newClient(context.Background(), config, client, server)
if err != nil {
t.Fatal(err)
}

68
twitch/client/template.go Normal file
View File

@ -0,0 +1,68 @@
package client
import (
"log/slog"
"math/rand"
"strconv"
"strings"
textTemplate "text/template"
"git.sr.ht/~ashkeel/strimertul/log"
"github.com/Masterminds/sprig/v3"
"git.sr.ht/~ashkeel/strimertul/twitch/template"
"github.com/nicklaw5/helix/v2"
)
const ChatCounterPrefix = "twitch/chat/counters/"
type templateEngineImpl struct {
customFunctions textTemplate.FuncMap
}
func (b *templateEngineImpl) MakeTemplate(message string) (*textTemplate.Template, error) {
return textTemplate.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
}
func (c *Client) GetTemplateEngine() template.Engine {
return &templateEngineImpl{
customFunctions: textTemplate.FuncMap{
"user": func(message helix.EventSubChannelChatMessageEvent) string {
return message.ChatterUserLogin
},
"param": func(num int, message helix.EventSubChannelChatMessageEvent) string {
parts := strings.Split(message.Message.Text, " ")
if num >= len(parts) {
return parts[len(parts)-1]
}
return parts[num]
},
"randomInt": func(min int, max int) int {
return rand.Intn(max-min) + min
},
"game": func(channel string) string {
channel = strings.TrimLeft(channel, "@")
info, err := c.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
if err != nil {
return "unknown"
}
return info.Data.Channels[0].GameName
},
"count": func(name string) int {
counterKey := ChatCounterPrefix + name
counter := 0
if byt, err := c.DB.GetKey(counterKey); err == nil {
counter, _ = strconv.Atoi(byt)
}
counter++
err := c.DB.PutKey(counterKey, strconv.Itoa(counter))
if err != nil {
c.Logger.Error("Error saving key", slog.String("key", counterKey), log.Error(err))
}
return counter
},
},
}
}

View File

@ -1,181 +0,0 @@
package twitch
import (
"bytes"
"github.com/Masterminds/sprig/v3"
"math/rand"
"strconv"
"strings"
"text/template"
"time"
irc "github.com/gempir/go-twitch-irc/v4"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
type AccessLevelType string
const (
ALTEveryone AccessLevelType = "everyone"
ALTSubscribers AccessLevelType = "subscriber"
ALTVIP AccessLevelType = "vip"
ALTModerators AccessLevelType = "moderators"
ALTStreamer AccessLevelType = "streamer"
)
var accessLevels = map[AccessLevelType]int{
ALTEveryone: 0,
ALTSubscribers: 1,
ALTVIP: 2,
ALTModerators: 3,
ALTStreamer: 999,
}
type BotCommandHandler func(bot *Bot, message irc.PrivateMessage)
type BotCommand struct {
Description string
Usage string
AccessLevel AccessLevelType
Handler BotCommandHandler
Enabled bool
}
func cmdCustom(bot *Bot, cmd string, data BotCustomCommand, message irc.PrivateMessage) {
// Check access level
accessLevel := getUserAccessLevel(message.User)
// Ensure that access level is high enough
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
return
}
var buf bytes.Buffer
tpl, ok := bot.customTemplates.GetKey(cmd)
if !ok {
return
}
if err := tpl.Execute(&buf, message); err != nil {
bot.logger.Error("Failed to execute custom command template", zap.Error(err))
return
}
switch data.ResponseType {
case ResponseTypeDefault, ResponseTypeChat:
bot.Client.Say(message.Channel, buf.String())
case ResponseTypeReply:
bot.Client.Reply(message.Channel, message.ID, buf.String())
case ResponseTypeWhisper:
client, err := bot.api.GetUserClient(false)
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: bot.api.User.ID,
ToUserID: message.User.ID,
Message: buf.String(),
})
if reply.Error != "" {
bot.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
bot.logger.Error("Failed to send whisper", zap.Error(err))
}
case ResponseTypeAnnounce:
client, err := bot.api.GetUserClient(false)
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: bot.api.User.ID,
ModeratorID: bot.api.User.ID,
Message: buf.String(),
})
if reply.Error != "" {
bot.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
bot.logger.Error("Failed to send announcement", zap.Error(err))
}
}
}
func (b *Bot) MakeTemplate(message string) (*template.Template, error) {
return template.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
}
var TestMessageData = irc.PrivateMessage{
User: irc.User{
ID: "603448316",
Name: "ashkeelvt",
DisplayName: "AshKeelVT",
Color: "#EC2B87",
Badges: map[string]int{
"subscriber": 0,
"moments": 1,
"broadcaster": 1,
},
},
Type: 1,
Tags: map[string]string{
"emotes": "",
"first-msg": "0",
"id": "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
"turbo": "0",
"user-id": "603448316",
"badges": "broadcaster/1,subscriber/0,moments/1",
"color": "#EC2B87",
"user-type": "",
"room-id": "603448316",
"tmi-sent-ts": "1684345559394",
"flags": "",
"mod": "0",
"returning-chatter": "0",
"badge-info": "subscriber/21",
"display-name": "AshKeelVT",
"subscriber": "1",
},
Message: "!test",
Channel: "ashkeelvt",
RoomID: "603448316",
ID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
Time: time.Now(),
Emotes: nil,
Bits: 0,
Action: false,
}
func (b *Bot) setupFunctions() {
b.customFunctions = template.FuncMap{
"user": func(message irc.PrivateMessage) string {
return message.User.DisplayName
},
"param": func(num int, message irc.PrivateMessage) string {
parts := strings.Split(message.Message, " ")
if num >= len(parts) {
return parts[len(parts)-1]
}
return parts[num]
},
"randomInt": func(min int, max int) int {
return rand.Intn(max-min) + min
},
"game": func(channel string) string {
channel = strings.TrimLeft(channel, "@")
info, err := b.api.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
if err != nil {
return "unknown"
}
return info.Data.Channels[0].GameName
},
"count": func(name string) int {
counterKey := BotCounterPrefix + name
counter := 0
if byt, err := b.api.db.GetKey(counterKey); err == nil {
counter, _ = strconv.Atoi(byt)
}
counter += 1
err := b.api.db.PutKey(counterKey, strconv.Itoa(counter))
if err != nil {
b.logger.Error("Error saving key", zap.Error(err), zap.String("key", counterKey))
}
return counter
},
}
}

15
twitch/config.go Normal file
View File

@ -0,0 +1,15 @@
package twitch
const ConfigKey = "twitch/config"
// Config is the general configuration for the Twitch subsystem
type Config struct {
// Enable subsystem
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`
// Twitch API App Client Secret
APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"`
}

View File

@ -1,104 +1,39 @@
package twitch
import (
"github.com/nicklaw5/helix/v2"
)
const CallbackRoute = "/twitch/callback"
const ConfigKey = "twitch/config"
// Config is the general configuration for the Twitch subsystem
type Config struct {
// Enable subsystem
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Enable the chatbot
EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`
// Twitch API App Client Secret
APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"`
}
const StreamInfoKey = "twitch/stream-info"
const BotConfigKey = "twitch/bot-config"
// BotConfig is the general configuration for the Twitch chatbot
type BotConfig struct {
// Chatbot username (for internal use, ignored by Twitch)
Username string `json:"username" desc:"Chatbot username (for internal use, ignored by Twitch)"`
// OAuth key for IRC authentication
Token string `json:"oauth" desc:"OAuth key for IRC authentication"`
// Twitch channel to join and use
Channel string `json:"channel" desc:"Twitch channel to join and use"`
// How many messages to keep in twitch/chat-history
ChatHistory int `json:"chat_history" desc:"How many messages to keep in twitch/chat-history"`
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}
const (
ChatEventKey = "twitch/ev/chat-message"
ChatHistoryKey = "twitch/chat-history"
ChatActivityKey = "twitch/chat-activity"
)
type ResponseType string
const (
ResponseTypeDefault ResponseType = ""
ResponseTypeChat ResponseType = "chat"
ResponseTypeWhisper ResponseType = "whisper"
ResponseTypeReply ResponseType = "reply"
ResponseTypeAnnounce ResponseType = "announce"
)
// BotCustomCommand is a definition of a custom command of the chatbot
type BotCustomCommand struct {
// Command description
Description string `json:"description" desc:"Command description"`
// Minimum access level needed to use the command
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
// Response template (in Go templating format)
Response string `json:"response" desc:"Response template (in Go templating format)"`
// Is the command enabled?
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
// How to respond to the user
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
}
const CustomCommandsKey = "twitch/bot-custom-commands"
const (
// WritePlainMessageRPC is the old send command, will be renamed someday
WritePlainMessageRPC = "twitch/@send-chat-message"
WriteMessageRPC = "twitch/bot/@send-message"
)
// WriteMessageRequest is an RPC to send a chat message with extra options
type WriteMessageRequest struct {
Message string `json:"message" desc:"Chat message to send"`
ReplyTo *string `json:"reply_to" desc:"If specified, send as reply to a message ID"`
WhisperTo *string `json:"whisper_to" desc:"If specified, send as whisper to user ID"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
}
const BotCounterPrefix = "twitch/bot-counters/"
const AuthKey = "twitch/auth-keys"
const (
EventSubEventKey = "twitch/ev/eventsub-event"
EventSubHistoryKey = "twitch/eventsub-history"
)
const EventSubHistorySize = 100
var TestMessageData = helix.EventSubChannelChatMessageEvent{
ChatterUserLogin: "ashkeelvt",
ChatterUserID: "603448316",
ChatterUserName: "AshKeelVT",
Color: "#EC2B87",
Badges: []helix.EventSubChatBadge{
{
SetID: "broadcaster",
ID: "1",
},
{
SetID: "subscriber",
ID: "21",
},
},
MessageID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
Message: helix.EventSubChatMessage{
Text: "!test param1 param2 param3 param4",
Fragments: []helix.EventSubChatMessageFragment{
{
Text: "!test param1 param2 param3 param4",
Type: "text",
},
},
},
MessageType: "chat",
}

View File

@ -1,96 +0,0 @@
package twitch
import (
"reflect"
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
irc "github.com/gempir/go-twitch-irc/v4"
"github.com/nicklaw5/helix/v2"
)
// Documentation stuff, keep updated at all times
var Keys = interfaces.KeyMap{
ConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch subsystem",
Type: reflect.TypeOf(Config{}),
},
StreamInfoKey: interfaces.KeyDef{
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
Type: reflect.TypeOf([]helix.Stream{}),
},
BotConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch chatbot",
Type: reflect.TypeOf(BotConfig{}),
},
ChatEventKey: interfaces.KeyDef{
Description: "On chat message received",
Type: reflect.TypeOf(irc.PrivateMessage{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
ChatHistoryKey: interfaces.KeyDef{
Description: "Last chat messages received",
Type: reflect.TypeOf([]irc.PrivateMessage{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
ChatActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),
},
CustomCommandsKey: interfaces.KeyDef{
Description: "Chatbot custom commands",
Type: reflect.TypeOf(map[string]BotCustomCommand{}),
},
AuthKey: interfaces.KeyDef{
Description: "User access token for the twitch subsystem",
Type: reflect.TypeOf(AuthResponse{}),
},
EventSubEventKey: interfaces.KeyDef{
Description: "On Eventsub event received",
Type: reflect.TypeOf(NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
EventSubHistoryKey: interfaces.KeyDef{
Description: "Last eventsub notifications received",
Type: reflect.TypeOf([]NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
BotAlertsKey: interfaces.KeyDef{
Description: "Configuration of chat bot alerts",
Type: reflect.TypeOf(BotAlertsConfig{}),
},
BotTimersKey: interfaces.KeyDef{
Description: "Configuration of chat bot timers",
Type: reflect.TypeOf(BotTimersConfig{}),
},
WritePlainMessageRPC: interfaces.KeyDef{
Description: "Send plain text chat message (this will be deprecated or renamed someday, please use the other one!)",
Type: reflect.TypeOf(""),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
WriteMessageRPC: interfaces.KeyDef{
Description: "Send chat message with extra options (as reply, whisper, etc)",
Type: reflect.TypeOf(WriteMessageRequest{}),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
}
var Enums = interfaces.EnumMap{
"AccessLevelType": interfaces.Enum{
Values: []any{
ALTEveryone,
ALTSubscribers,
ALTVIP,
ALTModerators,
ALTStreamer,
},
},
"ResponseType": interfaces.Enum{
Values: []any{
ResponseTypeChat,
ResponseTypeReply,
ResponseTypeWhisper,
ResponseTypeAnnounce,
},
},
}

90
twitch/doc/doc.go Normal file
View File

@ -0,0 +1,90 @@
package doc
import (
"reflect"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
)
// Documentation stuff, keep updated at all times
var Keys = interfaces.KeyMap{
twitch.ConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch subsystem",
Type: reflect.TypeOf(twitch.Config{}),
},
twitch.StreamInfoKey: interfaces.KeyDef{
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
Type: reflect.TypeOf([]helix.Stream{}),
},
twitch.AuthKey: interfaces.KeyDef{
Description: "User access token for the twitch subsystem",
Type: reflect.TypeOf(twitch.AuthResponse{}),
},
chat.ConfigKey: interfaces.KeyDef{
Description: "Configuration for chat-related features",
Type: reflect.TypeOf(chat.Config{}),
},
chat.ActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),
},
chat.CustomCommandsKey: interfaces.KeyDef{
Description: "Chat custom commands",
Type: reflect.TypeOf(map[string]chat.CustomCommand{}),
},
chat.CustomAccountKey: interfaces.KeyDef{
Description: "User access token for the chat account (if not using the main one)",
Type: reflect.TypeOf(twitch.AuthResponse{}),
},
chat.WriteMessageRPC: interfaces.KeyDef{
Description: "Send chat message with extra options (as reply, whisper, etc)",
Type: reflect.TypeOf(chat.WriteMessageRequest{}),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
eventsub.EventKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "On Eventsub event [event-name] received",
Type: reflect.TypeOf(eventsub.NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
eventsub.HistoryKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "Last eventsub notifications received for [event-name]",
Type: reflect.TypeOf([]eventsub.NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
alerts.ConfigKey: interfaces.KeyDef{
Description: "Configuration of chat alerts",
Type: reflect.TypeOf(alerts.Config{}),
},
timers.ConfigKey: interfaces.KeyDef{
Description: "Configuration of chat timers",
Type: reflect.TypeOf(timers.Config{}),
},
}
var Enums = interfaces.EnumMap{
"AccessLevelType": interfaces.Enum{
Values: []any{
chat.ALTEveryone,
chat.ALTSubscribers,
chat.ALTVIP,
chat.ALTModerators,
chat.ALTStreamer,
},
},
"ResponseType": interfaces.Enum{
Values: []any{
chat.ResponseTypeChat,
chat.ResponseTypeReply,
chat.ResponseTypeWhisper,
chat.ResponseTypeAnnounce,
},
},
}

362
twitch/eventsub/client.go Normal file
View File

@ -0,0 +1,362 @@
package eventsub
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
"github.com/gorilla/websocket"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/nicklaw5/helix/v2"
)
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
type Client struct {
ctx context.Context
twitchAPI *helix.Client
db database.Database
logger *slog.Logger
user helix.User
eventCache *lru.Cache[string, time.Time]
savedSubscriptions map[string]bool
}
func Setup(ctx context.Context, twitchAPI *helix.Client, user helix.User, db database.Database, logger *slog.Logger) (*Client, error) {
eventCache, err := lru.New[string, time.Time](128)
if err != nil {
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
}
client := &Client{
ctx: ctx,
twitchAPI: twitchAPI,
db: db,
logger: logger,
user: user,
eventCache: eventCache,
savedSubscriptions: make(map[string]bool),
}
go client.eventSubLoop()
return client, nil
}
func (c *Client) eventSubLoop() {
// Cleanup subscriptions for dead sessions so we don't get hit by the sub limit
go c.cleanupSubscriptions()
endpoint := websocketEndpoint
var err error
var connection *websocket.Conn
for endpoint != "" {
endpoint, connection, err = c.connectWebsocket(endpoint, connection)
if err != nil {
c.logger.Error("EventSub websocket read error", log.Error(err))
}
}
if connection != nil {
utils.Close(connection)
}
}
func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error) {
for {
messageType, messageData, err := connection.ReadMessage()
if err != nil {
wsErr <- err
close(recv)
close(wsErr)
return
}
if messageType != websocket.TextMessage {
continue
}
recv <- messageData
}
}
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn) (string, *websocket.Conn, error) {
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
c.logger.Error("Could not establish a connection to the EventSub websocket", log.Error(err))
return "", nil, err
}
received := make(chan []byte, 10)
wsErr := make(chan error, 1)
go readLoop(connection, received, wsErr)
for {
// Wait for next message or closing/error
var messageData []byte
select {
case <-c.ctx.Done():
return "", nil, nil
case err = <-wsErr:
return url, nil, err // Return the endpoint so we can reconnect
case messageData = <-received:
}
var wsMessage WebsocketMessage
err = json.Unmarshal(messageData, &wsMessage)
if err != nil {
c.logger.Error("Error decoding EventSub message", log.Error(err))
continue
}
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection)
if done {
return reconnectURL, connection, err
}
}
}
func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *websocket.Conn) (string, bool, error) {
switch wsMessage.Metadata.MessageType {
case "session_keepalive":
// Nothing to do
case "session_welcome":
var welcomeData WelcomeMessagePayload
err := json.Unmarshal(wsMessage.Payload, &welcomeData)
if err != nil {
c.logger.Error("Error decoding EventSub welcome message", slog.String("message-type", wsMessage.Metadata.MessageType), log.Error(err))
break
}
c.logger.Info("Connection to EventSub websocket established", slog.String("session-id", welcomeData.Session.ID))
// We can only close the old connection once the new one has been established
if oldConnection != nil {
utils.Close(oldConnection)
}
// Add subscription to websocket session
err = c.addSubscriptionsForSession(welcomeData.Session.ID)
if err != nil {
c.logger.Error("Could not add subscriptions", log.Error(err))
break
}
case "session_reconnect":
var reconnectData WelcomeMessagePayload
err := json.Unmarshal(wsMessage.Payload, &reconnectData)
if err != nil {
c.logger.Error("Error decoding EventSub session reconnect parameters", slog.String("message-type", wsMessage.Metadata.MessageType), log.Error(err))
break
}
c.logger.Info("EventSub websocket requested a reconnection", slog.String("session-id", reconnectData.Session.ID), slog.String("reconnect-url", reconnectData.Session.ReconnectURL))
return reconnectData.Session.ReconnectURL, true, nil
case "notification":
go c.processEvent(wsMessage)
case "revocation":
// TODO idk what to do here
}
return "", false, nil
}
func (c *Client) processEvent(message WebsocketMessage) {
// Check if we processed this already
if message.Metadata.MessageID != "" {
if c.eventCache.Contains(message.Metadata.MessageID) {
c.logger.Debug("Received duplicate event, ignoring", slog.String("message-id", message.Metadata.MessageID))
return
}
}
defer c.eventCache.Add(message.Metadata.MessageID, message.Metadata.MessageTimestamp)
// Decode data
var notificationData NotificationMessagePayload
err := json.Unmarshal(message.Payload, &notificationData)
if err != nil {
c.logger.Error("Error decoding EventSub notification payload", slog.String("message-type", message.Metadata.MessageType), log.Error(err))
}
notificationData.Date = time.Now()
eventKey := fmt.Sprintf("%s%s", EventKeyPrefix, notificationData.Subscription.Type)
historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, notificationData.Subscription.Type)
err = c.db.PutJSON(eventKey, notificationData)
if err != nil {
c.logger.Error("Error storing event to database", slog.String("key", eventKey), log.Error(err))
}
var archive []NotificationMessagePayload
err = c.db.GetJSON(historyKey, &archive)
if err != nil {
archive = []NotificationMessagePayload{}
}
archive = append(archive, notificationData)
if len(archive) > HistorySize {
archive = archive[len(archive)-HistorySize:]
}
err = c.db.PutJSON(historyKey, archive)
if err != nil {
c.logger.Error("Error storing event to database", slog.String("key", historyKey), log.Error(err))
}
}
func (c *Client) addSubscriptionsForSession(session string) error {
if c.savedSubscriptions[session] {
// Already subscribed
return nil
}
transport := helix.EventSubTransport{
Method: "websocket",
SessionID: session,
}
for topic, version := range subscriptionVersions {
sub, err := c.twitchAPI.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: topic,
Version: version,
Status: "enabled",
Transport: transport,
Condition: topicCondition(topic, c.user.ID),
})
if sub.Error != "" || sub.ErrorMessage != "" {
c.logger.Error("EventSub Subscription error", slog.String("topic", topic), slog.String("topic-version", version), slog.String("err", sub.Error), slog.String("message", sub.ErrorMessage))
return fmt.Errorf("%s: %s", sub.Error, sub.ErrorMessage)
}
if err != nil {
return fmt.Errorf("error subscribing to %s: %w", topic, err)
}
}
c.savedSubscriptions[session] = true
return nil
}
func (c *Client) cleanupSubscriptions() {
// Clear all subscriptions for dead/broken sessions
c.cleanupSubscriptionForStatus("websocket_disconnected")
c.cleanupSubscriptionForStatus("websocket_failed_ping_pong")
c.cleanupSubscriptionForStatus("websocket_received_inbound_traffic")
c.cleanupSubscriptionForStatus("websocket_connection_unused")
c.cleanupSubscriptionForStatus("websocket_internal_error")
c.cleanupSubscriptionForStatus("websocket_network_timeout")
c.cleanupSubscriptionForStatus("websocket_network_error")
c.cleanupSubscriptionForStatus("websocket_failed_to_reconnect")
}
func (c *Client) cleanupSubscriptionForStatus(status string) {
var cursor string
for {
subscriptions, err := c.twitchAPI.GetEventSubSubscriptions(&helix.EventSubSubscriptionsParams{
Status: status,
After: cursor,
})
if err != nil {
c.logger.Warn("Could not get subscriptions for status", slog.String("status", status), log.Error(err))
}
// Unsubscribe from all subscriptions for dead sessions
for _, sub := range subscriptions.Data.EventSubSubscriptions {
res, err := c.twitchAPI.RemoveEventSubSubscription(sub.ID)
if err != nil {
c.logger.Warn("Could not unsubscribe from dead session", slog.String("session-id", sub.Transport.SessionID), slog.String("subscription-id", sub.ID), log.Error(err))
}
if res.ErrorMessage != "" {
c.logger.Warn("Could not unsubscribe from dead session", slog.String("session-id", sub.Transport.SessionID), slog.String("subscription-id", sub.ID), slog.String("message", res.ErrorMessage), slog.Int("messageCode", res.ErrorStatus))
}
}
// Check for cursor
if subscriptions.Data.Pagination.Cursor == "" {
break
}
cursor = subscriptions.Data.Pagination.Cursor
}
}
func topicCondition(topic string, id string) helix.EventSubCondition {
switch topic {
case helix.EventSubTypeChannelRaid:
return helix.EventSubCondition{
ToBroadcasterUserID: id,
}
case helix.EventSubTypeChannelFollow:
return helix.EventSubCondition{
BroadcasterUserID: id,
ModeratorUserID: id,
}
case
helix.EventSubTypeChannelChatMessage,
helix.EventSubTypeChannelChatNotification:
{
return helix.EventSubCondition{
BroadcasterUserID: id,
UserID: id,
}
}
default:
return helix.EventSubCondition{
BroadcasterUserID: id,
}
}
}
type WebsocketMessage struct {
Metadata Metadata `json:"metadata"`
Payload json.RawMessage `json:"payload"`
}
type WelcomeMessagePayload struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
ConnectedAt time.Time `json:"connected_at"`
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
ReconnectURL string `json:"reconnect_url,omitempty"`
} `json:"session"`
}
type NotificationMessagePayload struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Event json.RawMessage `json:"event"`
Date time.Time `json:"date,omitempty"`
}
type Metadata struct {
MessageID string `json:"message_id"`
MessageType string `json:"message_type"`
MessageTimestamp time.Time `json:"message_timestamp"`
SubscriptionType string `json:"subscription_type"`
SubscriptionVersion string `json:"subscription_version"`
}
var subscriptionVersions = map[string]string{
helix.EventSubTypeChannelUpdate: "1",
helix.EventSubTypeChannelFollow: "2",
helix.EventSubTypeChannelSubscription: "1",
helix.EventSubTypeChannelSubscriptionGift: "1",
helix.EventSubTypeChannelSubscriptionMessage: "1",
helix.EventSubTypeChannelCheer: "1",
helix.EventSubTypeChannelRaid: "1",
helix.EventSubTypeChannelChatMessage: "1",
helix.EventSubTypeChannelChatNotification: "1",
helix.EventSubTypeChannelPollBegin: "1",
helix.EventSubTypeChannelPollProgress: "1",
helix.EventSubTypeChannelPollEnd: "1",
helix.EventSubTypeChannelPredictionBegin: "1",
helix.EventSubTypeChannelPredictionProgress: "1",
helix.EventSubTypeChannelPredictionLock: "1",
helix.EventSubTypeChannelPredictionEnd: "1",
helix.EventSubTypeHypeTrainBegin: "1",
helix.EventSubTypeHypeTrainProgress: "1",
helix.EventSubTypeHypeTrainEnd: "1",
helix.EventSubTypeChannelPointsCustomRewardAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardUpdate: "1",
helix.EventSubTypeChannelPointsCustomRewardRemove: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionUpdate: "1",
helix.EventSubTypeStreamOnline: "1",
helix.EventSubTypeStreamOffline: "1",
}

8
twitch/eventsub/data.go Normal file
View File

@ -0,0 +1,8 @@
package eventsub
const (
EventKeyPrefix = "twitch/ev/eventsub-event/"
HistoryKeyPrefix = "twitch/eventsub-history/"
)
const HistorySize = 100

61
twitch/scopes.go Normal file
View File

@ -0,0 +1,61 @@
package twitch
import (
"slices"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/database"
)
var scopes = []string{
"bits:read",
"channel:bot",
"channel:moderate",
"channel:read:hype_train",
"channel:read:polls",
"channel:read:predictions",
"channel:read:redemptions",
"channel:read:subscriptions",
"chat:edit",
"chat:read",
"moderator:manage:announcements",
"moderator:read:chatters",
"moderator:read:followers",
"user:bot",
"user:manage:whispers",
"user:read:chat",
"user:write:chat",
"user_read",
"whispers:edit",
"whispers:read",
}
// CheckScopes checks if the user has authorized all required scopes
// Normally this would be the case but between versions strimertul has changed
// the required scopes, and it's possible that some users have not re-authorized
// the application with the new scopes.
func CheckScopes(db database.Database) (bool, error) {
var authResp AuthResponse
if err := db.GetJSON(AuthKey, &authResp); err != nil {
return false, err
}
// Sort scopes for comparison
slices.Sort(authResp.Scope)
slices.Sort(scopes)
return slices.Equal(scopes, authResp.Scope), nil
}
func GetAuthorizationURL(api *helix.Client, state string) string {
if api == nil {
return "twitch-not-configured"
}
return api.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code",
Scopes: scopes,
State: state,
ForceVerify: true,
})
}

View File

@ -0,0 +1,9 @@
package template
import (
textTemplate "text/template"
)
type Engine interface {
MakeTemplate(message string) (*textTemplate.Template, error)
}

27
twitch/timers/config.go Normal file
View File

@ -0,0 +1,27 @@
package timers
const ConfigKey = "twitch/timers/config"
type Config struct {
Timers map[string]ChatTimer `json:"timers" desc:"List of timers as a dictionary"`
}
type ChatTimer struct {
// Whether the timer is enabled
Enabled bool `json:"enabled" desc:"Enable the timer"`
// Timer name (must be unique)
Name string `json:"name" desc:"Timer name (must be unique)"`
// Minimum chat messages in the last 5 minutes for timer to trigger
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
// Minimum amount of time (in seconds) that needs to pass before it triggers again
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
// Messages to write (randomly chosen)
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
// Announce the message to the chat
Announce bool `json:"announce" desc:"If true, send as announcement"`
}

158
twitch/timers/module.go Normal file
View File

@ -0,0 +1,158 @@
package timers
import (
"context"
"encoding/json"
"log/slog"
"math/rand"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/containers/sync"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
const AverageMessageWindow = 5
type Module struct {
Config Config
lastTrigger *sync.Map[string, time.Time]
messages *sync.Slice[int]
logger *slog.Logger
db database.Database
ctx context.Context
}
func Setup(ctx context.Context, db database.Database, logger *slog.Logger) *Module {
mod := &Module{
lastTrigger: sync.NewMap[string, time.Time](),
messages: sync.NewSlice[int](),
db: db,
ctx: ctx,
logger: logger,
}
// Fill messages with zero values
// (This can probably be done faster)
for i := 0; i < AverageMessageWindow; i++ {
mod.messages.Push(0)
}
// Load config from database
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
logger.Debug("Config load error", log.Error(err))
mod.Config = Config{
Timers: make(map[string]ChatTimer),
}
// Save empty config
err = db.PutJSON(ConfigKey, mod.Config)
if err != nil {
logger.Warn("Could not save default config for bot timers", log.Error(err))
}
}
if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) {
if err := json.Unmarshal([]byte(value), &mod.Config); err != nil {
logger.Warn("Error reloading timer config", log.Error(err))
return
}
logger.Info("Reloaded timer config")
}); err != nil {
logger.Error("Could not set-up timer reload subscription", log.Error(err))
}
logger.Debug("Loaded timers", slog.Int("timers", len(mod.Config.Timers)))
// Start goroutine for clearing message counters and running timers
go mod.runTimers()
return mod
}
func (m *Module) runTimers() {
for {
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
currentTime := time.Now()
nextTick := currentTime.Round(time.Minute).Add(time.Minute)
timeUntilNextTick := nextTick.Sub(currentTime)
time.Sleep(timeUntilNextTick)
err := m.db.PutJSON(chat.ActivityKey, m.messages.Get())
if err != nil {
m.logger.Warn("Error saving chat activity", log.Error(err))
}
// Calculate activity
activity := m.currentChatActivity()
// Reset timer
index := time.Now().Minute() % AverageMessageWindow
messages := m.messages.Get()
messages[index] = 0
m.messages.Set(messages)
// Run timers
for name, timer := range m.Config.Timers {
m.ProcessTimer(name, timer, activity)
}
}
}
func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
// Must be enabled
if !timer.Enabled {
return
}
// Check if enough time has passed
lastTriggeredTime, ok := m.lastTrigger.GetKey(name)
if !ok {
// If it's the first time we're checking it, start the cooldown
lastTriggeredTime = time.Now()
m.lastTrigger.SetKey(name, lastTriggeredTime)
}
minDelay := timer.MinimumDelay
if minDelay < 60 {
minDelay = 60
}
now := time.Now()
if now.Sub(lastTriggeredTime) < time.Duration(minDelay)*time.Second {
return
}
// Make sure chat activity is high enough
if activity < timer.MinimumChatActivity {
return
}
// Pick a random message
message := timer.Messages[rand.Intn(len(timer.Messages))]
// Write message to chat
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: message,
Announce: timer.Announce,
})
// Update last trigger
m.lastTrigger.SetKey(name, now)
}
func (m *Module) currentChatActivity() int {
total := 0
for _, v := range m.messages.Get() {
total += v
}
return total
}
func (m *Module) OnMessage() {
index := time.Now().Minute() % AverageMessageWindow
m.messages.SetIndex(index, 1)
}

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