mirror of https://git.sr.ht/~ashkeel/strimertul
Compare commits
45 Commits
d24ab21d3d
...
5bdc05ced8
Author | SHA1 | Date |
---|---|---|
Ash Keel | 5bdc05ced8 | |
Ash Keel | 06f7e9be98 | |
Ash Keel | bba0bce1ec | |
Ash Keel | 2b7f332d88 | |
Ash Keel | ad0e93b4c0 | |
Ash Keel | 9642ef52e6 | |
Ash Keel | 02099a34b0 | |
Ash Keel | 7db0f43e90 | |
Ash Keel | 40b51ed69e | |
Ash Keel | 732eda6f1f | |
Ash Keel | e5489ca54c | |
Ash Keel | 2debea3f11 | |
Ash Keel | d13960a00e | |
Ash Keel | 455fddb031 | |
Ash Keel | 4eee1ccc12 | |
Ash Keel | d3496d5d39 | |
Ash Keel | 4c5bf8fd78 | |
Ash Keel | dac126e891 | |
Ash Keel | fe02999663 | |
Ash Keel | 7f6e14cd48 | |
Ash Keel | c186c0b942 | |
Ash Keel | 422c70c9d4 | |
Ash Keel | 2cec7b1ffe | |
Ash Keel | 9844c5480f | |
Ash Keel | 2c2b98e58e | |
Ash Keel | 3d0e824b4b | |
Ash Keel | 07e3a00990 | |
Ash Keel | decccb9fed | |
Ash Keel | 019e558b22 | |
Ash Keel | bc83a743f3 | |
Ash Keel | ce2ce81768 | |
Ash Keel | edcc4fb7f9 | |
Ash Keel | 97a81373ab | |
Ash Keel | f4930d7758 | |
Ash Keel | a06b9457ea | |
Ash Keel | 31d44b950e | |
Ash Keel | 0d1c60451b | |
Ash Keel | bcdecf50c0 | |
Ash Keel | f35f3f0458 | |
Ash Keel | 3c3ea7bdb4 | |
Ash Keel | b5f5c2975c | |
Ash Keel | 82b7d51df7 | |
Ash Keel | fbb943f307 | |
Ash Keel | e34974aaa3 | |
Ash Keel | ab7b8d48f9 |
|
@ -0,0 +1,11 @@
|
|||
*.exe
|
||||
*.db
|
||||
*.db.lock
|
||||
/data/
|
||||
/backups/
|
||||
.vscode
|
||||
.idea
|
||||
strimertul_*
|
||||
/build/bin/
|
||||
*.log
|
||||
api.json
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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 ./...
|
|
@ -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"
|
|
@ -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 }}"
|
|
@ -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 ./...
|
|
@ -0,0 +1,12 @@
|
|||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- revive
|
||||
- errorlint
|
||||
- errname
|
||||
- contextcheck
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
161
app.go
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
31
backup.go
31
backup.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
fc1fa70c3a8ac4cc66b38592b8aaab6b
|
||||
894313fcc17ff294db3c3171003cb162
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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());
|
||||
},
|
||||
|
|
|
@ -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 />} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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 },
|
||||
}),
|
||||
|
|
|
@ -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(
|
|
@ -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(
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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)',
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
89
go.mod
|
@ -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
572
go.sum
|
@ -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=
|
||||
|
|
|
@ -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()),
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
112
logging.go
112
logging.go
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
72
main.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
CD /D %~dp0
|
||||
C:\projects\strimertul\strimertul\build\bin\strimertul.exe
|
|
@ -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"`
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
386
twitch/bot.go
386
twitch/bot.go
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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, ¬ificationData)
|
||||
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",
|
||||
}
|
313
twitch/client.go
313
twitch/client.go
|
@ -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
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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):
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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"`
|
||||
}
|
127
twitch/data.go
127
twitch/data.go
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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, ¬ificationData)
|
||||
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",
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package eventsub
|
||||
|
||||
const (
|
||||
EventKeyPrefix = "twitch/ev/eventsub-event/"
|
||||
HistoryKeyPrefix = "twitch/eventsub-history/"
|
||||
)
|
||||
|
||||
const HistorySize = 100
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
textTemplate "text/template"
|
||||
)
|
||||
|
||||
type Engine interface {
|
||||
MakeTemplate(message string) (*textTemplate.Template, error)
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue