Compare commits

...

24 commits

Author SHA1 Message Date
932879c8df
prepare release v3.29.2 2024-04-13 14:19:20 +02:00
97dbc74ebc
CI: Fix missing permissions in workflow
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-13 14:13:36 +02:00
2c9d8ef33c
prepare release v3.29.1 2024-04-13 14:01:53 +02:00
de3a4941ed
CI: Fix bugs in new CI flow
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-13 13:55:54 +02:00
8dd3d7db0c
prepare release v3.29.0 2024-04-13 13:17:00 +02:00
2c17ef58fa
[counter] Revise template parsing logic
in order to support both `counter_set` and `counter_step` set to
conditional templates and therefore i.e. combine deathcounter increase
/ decrease / set and query into one rule

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-08 19:47:15 +02:00
8154a50351
[core] Enforce field validation on config
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-08 17:40:24 +02:00
8c2c4e7c62
[docs] Add field-type annotations to events
closes #57

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
7737d939f4
[eventsub] Add support for suspicious user events
- User status is updated
- User sent a message while having sus-user status

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
acf96c31ad
[core] Fix: Do not cache nil-TwitchClient
as client is assigned after plugins are initialized

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
afe2963d33
[core] Fix: Don't initialize twitch client before start checks
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
293a7d9e30
[counter] Allow counterTopList to specify how to sort
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
94b040ed81
[counter] Record first seen and last updated on counters
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
b131a7be5f
[clip] Fix: Optional fields were required
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
6c941fb330
CI: Concatenate workflow into one workflow
from one file per job through script

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
264eef4130
[core] Fix: Do not retry core-kv query when it's not set
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
35b47bca65
[linkcheck] Remove flaky test
caused by domaingrabber grabbing some random domain

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
e7e9877c05
[core] Remove deprecated fallback token / token migration
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
a49a1844ba
[docs] Fix typo
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
dc8f645f24
[spotify] Switch to PKCE flow, remove need for clientSecret
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
a9984b2df2
[spotify] Improve error handling / documentation
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
30482591a7
[core] Switch to go_helpers FieldCollection
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-07 13:37:13 +02:00
262742603c
[eventsub] Fix: Fetching existing subscriptions broken
as of fetch must not specify more than one filter

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-03 13:14:44 +02:00
f76cbebda3
CI: Build tags to develop, drop stable tag
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-04-02 16:29:35 +02:00
102 changed files with 1926 additions and 1753 deletions

View file

@ -1,57 +0,0 @@
---
name: doc-generator
on: push
jobs:
doc-generator:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
permissions:
contents: read
pages: write
id-token: write
runs-on: ubuntu-latest
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
curl \
git \
git-lfs \
make \
tar
- uses: actions/checkout@v3
with:
lfs: true
submodules: true
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Generate documentation
run: make render_docs DOCS_BASE_URL=https://luzifer.github.io/twitch-bot/
- name: Upload GitHub Pages artifact
if: github.ref == 'refs/heads/master'
uses: actions/upload-pages-artifact@v1
with:
path: .rendered-docs
- name: Deploy artifact
if: github.ref == 'refs/heads/master'
uses: actions/deploy-pages@v1
...

View file

@ -1,36 +0,0 @@
---
name: docker-publish
on:
push:
branches: ['master']
tags: ['v*']
permissions:
packages: write
jobs:
docker-publish:
defaults:
run:
shell: bash
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
show-progress: false
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Build & Publish
run: bash ci/docker-publish.sh
...

274
.github/workflows/generated_workflow.yml vendored Normal file
View file

@ -0,0 +1,274 @@
name: CI Workflow
on: push
permissions:
contents: read
jobs:
doc-generator:
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
permissions:
contents: read
pages: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
show-progress: false
submodules: true
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Generate documentation
run: make render_docs DOCS_BASE_URL=https://luzifer.github.io/twitch-bot/
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v1
with:
path: .rendered-docs
- name: Deploy artifact
uses: actions/deploy-pages@v1
docker-publish:
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master' }}
needs:
- test
defaults:
run:
shell: bash
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Build & Publish
run: bash ci/docker-publish.sh
integration-crdb:
if: ${{ github.ref == 'refs/heads/master' }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
crdb:
image: luzifer/crdb-gh-service
options: --health-cmd "curl -sSf http://localhost:8080/health" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
cockroachdb-bin
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up CRDB service
run: |
cockroach sql --host crdb --insecure <<EOF
CREATE DATABASE integration;
CREATE USER "twitch_bot" WITH PASSWORD NULL;
ALTER DATABASE integration OWNER to "twitch_bot";
EOF
- name: Run tests against CRDB
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=crdb user=twitch_bot dbname=integration port=26257 sslmode=disable timezone=UTC
run: make short_test
integration-mariadb:
if: ${{ github.ref == 'refs/heads/master' }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
options: --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
mariadb-clients
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MariaDB service
run: |
mariadb -h mariadb -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MariaDB
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mariadb:3306)/integration?charset=utf8mb4&parseTime=True
run: make short_test
integration-mysql:
if: ${{ github.ref == 'refs/heads/master' }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
mariadb-clients
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MySQL service
run: |
mariadb -h mysql -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MySQL
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mysql:3306)/integration?charset=utf8mb4&parseTime=True
run: make short_test
integration-postgres:
if: ${{ github.ref == 'refs/heads/master' }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: twitch-bot-pass
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Run tests against PostgreSQL
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=postgres user=postgres password=twitch-bot-pass dbname=postgres port=5432 sslmode=disable timezone=UTC
run: make short_test
release:
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs:
- test
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: |
git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Build release
run: make publish
- name: Extract changelog
run: awk "/^#/ && ++c==2{exit}; /^#/f" "History.md" | tail -n +2 >release_changelog.md
- name: Update stable branch
run: |
git branch -f stable ${GITHUB_SHA}
git push -f origin stable
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: .build/*
bodyFile: release_changelog.md
draft: false
generateReleaseNotes: false
test:
if: ${{ github.ref != 'refs/heads/stable' }}
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: |
git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Lint and test code
run: make lint test frontend_lint
- name: Build release (quick local for compile)
run: make build_prod
- name: Execute Trivy scan
run: make trivy

View file

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

View file

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

View file

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

View file

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

View file

@ -1,87 +0,0 @@
---
name: test-and-build
on:
push:
branches-ignore:
- stable
tags:
- 'v*'
permissions:
contents: write
jobs:
test-and-build:
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
awk \
curl \
diffutils \
git \
git-lfs \
go \
golangci-lint-bin \
make \
nodejs-lts-hydrogen \
npm \
tar \
trivy \
unzip \
which \
zip
- uses: actions/checkout@v4
with:
lfs: true
show-progress: false
- name: Marking workdir safe
run: |
git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Lint and test code
run: make lint test frontend_lint
- name: Build release
run: make publish
- name: Execute Trivy scan
run: make trivy
- name: Extract changelog
run: 'awk "/^#/ && ++c==2{exit}; /^#/f" "History.md" | tail -n +2 >release_changelog.md'
- name: Update stable branch
if: startsWith(github.ref, 'refs/tags/')
run: |
git branch -f stable ${GITHUB_SHA}
git push -f origin stable
- name: Release
uses: ncipollo/release-action@v1
if: startsWith(github.ref, 'refs/tags/')
with:
artifacts: '.build/*'
bodyFile: release_changelog.md
draft: false
generateReleaseNotes: false
...

View file

@ -1,3 +1,66 @@
# 3.29.2 / 2024-04-13
> [!IMPORTANT]
> This release introduces a new configuration validation which might lead to your bot not starting as of stronger type checking of actor settings. To validate the config is fine run a validation against the config once before replacing the bot binary / Docker image:
>
> `./twitch-bot --storage-conn-string "file::memory:?cache=shared" -c path/to/config.yaml validate-config`
>
> Using the connection string shown above will use a non-persistent database and can be used while the existing bot is running.
* New Features
* [eventsub] Add support for suspicious user events
* Improvements
* [core] Enforce attribute type schema validation on config
* [core] Remove deprecated fallback token / token migration
* [counter] Allow `counterTopList` to specify how to sort
* [counter] Record first seen and last updated on counters
* [counter] Revise template parsing logic
* [docs] Add field-type annotations to events
* [spotify] Improve error handling / documentation
* [spotify] Switch to PKCE flow, remove need for clientSecret
* Bugfixes
* [core] Fix: Do not retry core-kv query when it's not set
* [core] Fix: Don't initialize twitch client before start checks
* [eventsub] Fix: Fetching existing subscriptions broken
> [!NOTE]
> In case you're using the DockerHub Docker images and rely on the presence of the `stable` tag please switch to the [Github Registry](https://github.com/Luzifer/twitch-bot/pkgs/container/twitch-bot) and use the `latest` tag. Development releases are published as `develop`. The `stable` tag will not be updated beyond `v3.28.1`, DockerHub images are currently still supported but will be faded out.
> [!NOTE]
> Re-release of v3.29.0 as of broken tests in that release, no functional changes.
# 3.29.0 / 2024-04-13
> [!IMPORTANT]
> This release introduces a new configuration validation which might lead to your bot not starting as of stronger type checking of actor settings. To validate the config is fine run a validation against the config once before replacing the bot binary / Docker image:
>
> `./twitch-bot --storage-conn-string "file::memory:?cache=shared" -c path/to/config.yaml validate-config`
>
> Using the connection string shown above will use a non-persistent database and can be used while the existing bot is running.
* New Features
* [eventsub] Add support for suspicious user events
* Improvements
* [core] Enforce attribute type schema validation on config
* [core] Remove deprecated fallback token / token migration
* [counter] Allow `counterTopList` to specify how to sort
* [counter] Record first seen and last updated on counters
* [counter] Revise template parsing logic
* [docs] Add field-type annotations to events
* [spotify] Improve error handling / documentation
* [spotify] Switch to PKCE flow, remove need for clientSecret
* Bugfixes
* [core] Fix: Do not retry core-kv query when it's not set
* [core] Fix: Don't initialize twitch client before start checks
* [eventsub] Fix: Fetching existing subscriptions broken
> [!NOTE]
> In case you're using the DockerHub Docker images and rely on the presence of the `stable` tag please switch to the [Github Registry](https://github.com/Luzifer/twitch-bot/pkgs/container/twitch-bot) and use the `latest` tag. Development releases are published as `develop`. The `stable` tag will not be updated beyond `v3.28.1`, DockerHub images are currently still supported but will be faded out.
# 3.28.1 / 2024-04-02 # 3.28.1 / 2024-04-02
* New Features * New Features

View file

@ -15,6 +15,9 @@ lint:
publish: frontend_prod publish: frontend_prod
bash ./ci/build.sh bash ./ci/build.sh
short_test:
go test -cover -test.short -v ./...
test: test:
go test -cover -v ./... go test -cover -v ./...
@ -33,7 +36,7 @@ frontend_lint: node_modules
src src
node_modules: node_modules:
npm ci npm ci --include dev
# --- Tools # --- Tools
@ -41,6 +44,9 @@ update_ua_list:
# User-Agents provided by https://www.useragents.me/ # User-Agents provided by https://www.useragents.me/
curl -sSf https://www.useragents.me/api | jq -r '.data[].ua' | grep -v 'Trident' >internal/linkcheck/user-agents.txt curl -sSf https://www.useragents.me/api | jq -r '.data[].ua' | grep -v 'Trident' >internal/linkcheck/user-agents.txt
gh-workflow:
bash ci/create-workflow.sh
# -- Vulnerability scanning -- # -- Vulnerability scanning --
trivy: trivy:

View file

@ -4,12 +4,14 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"os/exec" "os/exec"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -49,7 +51,7 @@ func init() {
type ActorScript struct{} type ActorScript struct{}
// Execute implements actor interface // Execute implements actor interface
func (ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (ActorScript) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
command, err := attrs.StringSlice("command") command, err := attrs.StringSlice("command")
if err != nil { if err != nil {
return false, errors.Wrap(err, "getting command") return false, errors.Wrap(err, "getting command")
@ -130,13 +132,16 @@ func (ActorScript) IsAsync() bool { return false }
func (ActorScript) Name() string { return "script" } func (ActorScript) Name() string { return "script" }
// Validate implements actor interface // Validate implements actor interface
func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
cmd, err := attrs.StringSlice("command") if err = attrs.ValidateSchema(
if err != nil || len(cmd) == 0 { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "command", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeStringSlice}),
return errors.New("command must be slice of strings with length > 0") fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "skip_cooldown_on_error", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
for i, el := range cmd { for i, el := range attrs.MustStringSlice("command", nil) {
if err = tplValidator(el); err != nil { if err = tplValidator(el); err != nil {
return errors.Wrapf(err, "validating cmd template (element %d)", i) return errors.Wrapf(err, "validating cmd template (element %d)", i)
} }

View file

@ -7,6 +7,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -41,7 +42,7 @@ func registerAction(name string, acf plugins.ActorCreationFunc) {
availableActions[name] = acf availableActions[name] = acf
} }
func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData *plugins.FieldCollection) (preventCooldown bool, err error) { func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugins.RuleAction, eventData *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
availableActionsLock.RLock() availableActionsLock.RLock()
defer availableActionsLock.RUnlock() defer availableActionsLock.RUnlock()
@ -65,7 +66,7 @@ func triggerAction(c *irc.Client, m *irc.Message, rule *plugins.Rule, ra *plugin
return apc, errors.Wrap(err, "execute action") return apc, errors.Wrap(err, "execute action")
} }
func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData *plugins.FieldCollection) { func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData *fieldcollection.FieldCollection) {
// Send events to registered handlers // Send events to registered handlers
if event != nil { if event != nil {
go notifyEventHandlers(*event, eventData) go notifyEventHandlers(*event, eventData)
@ -77,9 +78,9 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string, eventData *plug
} }
} }
func handleMessageRuleExecution(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection) { func handleMessageRuleExecution(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection) {
var ( var (
ruleEventData = plugins.NewFieldCollection() ruleEventData = fieldcollection.NewFieldCollection()
preventCooldown bool preventCooldown bool
) )

View file

@ -13,8 +13,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/plugins"
) )
var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) var cronParser = cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
@ -174,7 +174,7 @@ func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
return true return true
} }
fields := plugins.NewFieldCollection() fields := fieldcollection.NewFieldCollection()
fields.Set("channel", a.Channel) fields.Set("channel", a.Channel)
res, err := formatMessage(*a.DisableOnTemplate, nil, nil, fields) res, err := formatMessage(*a.DisableOnTemplate, nil, nil, fields)

15
ci/create-workflow.sh Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
target_file=.github/workflows/generated_workflow.yml
source_files=($(find ci/workflow-parts -name 'part_*'))
base=ci/workflow-parts/index.yaml
cp ${base} ${target_file}
for source_file in "${source_files[@]}"; do
job_name=${source_file##*/part_}
job_name=${job_name%%.*}
yq -P ".jobs.${job_name} |= load(\"${source_file}\")" ${target_file} >${target_file}.new
mv ${target_file}.new ${target_file}
done

View file

@ -19,8 +19,8 @@ branch)
tags+=(develop) tags+=(develop)
;; ;;
tag) tag)
# Build to latest & stable: Older tags are not intended to rebuild # Build to latest: Older tags are not intended to rebuild
tags+=(latest stable ${GITHUB_REF_NAME}) tags+=(develop latest ${GITHUB_REF_NAME})
;; ;;
*) *)
log "ERR: The ref type ${GITHUB_REF_TYPE} is not handled." log "ERR: The ref type ${GITHUB_REF_TYPE} is not handled."

View file

@ -0,0 +1,7 @@
name: CI Workflow
on: push
permissions:
contents: read
jobs: {}

View file

@ -0,0 +1,44 @@
---
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
permissions:
contents: read
pages: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
show-progress: false
submodules: true
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Generate documentation
run: make render_docs DOCS_BASE_URL=https://luzifer.github.io/twitch-bot/
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v1
with:
path: .rendered-docs
- name: Deploy artifact
uses: actions/deploy-pages@v1
...

View file

@ -0,0 +1,30 @@
---
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master' }}
needs: [test]
defaults:
run:
shell: bash
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Build & Publish
run: bash ci/docker-publish.sh
...

View file

@ -0,0 +1,54 @@
---
if: ${{ github.ref == 'refs/heads/master' }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
crdb:
image: luzifer/crdb-gh-service
options: >-
--health-cmd "curl -sSf http://localhost:8080/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
cockroachdb-bin
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up CRDB service
run: |
cockroach sql --host crdb --insecure <<EOF
CREATE DATABASE integration;
CREATE USER "twitch_bot" WITH PASSWORD NULL;
ALTER DATABASE integration OWNER to "twitch_bot";
EOF
- name: Run tests against CRDB
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=crdb user=twitch_bot dbname=integration port=26257 sslmode=disable timezone=UTC
run: make short_test
...

View file

@ -0,0 +1,57 @@
---
if: ${{ github.ref == 'refs/heads/master' }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
options: >-
--health-cmd "healthcheck.sh --connect --innodb_initialized"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
mariadb-clients
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MariaDB service
run: |
mariadb -h mariadb -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MariaDB
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mariadb:3306)/integration?charset=utf8mb4&parseTime=True
run: make short_test
...

View file

@ -0,0 +1,57 @@
---
if: ${{ github.ref == 'refs/heads/master' }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Install required packages
run: |
pacman -Syy --noconfirm \
mariadb-clients
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Set up MySQL service
run: |
mariadb -h mysql -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MySQL
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mysql:3306)/integration?charset=utf8mb4&parseTime=True
run: make short_test
...

View file

@ -0,0 +1,43 @@
---
if: ${{ github.ref == 'refs/heads/master' }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: twitch-bot-pass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Run tests against PostgreSQL
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=postgres user=postgres password=twitch-bot-pass dbname=postgres port=5432 sslmode=disable timezone=UTC
run: make short_test
...

View file

@ -0,0 +1,49 @@
---
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs: [test]
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: |
git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Build release
run: make publish
- name: Extract changelog
run: 'awk "/^#/ && ++c==2{exit}; /^#/f" "History.md" | tail -n +2 >release_changelog.md'
- name: Update stable branch
run: |
git branch -f stable ${GITHUB_SHA}
git push -f origin stable
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: '.build/*'
bodyFile: release_changelog.md
draft: false
generateReleaseNotes: false
...

View file

@ -0,0 +1,35 @@
---
if: ${{ github.ref != 'refs/heads/stable' }}
defaults:
run:
shell: bash
container:
image: luzifer/gh-arch-env
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Marking workdir safe
run: |
git config --global --add safe.directory /__w/twitch-bot/twitch-bot
- name: Lint and test code
run: make lint test frontend_lint
- name: Build release (quick local for compile)
run: make build_prod
- name: Execute Trivy scan
run: make trivy
...

View file

@ -19,6 +19,7 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -303,7 +304,7 @@ func (c *configFile) CloseRawMessageWriter() (err error) {
return nil return nil
} }
func (c configFile) GetMatchingRules(m *irc.Message, event *string, eventData *plugins.FieldCollection) []*plugins.Rule { func (c configFile) GetMatchingRules(m *irc.Message, event *string, eventData *fieldcollection.FieldCollection) []*plugins.Rule {
configLock.RLock() configLock.RLock()
defer configLock.RUnlock() defer configLock.RUnlock()

View file

@ -8,10 +8,10 @@ Ad-break has begun and ads are playing now in mentioned channel.
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `duration` - Duration of the ads in seconds - `duration` _int64_ - Duration of the ads in seconds
- `is_automatic` - Were the ads started by the ad-manager? - `is_automatic` _bool_ - Were the ads started by the ad-manager?
- `started_at` - When did the ad-break start - `started_at` _time.Time_ - When did the ad-break start
## `ban` ## `ban`
@ -21,9 +21,9 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `target_id` - The ID of the user being banned - `target_id` _string_ - The ID of the user being banned
- `target_name` - The login-name of the user being banned - `target_name` _string_ - The login-name of the user being banned
## `bits` ## `bits`
@ -31,9 +31,9 @@ User spent bits in the channel. The full message is available like in a normal c
Fields: Fields:
- `bits` - Total amount of bits spent in the message - `bits` _int64_ - Total amount of bits spent in the message
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `username` - The login-name of the user who spent the bits - `username` _string_ - The login-name of the user who spent the bits
## `category_update` ## `category_update`
@ -41,8 +41,8 @@ The current category for the channel was changed. (This event has some delay to
Fields: Fields:
- `category` - The name of the new game / category - `category` _string_ - The name of the new game / category
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
## `channelpoint_redeem` ## `channelpoint_redeem`
@ -50,14 +50,14 @@ A custom channel-point reward was redeemed in the given channel. (Only available
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `reward_cost` - Number of points the user paid for the reward - `reward_cost` _int64_ - Number of points the user paid for the reward
- `reward_id` - ID of the reward the user redeemed - `reward_id` _string_ - ID of the reward the user redeemed
- `reward_title` - Title of the reward the user redeemed - `reward_title` _string_ - Title of the reward the user redeemed
- `status` - Status of the reward (one of `unknown`, `unfulfilled`, `fulfilled`, and `canceled`) - `status` _string_ - Status of the reward (one of `unknown`, `unfulfilled`, `fulfilled`, and `canceled`)
- `user_id` - The ID of the user who redeemed the reward - `user_id` _string_ - The ID of the user who redeemed the reward
- `user_input` - The text the user entered into the input for the reward - `user_input` _string_ - The text the user entered into the input for the reward
- `user` - The login-name of the user who redeemed the reward - `user` _string_ - The login-name of the user who redeemed the reward
## `clearchat` ## `clearchat`
@ -67,7 +67,7 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
## `delete` ## `delete`
@ -77,9 +77,9 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `message_id` - The UUID of the message being deleted - `message_id` _string_ - The UUID of the message being deleted
- `target_name` - Login name of the author of the deleted message - `target_name` _string_ - Login name of the author of the deleted message
## `follow` ## `follow`
@ -87,10 +87,10 @@ User followed the channel. This event is not de-duplicated and therefore might b
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `followed_at` - Time object of the follow date - `followed_at` _time.Time_ - Time object of the follow date
- `user_id` - ID of the newly following user - `user_id` _string_ - ID of the newly following user
- `user` - The login-name of the user who followed - `user` _string_ - The login-name of the user who followed
## `giftpaidupgrade` ## `giftpaidupgrade`
@ -98,9 +98,9 @@ User upgraded their gifted subscription into a paid one. This event does not con
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `gifter` - The login-name of the user who gifted the subscription - `gifter` _string_ - The login-name of the user who gifted the subscription
- `username` - The login-name of the user who upgraded their subscription - `username` _string_ - The login-name of the user who upgraded their subscription
## `hypetrain_begin`, `hypetrain_end`, `hypetrain_progress` ## `hypetrain_begin`, `hypetrain_end`, `hypetrain_progress`
@ -108,10 +108,10 @@ An Hype-Train has begun, ended or progressed in the given channel.
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `level` - The current level of the Hype-Train - `level` _int64_ - The current level of the Hype-Train
- `levelProgress` - Percentage of reached "points" in the current level to complete the level (not available on `hypetrain_end`) - `levelProgress` _float64_ - Percentage of reached "points" in the current level to complete the level (not available on `hypetrain_end`)
- `event` - Raw Hype-Train event, see schema in [`pkg/twitch/eventsub.go#L92`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/eventsub.go#L121) - `event` _EventSubEventHypetrain_ - Raw Hype-Train event, see schema in [`pkg/twitch/eventsub.go#L92`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/eventsub.go#L121)
## `join` ## `join`
@ -119,8 +119,8 @@ User joined the channel-chat. This is **NOT** an indicator they are viewing, the
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `user` - The login-name of the user who joined - `user` _string_ - The login-name of the user who joined
## `kofi_donation` ## `kofi_donation`
@ -128,14 +128,14 @@ A Ko-fi donation was received through the API-Webhook.
Fields: Fields:
- `channel` - The channel the event occurred for - `channel` _string_ - The channel the event occurred for
- `from` - The name submitted by Ko-fi (can be arbitrarily entered) - `from` _string_ - The name submitted by Ko-fi (can be arbitrarily entered)
- `amount` - The amount donated as submitted by Ko-fi (i.e. 27.95) - `amount` _float64_ - The amount donated as submitted by Ko-fi (i.e. 27.95)
- `currency` - The currency of the amount (i.e. USD) - `currency` _string_ - The currency of the amount (i.e. USD)
- `isSubscription` - Boolean, true on monthly subscriptions, false on single-donations - `isSubscription` _bool_ - true on monthly subscriptions, false on single-donations
- `isFirstSubPayment` - Boolean, true on first montly payment, false otherwise - `isFirstSubPayment` _bool_ - true on first montly payment, false otherwise
- `message` - The message entered by the donator (**not** present when donation was marked as private!) - `message` _string_ - The message entered by the donator (**not** present when donation was marked as private!)
- `tier` - The tier the subscriber subscribed to (seems not to be filled on the first transaction?) - `tier` _string_ - The tier the subscriber subscribed to (seems not to be filled on the first transaction?)
## `outbound_raid` ## `outbound_raid`
@ -143,10 +143,10 @@ The channel has raided another channel. (The event is issued in the moment the r
Fields: Fields:
- `channel` - The channel the raid originated at - `channel` _string_ - The channel the raid originated at
- `to` - The login-name of the channel the viewers are sent to - `to` _string_ - The login-name of the channel the viewers are sent to
- `to_id` - The ID of the channel the viewers are sent to - `to_id` _string_ - The ID of the channel the viewers are sent to
- `viewers` - The number of viewers included in the raid - `viewers` _int64_ - The number of viewers included in the raid
## `part` ## `part`
@ -154,8 +154,8 @@ User left the channel-chat. This is **NOT** an indicator they are no longer view
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `user` - The login-name of the user who left - `user` _string_ - The login-name of the user who left
## `permit` ## `permit`
@ -163,9 +163,9 @@ User received a permit, which means they are no longer affected by rules which a
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `user` - The login-name of the user who **gave** the permit - `user` _string_ - The login-name of the user who **gave** the permit
- `to` - The username who got the permit - `to` _string_ - The username who got the permit
## `poll_begin` / `poll_end` / `poll_progress` ## `poll_begin` / `poll_end` / `poll_progress`
@ -173,10 +173,10 @@ A poll was started / was ended / had changes in the given channel.
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `poll` - The poll object describing the poll, see schema in [`pkg/twitch/eventsub.go#L92`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/eventsub.go#L152) - `poll` _EventSubEventPoll_ - The poll object describing the poll, see schema in [`pkg/twitch/eventsub.go#L92`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/eventsub.go#L152)
- `status` - The status of the poll (one of `completed`, `terminated` or `archived`) - only available in `poll_end` - `status` _string_ - The status of the poll (one of `completed`, `terminated` or `archived`) - only available in `poll_end`
- `title` - The title of the poll the event was generated for - `title` _string_ - The title of the poll the event was generated for
## `raid` ## `raid`
@ -184,9 +184,9 @@ The channel was raided by another user.
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `username` - The login-name of the user who raided the channel - `username` _string_ - The login-name of the user who raided the channel
- `viewercount` - The amount of users who have been raided (this number is not fully accurate) - `viewercount` _int64_ - The amount of users who have been raided (this number is not fully accurate)
## `resub` ## `resub`
@ -194,10 +194,10 @@ The user shared their resubscription. (This event is triggered manually by the u
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`) - `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` - How long have they been subscribed - `subscribed_months` _int64_ - How long have they been subscribed
- `username` - The login-name of the user who resubscribed - `username` _string_ - The login-name of the user who resubscribed
## `shoutout_created` ## `shoutout_created`
@ -205,10 +205,10 @@ The channel gave another streamer a (Twitch native) shoutout
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `to_id` - The ID of the channel who received the shoutout - `to_id` _string_ - The ID of the channel who received the shoutout
- `to` - The login-name of the channel who received the shoutout - `to` _string_ - The login-name of the channel who received the shoutout
- `viewers` - The amount of viewers the shoutout was shown to - `viewers` _int64_ - The amount of viewers the shoutout was shown to
## `shoutout_received` ## `shoutout_received`
@ -216,10 +216,10 @@ The channel received a (Twitch native) shoutout by another channel.
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `from_id` - The ID of the channel who issued the shoutout - `from_id` _string_ - The ID of the channel who issued the shoutout
- `from` - The login-name of the channel who issued the shoutout - `from` _string_ - The login-name of the channel who issued the shoutout
- `viewers` - The amount of viewers the shoutout was shown to - `viewers` _int64_ - The amount of viewers the shoutout was shown to
## `stream_offline` ## `stream_offline`
@ -227,7 +227,7 @@ The channels stream went offline. (This event has some delay to the real categor
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
## `stream_online` ## `stream_online`
@ -235,7 +235,7 @@ The channels stream went offline. (This event has some delay to the real categor
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
## `sub` ## `sub`
@ -243,9 +243,9 @@ The user newly subscribed on their own. (This event is triggered automatically a
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`) - `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `username` - The login-name of the user who subscribed - `username` _string_ - The login-name of the user who subscribed
## `subgift` ## `subgift`
@ -253,13 +253,14 @@ The user gifted the subscription to a specific user. (This event **DOES** occur
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `gifted_months` - Number of months the user gifted - `gifted_months` _int64_ - Number of months the user gifted
- `origin_id` - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event) - `origin_id` _string_ - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event)
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`) - `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` - How long the recipient has been subscribed - `subscribed_months` _int64_ - How long the recipient has been subscribed
- `to` - The user who received the sub - `to` _string_ - The user who received the sub
- `username` - The login-name of the user who gifted the subscription - `total_gifted` _int64_ - How many subs has the user given in total (might be zero due to users preferences)
- `username` _string_ - The login-name of the user who gifted the subscription
## `submysterygift` ## `submysterygift`
@ -267,11 +268,34 @@ The user gifted multiple subs to the community. (This event is followed by `numb
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `number` - The amount of gifted subs - `number` _int64_ - The amount of gifted subs
- `origin_id` - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event) - `origin_id` _string_ - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event)
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`) - `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `username` - The login-name of the user who gifted the subscription - `username` _string_ - The login-name of the user who gifted the subscription
## `sus_user_message`
A suspicious (monitored / restricted) user sent a message in the given channel
- `ban_evasion` _string_ - Status of the ban-evasion detection: `unknown`, `possible`, `likely`
- `channel` _string_ - The channel in which the event occurred
- `message` _string_ - The message the user sent in plain text
- `shared_ban_channels` _[]string_ - IDs of channels with shared ban-info in which the user is also banned
- `status` _string_ - Restriction status: `active_monitoring`, `restricted`
- `user_id` _string_ - ID of the user sending the message
- `user_type` _[]string_ - How the user ended being on the naughty-list: `manually_added`, `ban_evader_detector`, or `shared_channel_ban`
- `username` _string_ - The login-name of the user sending the message
## `sus_user_update`
The status of suspicious user was changed by a moderator
- `channel` _string_ - The channel in which the event occurred
- `moderator` _string_ - The login-name of the moderator changing the status
- `status` _string_ - Restriction status: `no_treatment`, `active_monitoring`, `restricted`
- `user_id` _string_ - ID of the suspicious user
- `username` _string_ - Login-name of the suspicious user
## `timeout` ## `timeout`
@ -281,11 +305,11 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `duration` - The timeout duration (`time.Duration`, nanoseconds) - `duration` _time.Duration_ - The timeout duration (nanoseconds)
- `seconds` - The timeout duration (`int`, seconds) - `seconds` _int_ - The timeout duration (seconds)
- `target_id` - The ID of the user being timed out - `target_id` _string_ - The ID of the user being timed out
- `target_name` - The login-name of the user being timed out - `target_name` _string_ - The login-name of the user being timed out
## `title_update` ## `title_update`
@ -293,8 +317,8 @@ The current title for the channel was changed. (This event has some delay to the
Fields: Fields:
- `channel` - The channel the event occurred in - `channel` _string_ - The channel the event occurred in
- `title` - The title of the stream - `title` _string_ - The title of the stream
## `whisper` ## `whisper`
@ -302,4 +326,4 @@ The bot received a whisper message. (You can use `(.*)` as message match and `{{
Fields: Fields:
- `username` - The login-name of the user who sent the message - `username` _string_ - The login-name of the user who sent the message

View file

@ -128,9 +128,9 @@ Example:
### `counterTopList` ### `counterTopList`
Returns the top n counters for the given prefix as objects with Name and Value fields Returns the top n counters for the given prefix as objects with Name and Value fields. Can be ordered by `name` / `value` / `first_seen` / `last_modified` ascending (`ASC`) or descending (`DESC`): i.e. `last_modified DESC` defaults to `value DESC`
Syntax: `counterTopList <prefix> <n>` Syntax: `counterTopList <prefix> <n> [orderBy]`
Example: Example:
@ -467,7 +467,7 @@ Example:
``` ```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% # Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 70% < Your int this hour: 88%
``` ```
### `spotifyCurrentPlaying` ### `spotifyCurrentPlaying`

View file

@ -17,25 +17,29 @@ Start with going to the [Spotify for Developers Dashboard](https://developer.spo
- "App name" is something you can choose yourself - "App name" is something you can choose yourself
- "App description" is also required, choose yourself - "App description" is also required, choose yourself
- "Redirect URI" must be `https://example.com/spotify/<channel>` so for exmaple `https://example.com/spotify/luziferus` - "Redirect URI" must be `https://example.com/spotify/<channel>` so for example `https://example.com/spotify/luziferus`
- Select "Web API" for the "API/SDKs you are planning to use" - Select "Web API" for the "API/SDKs you are planning to use"
- Check the ToS box (of course after reading those!) - Check the ToS box (of course after reading those!)
- Click "Save" - Click "Save"
- From the "Settings" button of your app get the "Client ID" and "Client secret" and note them down - From the "Settings" button of your app get the "Client ID" and note it down
- Optional: If you need to authorize multiple channels (i.e. for multiple users of the bot instance) you can edit the "Redirect URIs" on the "Settings" page and add more. - Optional: If you need to authorize multiple channels (i.e. for multiple users of the bot instance) you can edit the "Redirect URIs" on the "Settings" page and add more.
{{< alert style="info" >}}If you are managing a bot instance for multiple persons having their own Spotify accounts you need to invite them to the Spotify app as long as it is in development-mode. You can do that in the Spotify Developer Dashboard under "User Management" (up to 25 users). As an alternative every person can create an own Spotify app and you can enter their `clientId` into the config for their respective channel.{{< /alert >}}
Now head into the configuration file and configure the Spotify module: Now head into the configuration file and configure the Spotify module:
```yaml ```yaml
# Module configuration by channel or defining bot-wide defaults. See # Module configuration by channel or defining bot-wide defaults. See
# module specific documentation for options to configure in this # module specific documentation for options to configure in this
# section. All modules come with internal defaults so there is no # section.
# need to configure this but you can overwrite the internal defaults.
module_config: module_config:
spotify: spotify:
# Use one client-id for all channels (invite users)
default: default:
clientId: 'put the client ID you noted down here' clientId: 'put the client ID you noted down here'
clientSecret: 'put the secret here' # Use one client-id per channel (have each user create an app)
anotherttvuser:
clientId: 'put the client ID they sent you here'
``` ```
Now send the user which currently playing track should be displayed to the `https://example.com/spotify/<channel>` URL. So I for example would visit `https://example.com/spotify/luziferus`. They are redirected to Spotify, need to authorize the app and if everything went well the bot tells them "Spotify is now authorized for this channel, you can close this page". Now send the user which currently playing track should be displayed to the `https://example.com/spotify/<channel>` URL. So I for example would visit `https://example.com/spotify/luziferus`. They are redirected to Spotify, need to authorize the app and if everything went well the bot tells them "Spotify is now authorized for this channel, you can close this page".

View file

@ -5,6 +5,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -44,6 +45,8 @@ var (
eventTypeSubgift = ptrStr("subgift") eventTypeSubgift = ptrStr("subgift")
eventTypeSubmysterygift = ptrStr("submysterygift") eventTypeSubmysterygift = ptrStr("submysterygift")
eventTypeSub = ptrStr("sub") eventTypeSub = ptrStr("sub")
eventTypeSusUserMessage = ptrStr("sus_user_message")
eventTypeSusUserUpdate = ptrStr("sus_user_update")
eventTypeTimeout = ptrStr("timeout") eventTypeTimeout = ptrStr("timeout")
eventTypeWatchStreak = ptrStr("watch_streak") eventTypeWatchStreak = ptrStr("watch_streak")
eventTypeWhisper = ptrStr("whisper") eventTypeWhisper = ptrStr("whisper")
@ -82,6 +85,8 @@ var (
eventTypeSub, eventTypeSub,
eventTypeSubgift, eventTypeSubgift,
eventTypeSubmysterygift, eventTypeSubmysterygift,
eventTypeSusUserMessage,
eventTypeSusUserUpdate,
eventTypeTimeout, eventTypeTimeout,
eventTypeWatchStreak, eventTypeWatchStreak,
eventTypeWhisper, eventTypeWhisper,
@ -93,7 +98,7 @@ var (
} }
) )
func notifyEventHandlers(event string, eventData *plugins.FieldCollection) { func notifyEventHandlers(event string, eventData *fieldcollection.FieldCollection) {
registeredEventHandlersLock.Lock() registeredEventHandlersLock.Lock()
defer registeredEventHandlersLock.Unlock() defer registeredEventHandlersLock.Unlock()

View file

@ -11,6 +11,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
korvike "github.com/Luzifer/korvike/functions" korvike "github.com/Luzifer/korvike/functions"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -37,7 +38,7 @@ func newTemplateFuncProvider() *templateFuncProvider {
return out return out
} }
func (t *templateFuncProvider) GetFuncMap(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) template.FuncMap { func (t *templateFuncProvider) GetFuncMap(m *irc.Message, r *plugins.Rule, fields *fieldcollection.FieldCollection) template.FuncMap {
t.lock.RLock() t.lock.RLock()
defer t.lock.RUnlock() defer t.lock.RUnlock()

View file

@ -6,12 +6,13 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
func init() { func init() {
tplFuncs.Register("arg", func(m *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection) interface{} { tplFuncs.Register("arg", func(m *irc.Message, _ *plugins.Rule, _ *fieldcollection.FieldCollection) interface{} {
return func(arg int) (string, error) { return func(arg int) (string, error) {
msgParts := strings.Split(m.Trailing(), " ") msgParts := strings.Split(m.Trailing(), " ")
if len(msgParts) <= arg { if len(msgParts) <= arg {
@ -30,7 +31,7 @@ func init() {
}, },
}) })
tplFuncs.Register("chatterHasBadge", func(m *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection) interface{} { tplFuncs.Register("chatterHasBadge", func(m *irc.Message, _ *plugins.Rule, _ *fieldcollection.FieldCollection) interface{} {
return func(badge string) bool { return func(badge string) bool {
badges := twitch.ParseBadgeLevels(m) badges := twitch.ParseBadgeLevels(m)
return badges.Has(badge) return badges.Has(badge)
@ -57,7 +58,7 @@ func init() {
}, },
) )
tplFuncs.Register("group", func(m *irc.Message, r *plugins.Rule, _ *plugins.FieldCollection) interface{} { tplFuncs.Register("group", func(m *irc.Message, r *plugins.Rule, _ *fieldcollection.FieldCollection) interface{} {
return func(idx int, fallback ...string) (string, error) { return func(idx int, fallback ...string) (string, error) {
fields := r.GetMatchMessage().FindStringSubmatch(m.Trailing()) fields := r.GetMatchMessage().FindStringSubmatch(m.Trailing())
if len(fields) <= idx { if len(fields) <= idx {
@ -94,7 +95,7 @@ func init() {
}, },
) )
tplFuncs.Register("tag", func(m *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection) interface{} { tplFuncs.Register("tag", func(m *irc.Message, _ *plugins.Rule, _ *fieldcollection.FieldCollection) interface{} {
return func(tag string) string { return m.Tags[tag] } return func(tag string) string { return m.Tags[tag] }
}, plugins.TemplateFuncDocumentation{ }, plugins.TemplateFuncDocumentation{
Description: "Takes the message sent to the channel, returns the value of the tag specified", Description: "Takes the message sent to the channel, returns the value of the tag specified",

12
go.mod
View file

@ -4,19 +4,19 @@ go 1.21
require ( require (
github.com/Luzifer/go-openssl/v4 v4.2.2 github.com/Luzifer/go-openssl/v4 v4.2.2
github.com/Luzifer/go_helpers/v2 v2.23.0 github.com/Luzifer/go_helpers/v2 v2.24.0
github.com/Luzifer/korvike/functions v0.11.0 github.com/Luzifer/korvike/functions v0.11.0
github.com/Luzifer/rconfig/v2 v2.5.0 github.com/Luzifer/rconfig/v2 v2.5.0
github.com/Masterminds/sprig/v3 v3.2.3 github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.27.0 github.com/getsentry/sentry-go v0.27.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-git/go-git/v5 v5.11.0 github.com/go-git/go-git/v5 v5.12.0
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2 github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/itchyny/gojq v0.12.14 github.com/itchyny/gojq v0.12.15
github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/orandin/sentrus v1.0.0 github.com/orandin/sentrus v1.0.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@ -31,7 +31,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.6 gorm.io/driver/mysql v1.5.6
gorm.io/driver/postgres v1.5.7 gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.25.8 gorm.io/gorm v1.25.9
) )
require ( require (
@ -87,7 +87,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shopspring/decimal v1.3.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
@ -105,6 +105,6 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.49.0 // indirect modernc.org/libc v1.49.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.29.5 // indirect modernc.org/sqlite v1.29.5 // indirect
) )

28
go.sum
View file

@ -6,8 +6,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc= github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc=
github.com/Luzifer/go-openssl/v4 v4.2.2/go.mod h1:+kAwI4NpyYXoWil85gKSCEJNoCQlMeFikEMn2f+5ffc= github.com/Luzifer/go-openssl/v4 v4.2.2/go.mod h1:+kAwI4NpyYXoWil85gKSCEJNoCQlMeFikEMn2f+5ffc=
github.com/Luzifer/go_helpers/v2 v2.23.0 h1:VowDwOCl6nOt+GVqKUX/do6a94pEeqNTRHb29MsoGX4= github.com/Luzifer/go_helpers/v2 v2.24.0 h1:abACOhsn6a6c6X22jq42mZM1wuOM0Ihfa6yzssrjrOg=
github.com/Luzifer/go_helpers/v2 v2.23.0/go.mod h1:BSGkJ/dxqs7AxsfZt8zjJb4R6YB5dONS+/ad7foLUrk= github.com/Luzifer/go_helpers/v2 v2.24.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc=
github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q= github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q=
github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU= github.com/Luzifer/korvike/functions v0.11.0/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok= github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok=
@ -62,8 +62,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 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-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@ -72,8 +72,8 @@ github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
@ -161,8 +161,8 @@ github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 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 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc= github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI=
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@ -245,8 +245,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 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 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@ -422,8 +422,8 @@ gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo= gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw= modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw=
@ -438,8 +438,8 @@ modernc.org/libc v1.49.0 h1:/kkNBuCXvlTbOGwrQdgR67eK1Y9+kR+fhdBd89C64VM=
modernc.org/libc v1.49.0/go.mod h1:DNz0lgQgT6FPIPm8rHtjFj0FL5/YOr/NYFXWYBcSxMw= modernc.org/libc v1.49.0/go.mod h1:DNz0lgQgT6FPIPm8rHtjFj0FL5/YOr/NYFXWYBcSxMw=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=

View file

@ -14,14 +14,14 @@ import (
) )
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`) announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`)
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
args.RegisterMessageModFunc("/announce", handleChatCommand) args.RegisterMessageModFunc("/announce", handleChatCommand)
@ -36,7 +36,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("announce message does not match required format") return errors.New("announce message does not match required format")
} }
if err := botTwitchClient.SendChatAnnouncement(context.Background(), channel, matches[1], matches[2]); err != nil { if err := botTwitchClient().SendChatAnnouncement(context.Background(), channel, matches[1], matches[2]); err != nil {
return errors.Wrap(err, "sending announcement") return errors.Wrap(err, "sending announcement")
} }

View file

@ -11,6 +11,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -18,7 +20,7 @@ import (
const actorName = "ban" const actorName = "ban"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`) banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`)
@ -26,7 +28,7 @@ var (
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) { func Register(args plugins.RegistrationArguments) (err error) {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -87,7 +89,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrStringEmpty := func(v string) *string { return &v }("") ptrStringEmpty := func(v string) *string { return &v }("")
reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData) reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData)
@ -96,7 +98,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.BanUser( botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData), plugins.DeriveUser(m, eventData),
@ -110,14 +112,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
reasonTemplate, err := attrs.String("reason") if err = attrs.ValidateSchema(
if err != nil || reasonTemplate == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "reason", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("reason must be non-empty string") fieldcollection.MustHaveNoUnknowFields,
} helpers.SchemaValidateTemplateField(tplValidator, "reason"),
); err != nil {
if err = tplValidator(reasonTemplate); err != nil { return fmt.Errorf("validating attributes: %w", err)
return errors.Wrap(err, "validating reason template")
} }
return nil return nil
@ -131,7 +132,7 @@ func handleAPIBan(w http.ResponseWriter, r *http.Request) {
reason = r.FormValue("reason") reason = r.FormValue("reason")
) )
if err := botTwitchClient.BanUser(r.Context(), channel, user, 0, reason); err != nil { if err := botTwitchClient().BanUser(r.Context(), channel, user, 0, reason); err != nil {
http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError)
return return
} }
@ -147,7 +148,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("ban message does not match required format") return errors.New("ban message does not match required format")
} }
if err := botTwitchClient.BanUser(context.Background(), channel, matches[1], 0, matches[2]); err != nil { if err := botTwitchClient().BanUser(context.Background(), channel, matches[1], 0, matches[2]); err != nil {
return errors.Wrap(err, "executing ban") return errors.Wrap(err, "executing ban")
} }

View file

@ -9,6 +9,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -19,9 +21,6 @@ var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
hasPerm plugins.ChannelPermissionCheckFunc hasPerm plugins.ChannelPermissionCheckFunc
tcGetter func(string) (*twitch.Client, error) tcGetter func(string) (*twitch.Client, error)
ptrBoolFalse = func(v bool) *bool { return &v }(false)
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -71,7 +70,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
channel := plugins.DeriveChannel(m, eventData) channel := plugins.DeriveChannel(m, eventData)
if channel, err = formatMessage(attrs.MustString("channel", &channel), m, r, eventData); err != nil { if channel, err = formatMessage(attrs.MustString("channel", &channel), m, r, eventData); err != nil {
return false, errors.Wrap(err, "parsing channel") return false, errors.Wrap(err, "parsing channel")
@ -96,7 +95,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
return false, errors.Wrapf(err, "getting Twitch client for %q", creator) return false, errors.Wrapf(err, "getting Twitch client for %q", creator)
} }
clipInfo, err := tc.CreateClip(context.TODO(), channel, attrs.MustBool("add_delay", ptrBoolFalse)) clipInfo, err := tc.CreateClip(context.TODO(), channel, attrs.MustBool("add_delay", helpers.Ptr(false)))
if err != nil { if err != nil {
return false, errors.Wrap(err, "creating clip") return false, errors.Wrap(err, "creating clip")
} }
@ -110,11 +109,15 @@ func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
for _, field := range []string{"channel", "creator"} { if err = attrs.ValidateSchema(
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.Wrapf(err, "validating %s template", field) fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "creator", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "add_delay", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "channel", "creator"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -9,6 +9,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/actors/linkdetector" "github.com/Luzifer/twitch-bot/v3/internal/actors/linkdetector"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -17,13 +18,13 @@ import (
const actorName = "clipdetector" const actorName = "clipdetector"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
clipIDScanner = regexp.MustCompile(`(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/([A-Za-z0-9_-]+)`) clipIDScanner = regexp.MustCompile(`(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/([A-Za-z0-9_-]+)`)
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -40,7 +41,7 @@ func Register(args plugins.RegistrationArguments) error {
type Actor struct{} type Actor struct{}
// Execute implements the actor interface // Execute implements the actor interface
func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
if eventData.HasAll("clips") { if eventData.HasAll("clips") {
// We already detected clips, lets not do it again // We already detected clips, lets not do it again
return false, nil return false, nil
@ -63,7 +64,7 @@ func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
continue continue
} }
clipInfo, err := botTwitchClient.GetClipByID(context.Background(), clipIDMatch[1]) clipInfo, err := botTwitchClient().GetClipByID(context.Background(), clipIDMatch[1])
if err != nil { if err != nil {
return false, errors.Wrap(err, "getting clip info") return false, errors.Wrap(err, "getting clip info")
} }
@ -82,4 +83,6 @@ func (Actor) IsAsync() bool { return false }
func (Actor) Name() string { return actorName } func (Actor) Name() string { return actorName }
// Validate implements the actor interface // Validate implements the actor interface
func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil } func (Actor) Validate(plugins.TemplateValidatorFunc, *fieldcollection.FieldCollection) error {
return nil
}

View file

@ -3,6 +3,7 @@ package commercial
import ( import (
"context" "context"
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -10,6 +11,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -61,7 +64,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrStringEmpty := func(v string) *string { return &v }("") ptrStringEmpty := func(v string) *string { return &v }("")
durationStr, err := formatMessage(attrs.MustString("duration", ptrStringEmpty), m, r, eventData) durationStr, err := formatMessage(attrs.MustString("duration", ptrStringEmpty), m, r, eventData)
@ -75,14 +78,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
durationTemplate, err := attrs.String("duration") if err = attrs.ValidateSchema(
if err != nil || durationTemplate == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "duration", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("duration must be non-empty string") fieldcollection.MustHaveNoUnknowFields,
} helpers.SchemaValidateTemplateField(tplValidator, "duration"),
); err != nil {
if err = tplValidator(durationTemplate); err != nil { return fmt.Errorf("validating attributes: %w", err)
return errors.Wrap(err, "validating duration template")
} }
return nil return nil

View file

@ -7,12 +7,15 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -21,7 +24,7 @@ var (
db database.Connector db database.Connector
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrStringEmpty = func(s string) *string { return &s }("") errNotAValue = fmt.Errorf("not a value")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -135,7 +138,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
return fmt.Errorf("registering API route: %w", err) return fmt.Errorf("registering API route: %w", err)
} }
args.RegisterTemplateFunction("channelCounter", func(_ *irc.Message, _ *plugins.Rule, fields *plugins.FieldCollection) interface{} { args.RegisterTemplateFunction("channelCounter", func(_ *irc.Message, _ *plugins.Rule, fields *fieldcollection.FieldCollection) interface{} {
return func(name string) (string, error) { return func(name string) (string, error) {
channel, err := fields.String("channel") channel, err := fields.String("channel")
if err != nil { if err != nil {
@ -165,11 +168,11 @@ func Register(args plugins.RegistrationArguments) (err error) {
}, },
}) })
args.RegisterTemplateFunction("counterTopList", plugins.GenericTemplateFunctionGetter(func(prefix string, n int) ([]counter, error) { args.RegisterTemplateFunction("counterTopList", plugins.GenericTemplateFunctionGetter(func(prefix string, n int, orderBy string) ([]counter, error) {
return getCounterTopList(db, prefix, n) return getCounterTopList(db, prefix, n, orderBy)
}), plugins.TemplateFuncDocumentation{ }), plugins.TemplateFuncDocumentation{
Description: "Returns the top n counters for the given prefix as objects with Name and Value fields", Description: "Returns the top n counters for the given prefix as objects with Name and Value fields. Can be ordered by `name` / `value` / `first_seen` / `last_modified` ascending (`ASC`) or descending (`DESC`): i.e. `last_modified DESC` defaults to `value DESC`",
Syntax: `counterTopList <prefix> <n>`, Syntax: `counterTopList <prefix> <n> [orderBy]`,
Example: &plugins.TemplateFuncDocumentationExample{ Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ range (counterTopList (list .channel "test" "" | join ":") 3) }}{{ .Name }}: {{ .Value }} - {{ end }}`, Template: `{{ range (counterTopList (list .channel "test" "" | join ":") 3) }}{{ .Name }}: {{ .Value }} - {{ end }}`,
FakedOutput: "#example:test:foo: 5 - #example:test:bar: 4 - ", FakedOutput: "#example:test:foo: 5 - #example:test:bar: 4 - ",
@ -193,7 +196,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
mod = val[0] mod = val[0]
} }
if err := updateCounter(db, name, mod, false); err != nil { if err := updateCounter(db, name, mod, false, time.Now()); err != nil {
return 0, errors.Wrap(err, "updating counter") return 0, errors.Wrap(err, "updating counter")
} }
@ -212,65 +215,114 @@ func Register(args plugins.RegistrationArguments) (err error) {
type actorCounter struct{} type actorCounter struct{}
func (actorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (a actorCounter) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData) counterName, err := formatMessage(attrs.MustString("counter", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing response") return false, errors.Wrap(err, "preparing response")
} }
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" { // First lets look whether we shall set the counter (counter_set is
parseValue, err := formatMessage(counterSet, m, r, eventData) // defined and the template evaluates into something which is not
if err != nil { // an empty string)
return false, errors.Wrap(err, "execute counter value template") counterSet, err := a.parseAttributeTemplateToNumber(m, r, eventData, attrs, "counter_set", 0)
switch {
case err == nil:
// Nice, we got a value to set
if err = updateCounter(db, counterName, counterSet, true, time.Now()); err != nil {
return false, fmt.Errorf("setting counter: %w", err)
} }
return false, nil
counterValue, err := strconv.ParseInt(parseValue, 10, 64) case errors.Is(err, errNotAValue):
if err != nil { // Nope, not a set but that's fine, we just go to step-adjustment
return false, errors.Wrap(err, "parse counter value")
}
return false, errors.Wrap( default:
updateCounter(db, counterName, counterValue, true), // B0rked
"set counter", return false, fmt.Errorf("parsing counter-set: %w", err)
)
} }
var counterStep int64 = 1 // Second check whether we do have a template in counter_step and it
if s := attrs.MustString("counter_step", ptrStringEmpty); s != "" { // evaluates into a non-empty string and then adjust the counter
parseStep, err := formatMessage(s, m, r, eventData) // accordingly
if err != nil { counterStep, err := a.parseAttributeTemplateToNumber(m, r, eventData, attrs, "counter_step", 1)
return false, errors.Wrap(err, "execute counter step template") switch {
case err == nil, errors.Is(err, errNotAValue):
// Either got a value or there was none, therefore the default was
// returned which is 1 and we can apply this
if err = updateCounter(db, counterName, counterStep, false, time.Now()); err != nil {
return false, fmt.Errorf("updating counter: %w", err)
} }
return false, nil
counterStep, err = strconv.ParseInt(parseStep, 10, 64) default:
if err != nil { // B0rked
return false, errors.Wrap(err, "parse counter step") return false, fmt.Errorf("parsing counter-step: %w", err)
}
} }
return false, errors.Wrap(
updateCounter(db, counterName, counterStep, false),
"update counter",
)
} }
func (actorCounter) IsAsync() bool { return false } func (actorCounter) IsAsync() bool { return false }
func (actorCounter) Name() string { return "counter" } func (actorCounter) Name() string { return "counter" }
func (actorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if cn, err := attrs.String("counter"); err != nil || cn == "" { if err = attrs.ValidateSchema(
return errors.New("counter name must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "counter", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "counter_step", Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "counter_set", Type: fieldcollection.SchemaFieldTypeString}),
for _, field := range []string{"counter", "counter_step", "counter_set"} { fieldcollection.MustHaveNoUnknowFields,
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { helpers.SchemaValidateTemplateField(tplValidator, "counter", "counter_step", "counter_set"),
return errors.Wrapf(err, "validating %s template", field) ); err != nil {
} return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil
} }
func (actorCounter) parseAttributeTemplateToNumber(
m *irc.Message,
r *plugins.Rule,
eventData *fieldcollection.FieldCollection,
attrs *fieldcollection.FieldCollection,
field string,
defaultValue int64,
) (v int64, err error) {
// Get the string
sv, err := attrs.String(field)
switch {
case err == nil:
// We got a string and continue below
case errors.Is(err, fieldcollection.ErrValueNotSet):
// That's fine, the string is not available, we report that and
// return the default value
return defaultValue, errNotAValue
default:
// Not sure what brought us here but we should report that
return defaultValue, fmt.Errorf("getting string value: %w", err)
}
// Now we need to evaluate the template
sv, err = formatMessage(sv, m, r, eventData)
if err != nil {
return defaultValue, fmt.Errorf("executing template: %w", err)
}
// The template evaluated into an empty string, we don't try to
// parse that and report it as a missing value with default
if sv == "" {
return defaultValue, errNotAValue
}
// The template was not empty, we need to parse the resulting int
// and return it
v, err = strconv.ParseInt(sv, 10, 64)
if err != nil {
return defaultValue, fmt.Errorf("parsing to int: %w", err)
}
return v, nil
}
func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) { func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
template := r.FormValue("template") template := r.FormValue("template")
if template == "" { if template == "" {
@ -299,7 +351,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
return return
} }
if err = updateCounter(db, mux.Vars(r)["name"], value, absolute); err != nil { if err = updateCounter(db, mux.Vars(r)["name"], value, absolute, time.Now()); err != nil {
http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError)
return return
} }

View file

@ -1,18 +1,25 @@
package counter package counter
import ( import (
"fmt"
"strings"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
) )
type ( type (
counter struct { counter struct {
Name string `gorm:"primaryKey"` Name string `gorm:"primaryKey"`
Value int64 Value int64
FirstSeen time.Time
LastModified time.Time
} }
) )
@ -32,7 +39,7 @@ func getCounterValue(db database.Connector, counterName string) (int64, error) {
} }
//revive:disable-next-line:flag-parameter //revive:disable-next-line:flag-parameter
func updateCounter(db database.Connector, counterName string, value int64, absolute bool) error { func updateCounter(db database.Connector, counterName string, value int64, absolute bool, atTime time.Time) error {
if !absolute { if !absolute {
cv, err := getCounterValue(db, counterName) cv, err := getCounterValue(db, counterName)
if err != nil { if err != nil {
@ -46,8 +53,8 @@ func updateCounter(db database.Connector, counterName string, value int64, absol
helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error { helpers.RetryTransaction(db.DB(), func(tx *gorm.DB) error {
return tx.Clauses(clause.OnConflict{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}}, Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}), DoUpdates: clause.AssignmentColumns([]string{"last_modified", "value"}),
}).Create(counter{Name: counterName, Value: value}).Error }).Create(counter{Name: counterName, Value: value, FirstSeen: atTime.UTC(), LastModified: atTime.UTC()}).Error
}), }),
"storing counter value", "storing counter value",
) )
@ -75,12 +82,39 @@ func getCounterRank(db database.Connector, prefix, name string) (rank, count int
return rank, count, nil return rank, count, nil
} }
func getCounterTopList(db database.Connector, prefix string, n int) ([]counter, error) { func getCounterTopList(db database.Connector, prefix string, n int, orderBy ...string) ([]counter, error) {
var cc []counter var (
cc []counter
order string
validOrderCols = []string{"first_seen", "last_modified", "name", "value"}
validOrderDirs = []string{"ASC", "DESC"}
)
if len(orderBy) == 0 || orderBy[0] == "" {
order = "value DESC"
} else {
order = orderBy[0]
}
col, dir, _ := strings.Cut(order, " ")
if col == "" {
col = "value"
}
if dir == "" {
dir = "ASC"
}
if !str.StringInSlice(col, validOrderCols) {
return nil, fmt.Errorf("invalid orderBy column")
}
if !str.StringInSlice(dir, validOrderDirs) {
return nil, fmt.Errorf("invalid orderBy direction")
}
err := helpers.Retry(func() error { err := helpers.Retry(func() error {
return db.DB(). return db.DB().
Order("value DESC"). Order(strings.Join([]string{col, dir}, " ")).
Limit(n). Limit(n).
Find(&cc, "name LIKE ?", prefix+"%"). Find(&cc, "name LIKE ?", prefix+"%").
Error Error

View file

@ -3,6 +3,7 @@ package counter
import ( import (
"fmt" "fmt"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -20,12 +21,19 @@ func TestCounterStoreLoop(t *testing.T) {
assert.NoError(t, err, "reading non-existent counter") assert.NoError(t, err, "reading non-existent counter")
assert.Equal(t, int64(0), v, "expecting 0 counter value on non-existent counter") assert.Equal(t, int64(0), v, "expecting 0 counter value on non-existent counter")
err = updateCounter(dbc, counterName, 5, true) err = updateCounter(dbc, counterName, 5, true, time.Now())
assert.NoError(t, err, "inserting counter") assert.NoError(t, err, "inserting counter")
err = updateCounter(dbc, counterName, 1, false) var rawCounter counter
assert.NoError(t, dbc.DB().First(&rawCounter, "name = ?", counterName).Error)
assert.Equal(t, rawCounter.FirstSeen, rawCounter.LastModified)
err = updateCounter(dbc, counterName, 1, false, time.Now())
assert.NoError(t, err, "updating counter") assert.NoError(t, err, "updating counter")
assert.NoError(t, dbc.DB().First(&rawCounter, "name = ?", counterName).Error)
assert.NotEqual(t, rawCounter.FirstSeen, rawCounter.LastModified)
v, err = getCounterValue(dbc, counterName) v, err = getCounterValue(dbc, counterName)
assert.NoError(t, err, "reading existent counter") assert.NoError(t, err, "reading existent counter")
assert.Equal(t, int64(6), v, "expecting counter value on existing counter") assert.Equal(t, int64(6), v, "expecting counter value on existing counter")
@ -35,11 +43,13 @@ func TestCounterTopListAndRank(t *testing.T) {
dbc := database.GetTestDatabase(t) dbc := database.GetTestDatabase(t)
require.NoError(t, dbc.DB().AutoMigrate(&counter{})) require.NoError(t, dbc.DB().AutoMigrate(&counter{}))
testTime := time.Now().UTC()
counterTemplate := `#example:test:%v` counterTemplate := `#example:test:%v`
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
require.NoError( require.NoError(
t, t,
updateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true), updateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true, testTime),
"inserting counter %d", i, "inserting counter %d", i,
) )
} }
@ -49,11 +59,40 @@ func TestCounterTopListAndRank(t *testing.T) {
assert.Len(t, cc, 3) assert.Len(t, cc, 3)
assert.Equal(t, []counter{ assert.Equal(t, []counter{
{Name: "#example:test:5", Value: 5}, {Name: "#example:test:5", Value: 5, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:4", Value: 4}, {Name: "#example:test:4", Value: 4, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:3", Value: 3}, {Name: "#example:test:3", Value: 3, FirstSeen: testTime, LastModified: testTime},
}, cc) }, cc)
cc, err = getCounterTopList(dbc, fmt.Sprintf(counterTemplate, ""), 3, "name DESC")
require.NoError(t, err)
assert.Len(t, cc, 3)
assert.Equal(t, []counter{
{Name: "#example:test:5", Value: 5, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:4", Value: 4, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:3", Value: 3, FirstSeen: testTime, LastModified: testTime},
}, cc)
cc, err = getCounterTopList(dbc, fmt.Sprintf(counterTemplate, ""), 3, "name")
require.NoError(t, err)
assert.Len(t, cc, 3)
assert.Equal(t, []counter{
{Name: "#example:test:0", Value: 0, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:1", Value: 1, FirstSeen: testTime, LastModified: testTime},
{Name: "#example:test:2", Value: 2, FirstSeen: testTime, LastModified: testTime},
}, cc)
_, err = getCounterTopList(dbc, fmt.Sprintf(counterTemplate, ""), 3, "foobar")
assert.Error(t, err)
_, err = getCounterTopList(dbc, fmt.Sprintf(counterTemplate, ""), 3, "name foo")
assert.Error(t, err)
_, err = getCounterTopList(dbc, fmt.Sprintf(counterTemplate, ""), 3, "name ASC; DROP TABLE counters;")
assert.Error(t, err)
rank, count, err := getCounterRank(dbc, rank, count, err := getCounterRank(dbc,
fmt.Sprintf(counterTemplate, ""), fmt.Sprintf(counterTemplate, ""),
fmt.Sprintf(counterTemplate, 4)) fmt.Sprintf(counterTemplate, 4))

View file

@ -2,11 +2,14 @@
package delay package delay
import ( import (
"fmt"
"math/rand" "math/rand"
"time" "time"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -48,11 +51,10 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var ( var (
ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0) delay = attrs.MustDuration("delay", helpers.Ptr(time.Duration(0)))
delay = attrs.MustDuration("delay", ptrZeroDuration) jitter = attrs.MustDuration("jitter", helpers.Ptr(time.Duration(0)))
jitter = attrs.MustDuration("jitter", ptrZeroDuration)
) )
if delay == 0 && jitter == 0 { if delay == 0 && jitter == 0 {
@ -71,6 +73,13 @@ func (actor) Execute(_ *irc.Client, _ *irc.Message, _ *plugins.Rule, _ *plugins.
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) { func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "delay", Type: fieldcollection.SchemaFieldTypeDuration}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "jitter", Type: fieldcollection.SchemaFieldTypeDuration}),
fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil return nil
} }

View file

@ -7,17 +7,18 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const actorName = "delete" const actorName = "delete"
var botTwitchClient *twitch.Client var botTwitchClient func() *twitch.Client
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -32,14 +33,14 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, _ *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *fieldcollection.FieldCollection, _ *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
msgID, ok := m.Tags["id"] msgID, ok := m.Tags["id"]
if !ok || msgID == "" { if !ok || msgID == "" {
return false, nil return false, nil
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.DeleteMessage( botTwitchClient().DeleteMessage(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
@ -51,6 +52,6 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) (err error) { func (actor) Validate(plugins.TemplateValidatorFunc, *fieldcollection.FieldCollection) (err error) {
return nil return nil
} }

View file

@ -4,10 +4,13 @@ package eventmod
import ( import (
"encoding/json" "encoding/json"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -44,7 +47,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrStringEmpty := func(v string) *string { return &v }("") ptrStringEmpty := func(v string) *string { return &v }("")
fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData) fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData)
@ -69,14 +72,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
fieldsTemplate, err := attrs.String("fields") if err = attrs.ValidateSchema(
if err != nil || fieldsTemplate == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "fields", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("fields must be non-empty string") fieldcollection.MustHaveNoUnknowFields,
} helpers.SchemaValidateTemplateField(tplValidator, "fields"),
); err != nil {
if err = tplValidator(fieldsTemplate); err != nil { return fmt.Errorf("validating attributes: %w", err)
return errors.Wrap(err, "validating fields template")
} }
return nil return nil

View file

@ -5,6 +5,7 @@ package filesay
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@ -13,6 +14,8 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -57,7 +60,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrStringEmpty := func(v string) *string { return &v }("") ptrStringEmpty := func(v string) *string { return &v }("")
source, err := formatMessage(attrs.MustString("source", ptrStringEmpty), m, r, eventData) source, err := formatMessage(attrs.MustString("source", ptrStringEmpty), m, r, eventData)
@ -114,14 +117,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return true } func (actor) IsAsync() bool { return true }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
sourceTpl, err := attrs.String("source") if err = attrs.ValidateSchema(
if err != nil || sourceTpl == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "source", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("source is expected to be non-empty string") fieldcollection.MustHaveNoUnknowFields,
} helpers.SchemaValidateTemplateField(tplValidator, "source"),
); err != nil {
if err = tplValidator(sourceTpl); err != nil { return fmt.Errorf("validating attributes: %w", err)
return errors.Wrap(err, "validating source template")
} }
return nil return nil

View file

@ -3,16 +3,18 @@
package linkdetector package linkdetector
import ( import (
"fmt"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/internal/linkcheck" "github.com/Luzifer/twitch-bot/v3/internal/linkcheck"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const actorName = "linkdetector" const actorName = "linkdetector"
var ptrFalse = func(v bool) *bool { return &v }(false)
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -42,13 +44,13 @@ func Register(args plugins.RegistrationArguments) error {
type Actor struct{} type Actor struct{}
// Execute implements the actor interface // Execute implements the actor interface
func (Actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (Actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
if eventData.HasAll("links") { if eventData.HasAll("links") {
// We already detected links, lets not do it again // We already detected links, lets not do it again
return false, nil return false, nil
} }
if attrs.MustBool("heuristic", ptrFalse) { if attrs.MustBool("heuristic", helpers.Ptr(false)) {
eventData.Set("links", linkcheck.New().HeuristicScanForLinks(m.Trailing())) eventData.Set("links", linkcheck.New().HeuristicScanForLinks(m.Trailing()))
} else { } else {
eventData.Set("links", linkcheck.New().ScanForLinks(m.Trailing())) eventData.Set("links", linkcheck.New().ScanForLinks(m.Trailing()))
@ -64,4 +66,13 @@ func (Actor) IsAsync() bool { return false }
func (Actor) Name() string { return actorName } func (Actor) Name() string { return actorName }
// Validate implements the actor interface // Validate implements the actor interface
func (Actor) Validate(plugins.TemplateValidatorFunc, *plugins.FieldCollection) error { return nil } func (Actor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "heuristic", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil
}

View file

@ -4,6 +4,7 @@ package linkprotect
import ( import (
"context" "context"
"fmt"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -11,7 +12,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector" "github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -19,15 +22,13 @@ import (
const actorName = "linkprotect" const actorName = "linkprotect"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
clipLink = regexp.MustCompile(`.*(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/.*`) clipLink = regexp.MustCompile(`.*(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/.*`)
ptrBoolFalse = func(v bool) *bool { return &v }(false)
ptrStringEmpty = func(v string) *string { return &v }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -127,7 +128,7 @@ const (
) )
//nolint:gocyclo // Minimum over the limit, makes no sense to split //nolint:gocyclo // Minimum over the limit, makes no sense to split
func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
// In case the clip detector did not run before, lets run it now // In case the clip detector did not run before, lets run it now
if preventCooldown, err = (clipdetector.Actor{}).Execute(c, m, r, eventData, attrs); err != nil { if preventCooldown, err = (clipdetector.Actor{}).Execute(c, m, r, eventData, attrs); err != nil {
return preventCooldown, errors.Wrap(err, "detecting links / clips") return preventCooldown, errors.Wrap(err, "detecting links / clips")
@ -141,13 +142,13 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
if len(links) == 0 { if len(links) == 0 {
// If there are no links there is nothing to protect and there // If there are no links there is nothing to protect and there
// are also no clips as they are parsed from the links // are also no clips as they are parsed from the links
if attrs.MustBool("stop_on_no_action", ptrBoolFalse) { if attrs.MustBool("stop_on_no_action", helpers.Ptr(false)) {
return false, plugins.ErrStopRuleExecution return false, plugins.ErrStopRuleExecution
} }
return false, nil return false, nil
} }
clipsInterface, err := eventData.Any("clips") clipsInterface, err := eventData.Get("clips")
if err != nil { if err != nil {
return preventCooldown, errors.Wrap(err, "getting clips from event") return preventCooldown, errors.Wrap(err, "getting clips from event")
} }
@ -157,21 +158,21 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
} }
if a.check(links, clips, attrs) == verdictAllFine { if a.check(links, clips, attrs) == verdictAllFine {
if attrs.MustBool("stop_on_no_action", ptrBoolFalse) { if attrs.MustBool("stop_on_no_action", helpers.Ptr(false)) {
return false, plugins.ErrStopRuleExecution return false, plugins.ErrStopRuleExecution
} }
return false, nil return false, nil
} }
// That message misbehaved so we need to punish them // That message misbehaved so we need to punish them
switch lt := attrs.MustString("action", ptrStringEmpty); lt { switch lt := attrs.MustString("action", helpers.Ptr("")); lt {
case "ban": case "ban":
if err = botTwitchClient.BanUser( if err = botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
0, 0,
attrs.MustString("reason", ptrStringEmpty), attrs.MustString("reason", helpers.Ptr("")),
); err != nil { ); err != nil {
return false, errors.Wrap(err, "executing user ban") return false, errors.Wrap(err, "executing user ban")
} }
@ -182,7 +183,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, errors.New("found no mesage id") return false, errors.New("found no mesage id")
} }
if err = botTwitchClient.DeleteMessage( if err = botTwitchClient().DeleteMessage(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
@ -196,18 +197,18 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
return false, errors.Wrap(err, "parsing punishment level") return false, errors.Wrap(err, "parsing punishment level")
} }
if err = botTwitchClient.BanUser( if err = botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
to, to,
attrs.MustString("reason", ptrStringEmpty), attrs.MustString("reason", helpers.Ptr("")),
); err != nil { ); err != nil {
return false, errors.Wrap(err, "executing user ban") return false, errors.Wrap(err, "executing user ban")
} }
} }
if attrs.MustBool("stop_on_action", ptrBoolFalse) { if attrs.MustBool("stop_on_action", helpers.Ptr(false)) {
return false, plugins.ErrStopRuleExecution return false, plugins.ErrStopRuleExecution
} }
@ -218,41 +219,49 @@ func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error { func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("action"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("action must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "action", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "reason", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "allowed_links", Type: fieldcollection.SchemaFieldTypeStringSlice}),
if v, err := attrs.String("reason"); err != nil || v == "" { fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "disallowed_links", Type: fieldcollection.SchemaFieldTypeStringSlice}),
return errors.New("reason must be non-empty string") fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "allowed_clip_channels", Type: fieldcollection.SchemaFieldTypeStringSlice}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "disallowed_clip_channels", Type: fieldcollection.SchemaFieldTypeStringSlice}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "stop_on_action", Type: fieldcollection.SchemaFieldTypeBool}),
if len(attrs.MustStringSlice("allowed_links"))+ fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "stop_on_no_action", Type: fieldcollection.SchemaFieldTypeBool}),
len(attrs.MustStringSlice("disallowed_links"))+ fieldcollection.MustHaveNoUnknowFields,
len(attrs.MustStringSlice("allowed_clip_channels"))+ func(attrs, _ *fieldcollection.FieldCollection) error {
len(attrs.MustStringSlice("disallowed_clip_channels")) == 0 { if len(attrs.MustStringSlice("allowed_links", helpers.Ptr([]string{})))+
return errors.New("no conditions are provided") len(attrs.MustStringSlice("disallowed_links", helpers.Ptr([]string{})))+
len(attrs.MustStringSlice("allowed_clip_channels", helpers.Ptr([]string{})))+
len(attrs.MustStringSlice("disallowed_clip_channels", helpers.Ptr([]string{}))) == 0 {
return errors.New("no conditions are provided")
}
return nil
},
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil
} }
func (a actor) check(links []string, clips []twitch.ClipInfo, attrs *plugins.FieldCollection) (v verdict) { func (a actor) check(links []string, clips []twitch.ClipInfo, attrs *fieldcollection.FieldCollection) (v verdict) {
hasClipDefinition := len(attrs.MustStringSlice("allowed_clip_channels"))+len(attrs.MustStringSlice("disallowed_clip_channels")) > 0 hasClipDefinition := len(attrs.MustStringSlice("allowed_clip_channels", helpers.Ptr([]string{})))+len(attrs.MustStringSlice("disallowed_clip_channels", helpers.Ptr([]string{}))) > 0
if v = a.checkLinkDenied(attrs.MustStringSlice("disallowed_links"), links, hasClipDefinition); v == verdictMisbehave { if v = a.checkLinkDenied(attrs.MustStringSlice("disallowed_links", helpers.Ptr([]string{})), links, hasClipDefinition); v == verdictMisbehave {
return verdictMisbehave return verdictMisbehave
} }
if v = a.checkAllLinksAllowed(attrs.MustStringSlice("allowed_links"), links, hasClipDefinition); v == verdictMisbehave { if v = a.checkAllLinksAllowed(attrs.MustStringSlice("allowed_links", helpers.Ptr([]string{})), links, hasClipDefinition); v == verdictMisbehave {
return verdictMisbehave return verdictMisbehave
} }
if v = a.checkClipChannelDenied(attrs.MustStringSlice("disallowed_clip_channels"), clips); v == verdictMisbehave { if v = a.checkClipChannelDenied(attrs.MustStringSlice("disallowed_clip_channels", helpers.Ptr([]string{})), clips); v == verdictMisbehave {
return verdictMisbehave return verdictMisbehave
} }
if v = a.checkAllClipChannelsAllowed(attrs.MustStringSlice("allowed_clip_channels"), clips); v == verdictMisbehave { if v = a.checkAllClipChannelsAllowed(attrs.MustStringSlice("allowed_clip_channels", helpers.Ptr([]string{})), clips); v == verdictMisbehave {
return verdictMisbehave return verdictMisbehave
} }

View file

@ -2,18 +2,19 @@
package log package log
import ( import (
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
var ( var formatMessage plugins.MsgFormatter
formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("")
)
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
@ -44,8 +45,8 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
message, err := formatMessage(attrs.MustString("message", ptrStringEmpty), m, r, eventData) message, err := formatMessage(attrs.MustString("message", helpers.Ptr("")), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing message template") return false, errors.Wrap(err, "executing message template")
} }
@ -61,13 +62,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return true } func (actor) IsAsync() bool { return true }
func (actor) Name() string { return "log" } func (actor) Name() string { return "log" }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("message must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "message"),
if err = tplValidator(attrs.MustString("message", ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrap(err, "validating message template") return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -20,12 +20,7 @@ const (
postTimeout = 5 * time.Second postTimeout = 5 * time.Second
) )
var ( var formatMessage plugins.MsgFormatter
formatMessage plugins.MsgFormatter
ptrBoolFalse = func(v bool) *bool { return &v }(false)
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {

View file

@ -7,6 +7,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -50,18 +52,18 @@ type (
} }
) )
func (d discordActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (d discordActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var payload discordPayload var payload discordPayload
if payload.Content, err = formatMessage(attrs.MustString("content", ptrStringEmpty), m, r, eventData); err != nil { if payload.Content, err = formatMessage(attrs.MustString("content", helpers.Ptr("")), m, r, eventData); err != nil {
return false, errors.Wrap(err, "parsing content") return false, errors.Wrap(err, "parsing content")
} }
if payload.Username, err = formatMessage(attrs.MustString("username", ptrStringEmpty), m, r, eventData); err != nil { if payload.Username, err = formatMessage(attrs.MustString("username", helpers.Ptr("")), m, r, eventData); err != nil {
return false, errors.Wrap(err, "parsing username") return false, errors.Wrap(err, "parsing username")
} }
if payload.AvatarURL, err = formatMessage(attrs.MustString("avatar_url", ptrStringEmpty), m, r, eventData); err != nil { if payload.AvatarURL, err = formatMessage(attrs.MustString("avatar_url", helpers.Ptr("")), m, r, eventData); err != nil {
return false, errors.Wrap(err, "parsing avatar_url") return false, errors.Wrap(err, "parsing avatar_url")
} }
@ -69,14 +71,14 @@ func (d discordActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, ev
return false, err return false, err
} }
return sendPayload(attrs.MustString("hook_url", ptrStringEmpty), payload, http.StatusNoContent) return sendPayload(attrs.MustString("hook_url", helpers.Ptr("")), payload, http.StatusNoContent)
} }
func (discordActor) IsAsync() bool { return false } func (discordActor) IsAsync() bool { return false }
func (discordActor) Name() string { return "discordhook" } func (discordActor) Name() string { return "discordhook" }
func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil { if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil {
return err //nolint:wrapcheck return err //nolint:wrapcheck
} }
@ -89,7 +91,7 @@ func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs
return err //nolint:wrapcheck return err //nolint:wrapcheck
} }
if !attrs.MustBool("add_embed", ptrBoolFalse) { if !attrs.MustBool("add_embed", helpers.Ptr(false)) {
// We're not validating the rest if embeds are disabled but in // We're not validating the rest if embeds are disabled but in
// this case the content is mandatory // this case the content is mandatory
return d.ValidateRequireNonEmpty(attrs, "content") //nolint:wrapcheck return d.ValidateRequireNonEmpty(attrs, "content") //nolint:wrapcheck
@ -111,8 +113,8 @@ func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs
} }
//nolint:gocyclo // It's complex but just a bunch of converters //nolint:gocyclo // It's complex but just a bunch of converters
func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (err error) { func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (err error) {
if !attrs.MustBool("add_embed", ptrBoolFalse) { if !attrs.MustBool("add_embed", helpers.Ptr(false)) {
// No embed? No problem! // No embed? No problem!
return nil return nil
} }
@ -122,45 +124,45 @@ func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins
sv string sv string
) )
if embed.Title, err = formatMessage(attrs.MustString("embed_title", ptrStringEmpty), m, r, eventData); err != nil { if embed.Title, err = formatMessage(attrs.MustString("embed_title", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_title") return errors.Wrap(err, "parsing embed_title")
} }
if embed.Description, err = formatMessage(attrs.MustString("embed_description", ptrStringEmpty), m, r, eventData); err != nil { if embed.Description, err = formatMessage(attrs.MustString("embed_description", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_description") return errors.Wrap(err, "parsing embed_description")
} }
if embed.URL, err = formatMessage(attrs.MustString("embed_url", ptrStringEmpty), m, r, eventData); err != nil { if embed.URL, err = formatMessage(attrs.MustString("embed_url", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_url") return errors.Wrap(err, "parsing embed_url")
} }
if sv, err = formatMessage(attrs.MustString("embed_image", ptrStringEmpty), m, r, eventData); err != nil { if sv, err = formatMessage(attrs.MustString("embed_image", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_image") return errors.Wrap(err, "parsing embed_image")
} else if sv != "" { } else if sv != "" {
embed.Image = &discordPayloadEmbedImage{URL: sv} embed.Image = &discordPayloadEmbedImage{URL: sv}
} }
if sv, err = formatMessage(attrs.MustString("embed_thumbnail", ptrStringEmpty), m, r, eventData); err != nil { if sv, err = formatMessage(attrs.MustString("embed_thumbnail", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_thumbnail") return errors.Wrap(err, "parsing embed_thumbnail")
} else if sv != "" { } else if sv != "" {
embed.Thumbnail = &discordPayloadEmbedImage{URL: sv} embed.Thumbnail = &discordPayloadEmbedImage{URL: sv}
} }
if sv, err = formatMessage(attrs.MustString("embed_author_name", ptrStringEmpty), m, r, eventData); err != nil { if sv, err = formatMessage(attrs.MustString("embed_author_name", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_author_name") return errors.Wrap(err, "parsing embed_author_name")
} else if sv != "" { } else if sv != "" {
embed.Author = &discordPayloadEmbedAuthor{Name: sv} embed.Author = &discordPayloadEmbedAuthor{Name: sv}
if embed.Author.URL, err = formatMessage(attrs.MustString("embed_author_url", ptrStringEmpty), m, r, eventData); err != nil { if embed.Author.URL, err = formatMessage(attrs.MustString("embed_author_url", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_author_url") return errors.Wrap(err, "parsing embed_author_url")
} }
if embed.Author.IconURL, err = formatMessage(attrs.MustString("embed_author_icon_url", ptrStringEmpty), m, r, eventData); err != nil { if embed.Author.IconURL, err = formatMessage(attrs.MustString("embed_author_icon_url", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_author_icon_url") return errors.Wrap(err, "parsing embed_author_icon_url")
} }
} }
if sv, err = formatMessage(attrs.MustString("embed_fields", ptrStringEmpty), m, r, eventData); err != nil { if sv, err = formatMessage(attrs.MustString("embed_fields", helpers.Ptr("")), m, r, eventData); err != nil {
return errors.Wrap(err, "parsing embed_fields") return errors.Wrap(err, "parsing embed_fields")
} else if sv != "" { } else if sv != "" {
var flds []discordPayloadEmbedField var flds []discordPayloadEmbedField

View file

@ -7,6 +7,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -14,14 +16,14 @@ type slackCompatibleActor struct {
plugins.ActorKit plugins.ActorKit
} }
func (s slackCompatibleActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (s slackCompatibleActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
text, err := formatMessage(attrs.MustString("text", nil), m, r, eventData) text, err := formatMessage(attrs.MustString("text", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "parsing text") return false, errors.Wrap(err, "parsing text")
} }
return sendPayload( return sendPayload(
s.fixHookURL(attrs.MustString("hook_url", ptrStringEmpty)), s.fixHookURL(attrs.MustString("hook_url", helpers.Ptr(""))),
map[string]string{ map[string]string{
"text": text, "text": text,
}, },
@ -33,7 +35,7 @@ func (slackCompatibleActor) IsAsync() bool { return false }
func (slackCompatibleActor) Name() string { return "slackhook" } func (slackCompatibleActor) Name() string { return "slackhook" }
func (s slackCompatibleActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (s slackCompatibleActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil { if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil {
return err //nolint:wrapcheck return err //nolint:wrapcheck
} }

View file

@ -4,11 +4,14 @@ package modchannel
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -18,8 +21,6 @@ const actorName = "modchannel"
var ( var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
tcGetter func(string) (*twitch.Client, error) tcGetter func(string) (*twitch.Client, error)
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -70,10 +71,10 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var ( var (
game = attrs.MustString("game", ptrStringEmpty) game = attrs.MustString("game", helpers.Ptr(""))
title = attrs.MustString("title", ptrStringEmpty) title = attrs.MustString("title", helpers.Ptr(""))
) )
if game == "" && title == "" { if game == "" && title == "" {
@ -119,15 +120,15 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("channel"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("channel must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "game", Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "title", Type: fieldcollection.SchemaFieldTypeString}),
for _, field := range []string{"channel", "game", "title"} { fieldcollection.MustHaveNoUnknowFields,
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { helpers.SchemaValidateTemplateField(tplValidator, "channel", "game", "title"),
return errors.Wrapf(err, "validating %s template", field) ); err != nil {
} return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -14,7 +14,7 @@ type (
func actionBan(channel, match, _, user string) error { func actionBan(channel, match, _, user string) error {
return errors.Wrap( return errors.Wrap(
botTwitchClient.BanUser( botTwitchClient().BanUser(
context.Background(), context.Background(),
channel, channel,
user, user,
@ -27,7 +27,7 @@ func actionBan(channel, match, _, user string) error {
func actionDelete(channel, _, msgid, _ string) (err error) { func actionDelete(channel, _, msgid, _ string) (err error) {
return errors.Wrap( return errors.Wrap(
botTwitchClient.DeleteMessage( botTwitchClient().DeleteMessage(
context.Background(), context.Background(),
channel, channel,
msgid, msgid,
@ -39,7 +39,7 @@ func actionDelete(channel, _, msgid, _ string) (err error) {
func getActionTimeout(duration time.Duration) actionFn { func getActionTimeout(duration time.Duration) actionFn {
return func(channel, match, _, user string) error { return func(channel, match, _, user string) error {
return errors.Wrap( return errors.Wrap(
botTwitchClient.BanUser( botTwitchClient().BanUser(
context.Background(), context.Background(),
channel, channel,
user, user,

View file

@ -4,6 +4,7 @@
package nuke package nuke
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -13,7 +14,9 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -24,20 +27,16 @@ const (
) )
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
messageStore = map[string][]*storedMessage{} messageStore = map[string][]*storedMessage{}
messageStoreLock sync.RWMutex messageStoreLock sync.RWMutex
ptrStringDelete = func(v string) *string { return &v }("delete")
ptrStringEmpty = func(s string) *string { return &s }("")
ptrString10m = func(v string) *string { return &v }("10m")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -150,14 +149,14 @@ type (
} }
) )
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
rawMatch, err := formatMessage(attrs.MustString("match", nil), m, r, eventData) rawMatch, err := formatMessage(attrs.MustString("match", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting match") return false, errors.Wrap(err, "formatting match")
} }
match := regexp.MustCompile(rawMatch) match := regexp.MustCompile(rawMatch)
rawScan, err := formatMessage(attrs.MustString("scan", ptrString10m), m, r, eventData) rawScan, err := formatMessage(attrs.MustString("scan", helpers.Ptr("10m")), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting scan duration") return false, errors.Wrap(err, "formatting scan duration")
} }
@ -171,7 +170,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
action actionFn action actionFn
actionName string actionName string
) )
rawAction, err := formatMessage(attrs.MustString("action", ptrStringDelete), m, r, eventData) rawAction, err := formatMessage(attrs.MustString("action", helpers.Ptr("delete")), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting action") return false, errors.Wrap(err, "formatting action")
} }
@ -235,15 +234,15 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("match"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("match must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "match", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "action", Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "scan", Type: fieldcollection.SchemaFieldTypeString}),
for _, field := range []string{"scan", "action", "match"} { fieldcollection.MustHaveNoUnknowFields,
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { helpers.SchemaValidateTemplateField(tplValidator, "scan", "action", "match"),
return errors.Wrapf(err, "validating %s template", field) ); err != nil {
} return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -4,6 +4,7 @@ package punish
import ( import (
"context" "context"
"fmt"
"math" "math"
"strings" "strings"
"time" "time"
@ -12,6 +13,8 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -25,11 +28,9 @@ const (
) )
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
db database.Connector db database.Connector
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrDefaultCooldown = func(v time.Duration) *time.Duration { return &v }(oneWeek)
ptrStringEmpty = func(v string) *string { return &v }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -43,7 +44,7 @@ func Register(args plugins.RegistrationArguments) error {
return database.CopyObjects(src, target, &punishLevel{}) return database.CopyObjects(src, target, &punishLevel{})
}) })
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorNamePunish, func() plugins.Actor { return &actorPunish{} }) args.RegisterActor(actorNamePunish, func() plugins.Actor { return &actorPunish{} })
@ -146,12 +147,12 @@ type (
// Punish // Punish
func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var ( var (
cooldown = attrs.MustDuration("cooldown", ptrDefaultCooldown) cooldown = attrs.MustDuration("cooldown", helpers.Ptr(oneWeek))
reason = attrs.MustString("reason", ptrStringEmpty) reason = attrs.MustString("reason", helpers.Ptr(""))
user = attrs.MustString("user", nil) user = attrs.MustString("user", nil)
uuid = attrs.MustString("uuid", ptrStringEmpty) uuid = attrs.MustString("uuid", helpers.Ptr(""))
) )
levels, err := attrs.StringSlice("levels") levels, err := attrs.StringSlice("levels")
@ -171,7 +172,7 @@ func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, event
switch lt := levels[nLvl]; lt { switch lt := levels[nLvl]; lt {
case "ban": case "ban":
if err = botTwitchClient.BanUser( if err = botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"), strings.TrimLeft(user, "@"),
@ -187,7 +188,7 @@ func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, event
return false, errors.New("found no mesage id") return false, errors.New("found no mesage id")
} }
if err = botTwitchClient.DeleteMessage( if err = botTwitchClient().DeleteMessage(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
msgID, msgID,
@ -201,7 +202,7 @@ func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, event
return false, errors.Wrap(err, "parsing punishment level") return false, errors.Wrap(err, "parsing punishment level")
} }
if err = botTwitchClient.BanUser( if err = botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.TrimLeft(user, "@"), strings.TrimLeft(user, "@"),
@ -225,17 +226,17 @@ func (actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, event
func (actorPunish) IsAsync() bool { return false } func (actorPunish) IsAsync() bool { return false }
func (actorPunish) Name() string { return actorNamePunish } func (actorPunish) Name() string { return actorNamePunish }
func (actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("user must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "levels", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeStringSlice}),
} fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "cooldown", Type: fieldcollection.SchemaFieldTypeDuration}),
if v, err := attrs.StringSlice("levels"); err != nil || len(v) == 0 { fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "reason", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("levels must be slice of strings with length > 0") fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "uuid", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "user"),
if err = tplValidator(attrs.MustString("user", ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrap(err, "validating user template") return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil
@ -243,10 +244,10 @@ func (actorPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *p
// Reset // Reset
func (actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var ( var (
user = attrs.MustString("user", nil) user = attrs.MustString("user", nil)
uuid = attrs.MustString("uuid", ptrStringEmpty) uuid = attrs.MustString("uuid", helpers.Ptr(""))
) )
if user, err = formatMessage(user, m, r, eventData); err != nil { if user, err = formatMessage(user, m, r, eventData); err != nil {
@ -262,13 +263,14 @@ func (actorResetPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule,
func (actorResetPunish) IsAsync() bool { return false } func (actorResetPunish) IsAsync() bool { return false }
func (actorResetPunish) Name() string { return actorNameResetPunish } func (actorResetPunish) Name() string { return actorNameResetPunish }
func (actorResetPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorResetPunish) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("user must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "uuid", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
if err = tplValidator(attrs.MustString("user", ptrStringEmpty)); err != nil { helpers.SchemaValidateTemplateField(tplValidator, "user"),
return errors.Wrap(err, "validating user template") ); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -10,6 +10,8 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -23,9 +25,9 @@ var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc send plugins.SendMessageFunc
ptrStringEmpty = func(v string) *string { return &v }("") // ptrStringEmpty = func(v string) *string { return &v }("")
ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}") // ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}")
ptrStringZero = func(v string) *string { return &v }("0") // ptrStringZero = func(v string) *string { return &v }("0")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -93,7 +95,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
return fmt.Errorf("registering API: %w", err) return fmt.Errorf("registering API: %w", err)
} }
args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, _ *plugins.Rule, _ *plugins.FieldCollection) interface{} { args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, _ *plugins.Rule, _ *fieldcollection.FieldCollection) interface{} {
return func() (int, error) { return func() (int, error) {
return getMaxQuoteIdx(db, plugins.DeriveChannel(m, nil)) return getMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
} }
@ -113,11 +115,11 @@ type (
actor struct{} actor struct{}
) )
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
var ( var (
action = attrs.MustString("action", ptrStringEmpty) action = attrs.MustString("action", helpers.Ptr(""))
indexStr = attrs.MustString("index", ptrStringZero) indexStr = attrs.MustString("index", helpers.Ptr("0"))
quote = attrs.MustString("quote", ptrStringEmpty) quote = attrs.MustString("quote", helpers.Ptr(""))
) )
if indexStr == "" { if indexStr == "" {
@ -166,7 +168,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
fields.Set("index", idx) fields.Set("index", idx)
fields.Set("quote", quote) fields.Set("quote", quote)
format := attrs.MustString("format", ptrStringOutFormat) format := attrs.MustString("format", helpers.Ptr("Quote #{{ .index }}: {{ .quote }}"))
msg, err := formatMessage(format, m, r, fields) msg, err := formatMessage(format, m, r, fields)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting output message") return false, errors.Wrap(err, "formatting output message")
@ -190,31 +192,36 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
action := attrs.MustString("action", ptrStringEmpty) if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "action", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "quote", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "index", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "format", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "index", "quote", "format"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
action := attrs.MustString("action", helpers.Ptr(""))
switch action { switch action {
case "add": case "add":
if v, err := attrs.String("quote"); err != nil || v == "" { if v, err := attrs.String("quote"); err != nil || v == "" {
return errors.New("quote must be non-empty string for action add") return fmt.Errorf("quote must be non-empty string for action add")
} }
case "del": case "del":
if v, err := attrs.String("index"); err != nil || v == "" { if v, err := attrs.String("index"); err != nil || v == "" {
return errors.New("index must be non-empty string for adction del") return fmt.Errorf("index must be non-empty string for adction del")
} }
case "get": case "get":
// No requirements // No requirements
default: default:
return errors.New("action must be one of add, del or get") return fmt.Errorf("action must be one of add, del or get")
}
for _, field := range []string{"index", "quote", "format"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
} }
return nil return nil

View file

@ -2,9 +2,13 @@
package raw package raw
import ( import (
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -13,8 +17,6 @@ const actorName = "raw"
var ( var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc send plugins.SendMessageFunc
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -47,7 +49,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData) rawMsg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing raw message") return false, errors.Wrap(err, "preparing raw message")
@ -67,13 +69,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("message must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "message"),
if err = tplValidator(attrs.MustString("message", ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrap(err, "validating message template") return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -20,9 +22,6 @@ const actorName = "respond"
var ( var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc send plugins.SendMessageFunc
ptrBoolFalse = func(v bool) *bool { return &v }(false)
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -102,7 +101,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData) msg, err := formatMessage(attrs.MustString("message", nil), m, r, eventData)
if err != nil { if err != nil {
if !attrs.CanString("fallback") || attrs.MustString("fallback", nil) == "" { if !attrs.CanString("fallback") || attrs.MustString("fallback", nil) == "" {
@ -127,7 +126,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
}, },
} }
if attrs.MustBool("as_reply", ptrBoolFalse) { if attrs.MustBool("as_reply", helpers.Ptr(false)) {
id, ok := m.Tags["id"] id, ok := m.Tags["id"]
if ok { if ok {
if ircMessage.Tags == nil { if ircMessage.Tags == nil {
@ -146,15 +145,16 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("message must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "fallback", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "as_reply", Type: fieldcollection.SchemaFieldTypeBool}),
for _, field := range []string{"message", "fallback"} { fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "to_channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { fieldcollection.MustHaveNoUnknowFields,
return errors.Wrapf(err, "validating %s template", field) helpers.SchemaValidateTemplateField(tplValidator, "message", "fallback"),
} ); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -4,21 +4,24 @@ package shield
import ( import (
"context" "context"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
const actorName = "shield" const actorName = "shield"
var botTwitchClient *twitch.Client var botTwitchClient func() *twitch.Client
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -45,14 +48,12 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrBoolFalse := func(v bool) *bool { return &v }(false)
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.UpdateShieldMode( botTwitchClient().UpdateShieldMode(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
attrs.MustBool("enable", ptrBoolFalse), attrs.MustBool("enable", helpers.Ptr(false)),
), ),
"configuring shield mode", "configuring shield mode",
) )
@ -61,9 +62,12 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if _, err = attrs.Bool("enable"); err != nil { if err = attrs.ValidateSchema(
return errors.New("enable must be boolean") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "enable", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -4,11 +4,14 @@ package shoutout
import ( import (
"context" "context"
"fmt"
"regexp" "regexp"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -16,7 +19,7 @@ import (
const actorName = "shoutout" const actorName = "shoutout"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
@ -25,7 +28,7 @@ var (
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -55,14 +58,14 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
user, err := formatMessage(attrs.MustString("user", ptrStringEmpty), m, r, eventData) user, err := formatMessage(attrs.MustString("user", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing user template") return false, errors.Wrap(err, "executing user template")
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.SendShoutout( botTwitchClient().SendShoutout(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
user, user,
@ -74,13 +77,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("user must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "user"),
if err = tplValidator(attrs.MustString("user", ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrap(err, "validating user template") return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil
@ -94,7 +97,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.New("shoutout message does not match required format") return errors.New("shoutout message does not match required format")
} }
if err := botTwitchClient.SendShoutout(context.Background(), channel, matches[1]); err != nil { if err := botTwitchClient().SendShoutout(context.Background(), channel, matches[1]); err != nil {
return errors.Wrap(err, "executing shoutout") return errors.Wrap(err, "executing shoutout")
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
@ -24,6 +25,12 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
return track, fmt.Errorf("loading oauth token: %w", err) return track, fmt.Errorf("loading oauth token: %w", err)
} }
defer func() {
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
logrus.WithError(err).Error("storing back Spotify auth token")
}
}()
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout) ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
defer cancel() defer cancel()
@ -42,14 +49,34 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
} }
}() }()
defer func() { body, err := io.ReadAll(resp.Body)
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil { if err != nil {
logrus.WithError(err).Error("storing back Spotify auth token") return track, fmt.Errorf("reading response body: %w", err)
} }
}()
if err = json.NewDecoder(resp.Body).Decode(&track); err != nil { switch resp.StatusCode {
return track, fmt.Errorf("decoding response: %w", err) case http.StatusOK:
// This is perfect, continue below
case http.StatusUnauthorized:
// The token is FUBAR
return track, fmt.Errorf("token expired (HTTP 401 - unauthorized)")
case http.StatusForbidden:
// The request is FUBAR
return track, fmt.Errorf("bad oAuth request, report this to dev (HTTP 403 - forbidden): %q", body)
case http.StatusTooManyRequests:
// We asked too often
return track, fmt.Errorf("rate-limited (HTTP 429 - too many requests)")
default:
// WTF?
return track, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
}
if err = json.Unmarshal(body, &track); err != nil {
return track, fmt.Errorf("decoding response (%q): %w", body, err)
} }
return track, nil return track, nil

View file

@ -2,21 +2,33 @@ package spotify
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/gofrs/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
const spotifyRequestTimeout = 2 * time.Second const (
spotifyRequestTimeout = 2 * time.Second
pkcePBKDFIter = 210000
pkcePBKDFLen = 64
)
var instanceSalt = uuid.Must(uuid.NewV4()).String()
func handleStartAuth(w http.ResponseWriter, r *http.Request) { func handleStartAuth(w http.ResponseWriter, r *http.Request) {
channel := mux.Vars(r)["channel"] channel := mux.Vars(r)["channel"]
pkceVerifier := hex.EncodeToString(pbkdf2.Key([]byte(channel), []byte(instanceSalt), pkcePBKDFIter, pkcePBKDFLen, sha512.New))
redirURL := baseURL.ResolveReference(&url.URL{Path: r.URL.Path}) redirURL := baseURL.ResolveReference(&url.URL{Path: r.URL.Path})
conf, err := oauthConfig(channel, strings.Split(redirURL.String(), "?")[0]) conf, err := oauthConfig(channel, strings.Split(redirURL.String(), "?")[0])
@ -30,13 +42,20 @@ func handleStartAuth(w http.ResponseWriter, r *http.Request) {
if code == "" { if code == "" {
http.Redirect( http.Redirect(
w, r, w, r,
conf.AuthCodeURL(fmt.Sprintf("%x", sha256.Sum256(append([]byte(conf.ClientID), []byte(channel)...)))), conf.AuthCodeURL(
fmt.Sprintf("%x", sha256.Sum256(append([]byte(conf.ClientID), []byte(channel)...))),
oauth2.S256ChallengeOption(pkceVerifier),
),
http.StatusFound, http.StatusFound,
) )
return return
} }
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code")) token, err := conf.Exchange(
r.Context(),
r.URL.Query().Get("code"),
oauth2.VerifierOption(pkceVerifier),
)
if err != nil { if err != nil {
logrus.WithError(err).Error("getting Spotify oauth token") logrus.WithError(err).Error("getting Spotify oauth token")
http.Error(w, "unable to get Spotify auth token", http.StatusInternalServerError) http.Error(w, "unable to get Spotify auth token", http.StatusInternalServerError)
@ -58,14 +77,8 @@ func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) {
return nil, fmt.Errorf("getting clientId for channel: %w", err) return nil, fmt.Errorf("getting clientId for channel: %w", err)
} }
clientSecret, err := getModuleConfig(actorName, channel).String("clientSecret")
if err != nil {
return nil, fmt.Errorf("getting clientSecret for channel: %w", err)
}
return &oauth2.Config{ return &oauth2.Config{
ClientID: clientID, ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.spotify.com/authorize", AuthURL: "https://accounts.spotify.com/authorize",
TokenURL: "https://accounts.spotify.com/api/token", TokenURL: "https://accounts.spotify.com/api/token",

View file

@ -3,9 +3,13 @@
package stopexec package stopexec
import ( import (
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -42,10 +46,8 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
ptrStringEmpty := func(v string) *string { return &v }("") when, err := formatMessage(attrs.MustString("when", helpers.Ptr("")), m, r, eventData)
when, err := formatMessage(attrs.MustString("when", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing when template") return false, errors.Wrap(err, "executing when template")
} }
@ -60,14 +62,13 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
whenTemplate, err := attrs.String("when") if err = attrs.ValidateSchema(
if err != nil || whenTemplate == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "when", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("when must be non-empty string") fieldcollection.MustHaveNoUnknowFields,
} helpers.SchemaValidateTemplateField(tplValidator, "when"),
); err != nil {
if err = tplValidator(whenTemplate); err != nil { return fmt.Errorf("validating attributes: %w", err)
return errors.Wrap(err, "validating when template")
} }
return nil return nil

View file

@ -3,6 +3,7 @@ package timeout
import ( import (
"context" "context"
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
@ -10,6 +11,8 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -17,7 +20,7 @@ import (
const actorName = "timeout" const actorName = "timeout"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
@ -26,7 +29,7 @@ var (
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -65,14 +68,14 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData) reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing reason template") return false, errors.Wrap(err, "executing reason template")
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.BanUser( botTwitchClient().BanUser(
context.Background(), context.Background(),
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData), plugins.DeriveUser(m, eventData),
@ -86,17 +89,18 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.Duration("duration"); err != nil || v < time.Second { if err = attrs.ValidateSchema(
return errors.New("duration must be of type duration greater or equal one second") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "duration", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeDuration}),
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "reason", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "reason"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
if v, err := attrs.String("reason"); err != nil || v == "" { if attrs.MustDuration("duration", nil) < time.Second {
return errors.New("reason must be non-empty string") return errors.New("duration must be greater or equal one second")
}
if err = tplValidator(attrs.MustString("reason", ptrStringEmpty)); err != nil {
return errors.Wrap(err, "validating reason template")
} }
return nil return nil
@ -115,7 +119,7 @@ func handleChatCommand(m *irc.Message) error {
return errors.Wrap(err, "parsing timeout duration") return errors.Wrap(err, "parsing timeout duration")
} }
if err = botTwitchClient.BanUser(context.Background(), channel, matches[1], time.Duration(duration)*time.Second, matches[3]); err != nil { if err = botTwitchClient().BanUser(context.Background(), channel, matches[1], time.Duration(duration)*time.Second, matches[3]); err != nil {
return errors.Wrap(err, "executing timeout") return errors.Wrap(err, "executing timeout")
} }

View file

@ -11,6 +11,8 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -144,7 +146,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
type actorSetVariable struct{} type actorSetVariable struct{}
func (actorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData) varName, err := formatMessage(attrs.MustString("variable", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing variable name") return false, errors.Wrap(err, "preparing variable name")
@ -171,15 +173,15 @@ func (actorSetVariable) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule,
func (actorSetVariable) IsAsync() bool { return false } func (actorSetVariable) IsAsync() bool { return false }
func (actorSetVariable) Name() string { return "setvariable" } func (actorSetVariable) Name() string { return "setvariable" }
func (actorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("variable"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("variable name must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "variable", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "clear", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "set", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
for _, field := range []string{"set", "variable"} { fieldcollection.MustHaveNoUnknowFields,
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { helpers.SchemaValidateTemplateField(tplValidator, "set", "variable"),
return errors.Wrapf(err, "validating %s template", field) ); err != nil {
} return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -3,11 +3,14 @@ package vip
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -16,8 +19,6 @@ var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
permCheckFn plugins.ChannelPermissionCheckFunc permCheckFn plugins.ChannelPermissionCheckFunc
tcGetter func(string) (*twitch.Client, error) tcGetter func(string) (*twitch.Client, error)
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
@ -98,21 +99,20 @@ type (
) )
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
for _, field := range []string{"channel", "user"} { if err = attrs.ValidateSchema(
if v, err := attrs.String(field); err != nil || v == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.Errorf("%s must be non-empty string", field) fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "channel", "user"),
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrapf(err, "validating %s template", field) return fmt.Errorf("validating attributes: %w", err)
}
} }
return nil return nil
} }
func (actor) getParams(m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (channel, user string, err error) { func (actor) getParams(m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (channel, user string, err error) {
if channel, err = formatMessage(attrs.MustString("channel", nil), m, r, eventData); err != nil { if channel, err = formatMessage(attrs.MustString("channel", nil), m, r, eventData); err != nil {
return "", "", errors.Wrap(err, "parsing channel") return "", "", errors.Wrap(err, "parsing channel")
} }
@ -124,7 +124,7 @@ func (actor) getParams(m *irc.Message, r *plugins.Rule, eventData *plugins.Field
return strings.TrimLeft(channel, "#"), user, nil return strings.TrimLeft(channel, "#"), user, nil
} }
func (u unvipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (u unvipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
channel, user, err := u.getParams(m, r, eventData, attrs) channel, user, err := u.getParams(m, r, eventData, attrs)
if err != nil { if err != nil {
return false, errors.Wrap(err, "getting parameters") return false, errors.Wrap(err, "getting parameters")
@ -140,7 +140,7 @@ func (u unvipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, even
func (unvipActor) Name() string { return "unvip" } func (unvipActor) Name() string { return "unvip" }
func (v vipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (v vipActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
channel, user, err := v.getParams(m, r, eventData, attrs) channel, user, err := v.getParams(m, r, eventData, attrs)
if err != nil { if err != nil {
return false, errors.Wrap(err, "getting parameters") return false, errors.Wrap(err, "getting parameters")

View file

@ -3,10 +3,13 @@ package whisper
import ( import (
"context" "context"
"fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -14,21 +17,19 @@ import (
const actorName = "whisper" const actorName = "whisper"
var ( var (
botTwitchClient *twitch.Client botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrStringEmpty = func(s string) *string { return &s }("")
) )
// Register provides the plugins.RegisterFunc // Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient() botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{ args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Send a whisper (requires a verified bot!)", Description: "Send a whisper",
Name: "Send Whisper", Name: "Send Whisper",
Type: "whisper", Type: "whisper",
@ -59,7 +60,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData) to, err := formatMessage(attrs.MustString("to", nil), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "preparing whisper receiver") return false, errors.Wrap(err, "preparing whisper receiver")
@ -71,7 +72,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
} }
return false, errors.Wrap( return false, errors.Wrap(
botTwitchClient.SendWhisper(context.Background(), to, msg), botTwitchClient().SendWhisper(context.Background(), to, msg),
"sending whisper", "sending whisper",
) )
} }
@ -79,19 +80,14 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("to"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("to must be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "to", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
if v, err := attrs.String("message"); err != nil || v == "" { helpers.SchemaValidateTemplateField(tplValidator, "message", "to"),
return errors.New("message must be non-empty string") ); err != nil {
} return fmt.Errorf("validating attributes: %w", err)
for _, field := range []string{"message", "to"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
} }
return nil return nil

View file

@ -1,17 +1,20 @@
package customevent package customevent
import ( import (
"fmt"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
type actor struct{} type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData) fd, err := formatMessage(attrs.MustString("fields", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing fields template") return false, errors.Wrap(err, "executing fields template")
@ -35,15 +38,14 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false } func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName } func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if v, err := attrs.String("fields"); err != nil || v == "" { if err = attrs.ValidateSchema(
return errors.New("fields is expected to be non-empty string") fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "fields", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
} fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "schedule_in", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
for _, field := range []string{"fields", "schedule_in"} { helpers.SchemaValidateTemplateField(tplValidator, "fields", "schedule_in"),
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { ); err != nil {
return errors.Wrapf(err, "validating %s template", field) return fmt.Errorf("validating attributes: %w", err)
}
} }
return nil return nil

View file

@ -14,6 +14,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -130,14 +131,14 @@ func handleCreateEvent(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func parseEvent(channel string, fieldData io.Reader) (*plugins.FieldCollection, error) { func parseEvent(channel string, fieldData io.Reader) (*fieldcollection.FieldCollection, error) {
payload := make(map[string]any) payload := make(map[string]any)
if err := json.NewDecoder(fieldData).Decode(&payload); err != nil { if err := json.NewDecoder(fieldData).Decode(&payload); err != nil {
return nil, errors.Wrap(err, "parsing event payload") return nil, errors.Wrap(err, "parsing event payload")
} }
fields := plugins.FieldCollectionFromData(payload) fields := fieldcollection.FieldCollectionFromData(payload)
fields.Set("channel", "#"+strings.TrimLeft(channel, "#")) fields.Set("channel", "#"+strings.TrimLeft(channel, "#"))
return fields, nil return fields, nil

View file

@ -9,9 +9,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
) )
const cleanupTimeout = 15 * time.Minute const cleanupTimeout = 15 * time.Minute
@ -48,7 +48,7 @@ func getFutureEvents(db database.Connector) (out []storedCustomEvent, err error)
) )
} }
func storeEvent(db database.Connector, scheduleAt time.Time, channel string, fields *plugins.FieldCollection) error { func storeEvent(db database.Connector, scheduleAt time.Time, channel string, fields *fieldcollection.FieldCollection) error {
fieldBuf := new(bytes.Buffer) fieldBuf := new(bytes.Buffer)
if err := json.NewEncoder(fieldBuf).Encode(fields); err != nil { if err := json.NewEncoder(fieldBuf).Encode(fields); err != nil {
return errors.Wrap(err, "marshalling fields") return errors.Wrap(err, "marshalling fields")

View file

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -87,7 +88,7 @@ func handleKoFiPost(w http.ResponseWriter, r *http.Request) {
return return
} }
fields := plugins.NewFieldCollection() fields := fieldcollection.NewFieldCollection()
fields.Set("channel", "#"+strings.TrimLeft(channel, "#")) fields.Set("channel", "#"+strings.TrimLeft(channel, "#"))
switch payload.Type { switch payload.Type {

View file

@ -10,9 +10,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/backoff" "github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
) )
type ( type (
@ -86,7 +86,7 @@ func getEventByID(db database.Connector, eventID uint64) (socketMessage, error)
} }
func (o overlaysEvent) ToSocketMessage() (socketMessage, error) { func (o overlaysEvent) ToSocketMessage() (socketMessage, error) {
fields := new(plugins.FieldCollection) fields := new(fieldcollection.FieldCollection)
if err := json.NewDecoder(strings.NewReader(o.Fields)).Decode(fields); err != nil { if err := json.NewDecoder(strings.NewReader(o.Fields)).Decode(fields); err != nil {
return socketMessage{}, errors.Wrap(err, "decoding fields") return socketMessage{}, errors.Wrap(err, "decoding fields")
} }

View file

@ -7,8 +7,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
) )
func TestEventDatabaseRoundtrip(t *testing.T) { func TestEventDatabaseRoundtrip(t *testing.T) {
@ -30,7 +30,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true, IsLive: true,
Time: tEvent2, Time: tEvent2,
Type: "event 2", Type: "event 2",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: fieldcollection.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}) })
assert.Equal(t, uint64(1), evtID) assert.Equal(t, uint64(1), evtID)
assert.NoError(t, err, "adding second event") assert.NoError(t, err, "adding second event")
@ -39,7 +39,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true, IsLive: true,
Time: tEvent1, Time: tEvent1,
Type: "event 1", Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: fieldcollection.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}) })
assert.Equal(t, uint64(2), evtID) assert.Equal(t, uint64(2), evtID)
assert.NoError(t, err, "adding first event") assert.NoError(t, err, "adding first event")
@ -48,7 +48,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true, IsLive: true,
Time: tEvent1, Time: tEvent1,
Type: "event", Type: "event",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: fieldcollection.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}) })
assert.Equal(t, uint64(3), evtID) assert.Equal(t, uint64(3), evtID)
assert.NoError(t, err, "adding other channel event") assert.NoError(t, err, "adding other channel event")
@ -66,6 +66,6 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: false, IsLive: false,
Time: tEvent1, Time: tEvent1,
Type: "event 1", Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}), Fields: fieldcollection.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}, evt) }, evt)
} }

View file

@ -21,6 +21,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
@ -41,12 +42,12 @@ type (
// socketMessage represents the message overlay sockets will receive // socketMessage represents the message overlay sockets will receive
socketMessage struct { socketMessage struct {
EventID uint64 `json:"event_id"` EventID uint64 `json:"event_id"`
IsLive bool `json:"is_live"` IsLive bool `json:"is_live"`
Reason sendReason `json:"reason"` Reason sendReason `json:"reason"`
Time time.Time `json:"time"` Time time.Time `json:"time"`
Type string `json:"type"` Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"` Fields *fieldcollection.FieldCollection `json:"fields"`
} }
) )
@ -180,7 +181,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
return fmt.Errorf("registering API route: %w", err) return fmt.Errorf("registering API route: %w", err)
} }
if err = args.RegisterEventHandler(func(event string, eventData *plugins.FieldCollection) (err error) { if err = args.RegisterEventHandler(func(event string, eventData *fieldcollection.FieldCollection) (err error) {
subscribersLock.RLock() subscribersLock.RLock()
defer subscribersLock.RUnlock() defer subscribersLock.RUnlock()

View file

@ -1,8 +1,10 @@
package raffle package raffle
import ( import (
"fmt"
"time" "time"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -17,7 +19,7 @@ var ptrStrEmpty = ptrStr("")
func ptrStr(v string) *string { return &v } func ptrStr(v string) *string { return &v }
func (enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, evtData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { func (enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, evtData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error) {
if m != nil || evtData.MustString("reward_id", ptrStrEmpty) == "" { if m != nil || evtData.MustString("reward_id", ptrStrEmpty) == "" {
return false, errors.New("enter-raffle actor is only supposed to act on channelpoint redeems") return false, errors.New("enter-raffle actor is only supposed to act on channelpoint redeems")
} }
@ -43,7 +45,7 @@ func (enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule,
EnteredAt: time.Now().UTC(), EnteredAt: time.Now().UTC(),
} }
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{ raffleEventFields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": re.UserID, "user_id": re.UserID,
"user": re.UserLogin, "user": re.UserLogin,
}) })
@ -70,10 +72,12 @@ func (enterRaffleActor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule,
func (enterRaffleActor) IsAsync() bool { return false } func (enterRaffleActor) IsAsync() bool { return false }
func (enterRaffleActor) Name() string { return "enter-raffle" } func (enterRaffleActor) Name() string { return "enter-raffle" }
func (enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { func (enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
keyword, err := attrs.String("keyword") if err = attrs.ValidateSchema(
if err != nil || keyword == "" { fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "keyword", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
return errors.New("keyword must be non-empty string") fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
} }
return nil return nil

View file

@ -9,9 +9,9 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
) )
const ( const (
@ -374,7 +374,7 @@ func (d *dbClient) PickWinner(raffleID uint64) error {
d.speakUp[strings.Join([]string{r.Channel, winner.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: winner.ID, Until: speakUpUntil} d.speakUp[strings.Join([]string{r.Channel, winner.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: winner.ID, Until: speakUpUntil}
d.lock.Unlock() d.lock.Unlock()
fields := plugins.FieldCollectionFromData(map[string]any{ fields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": winner.UserID, "user_id": winner.UserID,
"user": winner.UserLogin, "user": winner.UserLogin,
"winner": winner, "winner": winner,
@ -636,9 +636,9 @@ func (d *dbClient) Update(r raffle) error {
// SendEvent processes the text template and sends the message if // SendEvent processes the text template and sends the message if
// enabled given through the event // enabled given through the event
func (r raffle) SendEvent(evt raffleMessageEvent, fields *plugins.FieldCollection) (err error) { func (r raffle) SendEvent(evt raffleMessageEvent, fields *fieldcollection.FieldCollection) (err error) {
if fields == nil { if fields == nil {
fields = plugins.NewFieldCollection() fields = fieldcollection.NewFieldCollection()
} }
fields.Set("raffle", r) // Make raffle available to templating fields.Set("raffle", r) // Make raffle available to templating

View file

@ -9,6 +9,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -95,7 +96,7 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
re.UserDisplayName = re.UserLogin re.UserDisplayName = re.UserLogin
} }
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{ raffleEventFields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": m.Tags["user-id"], "user_id": m.Tags["user-id"],
"user": user, "user": user,
}) })

4
internal/helpers/ptr.go Normal file
View file

@ -0,0 +1,4 @@
package helpers
// Ptr creates a pointer to any given type
func Ptr[T any](v T) *T { return &v }

View file

@ -0,0 +1,21 @@
package helpers
import (
"fmt"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
)
// SchemaValidateTemplateField contains a ValidateOpt for the
// fieldcollection schema validator to validate template fields
func SchemaValidateTemplateField(tplValidator func(string) error, fields ...string) fieldcollection.ValidateOpt {
return func(f, _ *fieldcollection.FieldCollection) (err error) {
for _, field := range fields {
if err = tplValidator(f.MustString(field, Ptr(""))); err != nil {
return fmt.Errorf("validating %s: %w", field, err)
}
}
return nil
}
}

View file

@ -179,8 +179,7 @@ func TestScanForLinks(t *testing.T) {
}, },
// Case: false positives // Case: false positives
{Heuristic: true, Message: "game dot exe has stopped working", ExpectedLinks: nil}, {Heuristic: true, Message: "game dot exe has stopped working", ExpectedLinks: nil},
{Heuristic: false, Message: "You're following since 12.12.2020 DogChamp", ExpectedLinks: nil}, {Heuristic: true, Message: "You are following since 12.12.2020 DogChamp", ExpectedLinks: nil},
{Heuristic: true, Message: "You're following since 12.12.2020 DogChamp", ExpectedLinks: []string{"http://You.re"}},
{Heuristic: false, Message: "Hey btw. es kann sein, dass", ExpectedLinks: nil}, {Heuristic: false, Message: "Hey btw. es kann sein, dass", ExpectedLinks: nil},
} { } {
t.Run(fmt.Sprintf("h:%v lc:%d m:%s", testCase.Heuristic, len(testCase.ExpectedLinks), testCase.Message), func(t *testing.T) { t.Run(fmt.Sprintf("h:%v lc:%d m:%s", testCase.Heuristic, len(testCase.ExpectedLinks), testCase.Message), func(t *testing.T) {

View file

@ -2,7 +2,7 @@
package access package access
import ( import (
"context" "fmt"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -29,8 +29,6 @@ type (
TwitchClient string TwitchClient string
TwitchClientSecret string TwitchClientSecret string
FallbackToken string // DEPRECATED
TokenUpdateHook func() TokenUpdateHook func()
} }
@ -93,72 +91,11 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
// bot user // bot user
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) { func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
botUsername, err := s.GetBotUsername() botUsername, err := s.GetBotUsername()
switch {
case errors.Is(err, nil):
// This is fine, we have a username
return s.GetTwitchClientForChannel(botUsername, cfg)
case errors.Is(err, database.ErrCoreMetaNotFound):
// The bot has no username stored, we try to auto-migrate below
default:
return nil, errors.Wrap(err, "getting bot username from database")
}
// Bot username is not set, either we're running from fallback token
// or did not yet execute the v3.5.0 migration
var botAccessToken, botRefreshToken string
err = s.db.ReadEncryptedCoreMeta(coreMetaKeyBotToken, &botAccessToken)
switch {
case errors.Is(err, nil):
// This is fine, we do have a pre-v3.5.0 config, lets do the migration
case errors.Is(err, database.ErrCoreMetaNotFound):
// We're don't have a stored pre-v3.5.0 token either, so we're
// running from the fallback token (which might be empty)
return twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, cfg.FallbackToken, ""), nil
default:
return nil, errors.Wrap(err, "getting bot access token from database")
}
if err = s.db.ReadEncryptedCoreMeta(coreMetaKeyBotRefreshToken, &botRefreshToken); err != nil {
return nil, errors.Wrap(err, "getting bot refresh token from database")
}
// Now we do have (hopefully valid) tokens for the bot and therefore
// can determine who the bot is. That means we can set the username
// for later reference and afterwards delete the duplicated tokens.
_, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, botAccessToken, botRefreshToken).GetAuthorizedUser(context.Background())
if err != nil { if err != nil {
return nil, errors.Wrap(err, "validating stored access token") return nil, fmt.Errorf("getting bot username: %w", err)
} }
if err = s.db.StoreCoreMeta(coreMetaKeyBotUsername, botUser); err != nil { return s.GetTwitchClientForChannel(botUsername, cfg)
return nil, errors.Wrap(err, "setting bot username")
}
if _, err = s.GetTwitchClientForChannel(botUser, cfg); errors.Is(err, gorm.ErrRecordNotFound) {
// There is no extended permission for that channel, we probably
// are in a state created by the v2 migrator. Lets just store the
// token without any permissions as we cannot know the permissions
// assigned to that token
if err = s.SetExtendedTwitchCredentials(botUser, botAccessToken, botRefreshToken, nil); err != nil {
return nil, errors.Wrap(err, "moving bot access token")
}
}
if err = s.db.DeleteCoreMeta(coreMetaKeyBotToken); err != nil {
return nil, errors.Wrap(err, "deleting deprecated bot token")
}
if err = s.db.DeleteCoreMeta(coreMetaKeyBotRefreshToken); err != nil {
return nil, errors.Wrap(err, "deleting deprecated bot refresh-token")
}
return s.GetTwitchClientForChannel(botUser, cfg)
} }
// GetTwitchClientForChannel returns a twitch.Client configured to act // GetTwitchClientForChannel returns a twitch.Client configured to act

View file

@ -3,6 +3,7 @@
package userstate package userstate
import ( import (
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
@ -16,7 +17,7 @@ func Register(args plugins.RegistrationArguments) error {
return errors.Wrap(err, "registering raw message handler") return errors.Wrap(err, "registering raw message handler")
} }
args.RegisterTemplateFunction("botHasBadge", func(m *irc.Message, _ *plugins.Rule, fields *plugins.FieldCollection) interface{} { args.RegisterTemplateFunction("botHasBadge", func(m *irc.Message, _ *plugins.Rule, fields *fieldcollection.FieldCollection) interface{} {
return func(badge string) bool { return func(badge string) bool {
state := userState.Get(plugins.DeriveChannel(m, fields)) state := userState.Get(plugins.DeriveChannel(m, fields))
if state == nil { if state == nil {

15
irc.go
View file

@ -13,6 +13,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -226,7 +227,7 @@ func (i ircHandler) handleClearChat(m *irc.Message) {
var ( var (
evt *string evt *string
fields = plugins.NewFieldCollection() fields = fieldcollection.NewFieldCollection()
) )
fields.Set(eventFieldChannel, i.getChannel(m)) // Compatibility to plugins.DeriveChannel fields.Set(eventFieldChannel, i.getChannel(m)) // Compatibility to plugins.DeriveChannel
@ -258,7 +259,7 @@ func (i ircHandler) handleClearChat(m *irc.Message) {
} }
func (i ircHandler) handleClearMessage(m *irc.Message) { func (i ircHandler) handleClearMessage(m *irc.Message) {
fields := plugins.FieldCollectionFromData(map[string]interface{}{ fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
"message_id": m.Tags["target-msg-id"], "message_id": m.Tags["target-msg-id"],
"target_name": m.Tags["login"], "target_name": m.Tags["login"],
@ -270,7 +271,7 @@ func (i ircHandler) handleClearMessage(m *irc.Message) {
} }
func (i ircHandler) handleJoin(m *irc.Message) { func (i ircHandler) handleJoin(m *irc.Message) {
fields := plugins.FieldCollectionFromData(map[string]interface{}{ fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser
}) })
@ -278,7 +279,7 @@ func (i ircHandler) handleJoin(m *irc.Message) {
} }
func (i ircHandler) handlePart(m *irc.Message) { func (i ircHandler) handlePart(m *irc.Message) {
fields := plugins.FieldCollectionFromData(map[string]interface{}{ fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser
}) })
@ -299,7 +300,7 @@ func (i ircHandler) handlePermit(m *irc.Message) {
username := msgParts[1] username := msgParts[1]
fields := plugins.FieldCollectionFromData(map[string]interface{}{ fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser
eventFieldUserID: m.Tags["user-id"], eventFieldUserID: m.Tags["user-id"],
@ -356,7 +357,7 @@ func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
} }
if bits := i.tagToNumeric(m, "bits", 0); bits > 0 { if bits := i.tagToNumeric(m, "bits", 0); bits > 0 {
fields := plugins.FieldCollectionFromData(map[string]interface{}{ fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"bits": bits, "bits": bits,
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
"message": m.Trailing(), "message": m.Trailing(),
@ -380,7 +381,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"trailing": m.Trailing(), "trailing": m.Trailing(),
}).Trace("IRC USERNOTICE event") }).Trace("IRC USERNOTICE event")
evtData := plugins.FieldCollectionFromData(map[string]any{ evtData := fieldcollection.FieldCollectionFromData(map[string]any{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.Tags["login"], // Compatibility to plugins.DeriveUser eventFieldUserName: m.Tags["login"], // Compatibility to plugins.DeriveUser
eventFieldUserID: m.Tags["user-id"], eventFieldUserID: m.Tags["user-id"],

51
main.go
View file

@ -54,7 +54,6 @@ var (
StorageEncryptionPass string `flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)"` StorageEncryptionPass string `flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)"`
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"` TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"`
TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"` TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"`
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client (fallback if no token was set in interface) -- DEPRECATED"`
ValidateConfig bool `flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success"` ValidateConfig bool `flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
WaitForSelfcheck time.Duration `flag:"wait-for-selfcheck" default:"60s" description:"Maximum time to wait for the self-check to respond when behind load-balancers"` WaitForSelfcheck time.Duration `flag:"wait-for-selfcheck" default:"60s" description:"Maximum time to wait for the self-check to respond when behind load-balancers"`
@ -117,10 +116,6 @@ func initApp() error {
}, ":") }, ":")
} }
if cfg.TwitchToken != "" {
log.Warn("You are using the DEPRECATED --twitch-token flag / TWITCH_TOKEN env variable, please switch to web-based auth! - This flag will be removed in a later release!")
}
return nil return nil
} }
@ -151,29 +146,6 @@ func main() {
log.WithError(err).Fatal("applying timer migration") log.WithError(err).Fatal("applying timer migration")
} }
if twitchClient, err = accessService.GetBotTwitchClient(access.ClientConfig{
TwitchClient: cfg.TwitchClient,
TwitchClientSecret: cfg.TwitchClientSecret,
FallbackToken: cfg.TwitchToken,
TokenUpdateHook: func() {
// make frontend reload its state as of token change
frontendNotifyHooks.Ping(frontendNotifyTypeReload)
},
}); err != nil {
if !errors.Is(err, access.ErrChannelNotAuthorized) {
log.WithError(err).Fatal("initializing Twitch client")
}
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, cfg.TwitchToken, "")
}
twitchWatch := newTwitchWatcher()
// Query may run that often as the twitchClient has an internal
// cache but shouldn't run more often as EventSub subscriptions
// are retried on error each time
if _, err = cronService.AddFunc("@every 30s", twitchWatch.Check); err != nil {
log.WithError(err).Fatal("registering twitchWatch cron")
}
// Allow config to subscribe to external rules // Allow config to subscribe to external rules
updCron := updateConfigCron() updCron := updateConfigCron()
if _, err = cronService.AddFunc(updCron, updateConfigFromRemote); err != nil { if _, err = cronService.AddFunc(updCron, updateConfigFromRemote); err != nil {
@ -257,6 +229,29 @@ func main() {
log.WithError(err).Fatal("Missing required parameters") log.WithError(err).Fatal("Missing required parameters")
} }
if twitchClient, err = accessService.GetBotTwitchClient(access.ClientConfig{
TwitchClient: cfg.TwitchClient,
TwitchClientSecret: cfg.TwitchClientSecret,
TokenUpdateHook: func() {
// make frontend reload its state as of token change
frontendNotifyHooks.Ping(frontendNotifyTypeReload)
},
}); err != nil {
if !errors.Is(err, access.ErrChannelNotAuthorized) {
log.WithError(err).Fatal("initializing Twitch client")
}
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, "", "")
}
twitchWatch := newTwitchWatcher()
// Query may run that often as the twitchClient has an internal
// cache but shouldn't run more often as EventSub subscriptions
// are retried on error each time
if _, err = cronService.AddFunc("@every 30s", twitchWatch.Check); err != nil {
log.WithError(err).Fatal("registering twitchWatch cron")
}
fsEvents := make(chan configChangeEvent, 1) fsEvents := make(chan configChangeEvent, 1)
go watchConfigChanges(cfg.Config, fsEvents) go watchConfigChanges(cfg.Config, fsEvents)

View file

@ -10,6 +10,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins" "github.com/Luzifer/twitch-bot/v3/plugins"
) )
@ -19,7 +20,7 @@ var (
stripNewline = regexp.MustCompile(`(?m)\s*\n\s*`) stripNewline = regexp.MustCompile(`(?m)\s*\n\s*`)
formatMessageFieldSetters = []func(compiledFields *plugins.FieldCollection, m *irc.Message, fields *plugins.FieldCollection){ formatMessageFieldSetters = []func(compiledFields *fieldcollection.FieldCollection, m *irc.Message, fields *fieldcollection.FieldCollection){
formatMessageFieldChannel, formatMessageFieldChannel,
formatMessageFieldMessage, formatMessageFieldMessage,
formatMessageFieldUserID, formatMessageFieldUserID,
@ -27,8 +28,8 @@ var (
} }
) )
func formatMessage(tplString string, m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) (string, error) { func formatMessage(tplString string, m *irc.Message, r *plugins.Rule, fields *fieldcollection.FieldCollection) (string, error) {
compiledFields := plugins.NewFieldCollection() compiledFields := fieldcollection.NewFieldCollection()
if config != nil { if config != nil {
configLock.RLock() configLock.RLock()
@ -61,11 +62,11 @@ func formatMessage(tplString string, m *irc.Message, r *plugins.Rule, fields *pl
return strings.TrimSpace(buf.String()), errors.Wrap(err, "execute template") return strings.TrimSpace(buf.String()), errors.Wrap(err, "execute template")
} }
func formatMessageFieldChannel(compiledFields *plugins.FieldCollection, m *irc.Message, fields *plugins.FieldCollection) { func formatMessageFieldChannel(compiledFields *fieldcollection.FieldCollection, m *irc.Message, fields *fieldcollection.FieldCollection) {
compiledFields.Set(eventFieldChannel, plugins.DeriveChannel(m, fields)) compiledFields.Set(eventFieldChannel, plugins.DeriveChannel(m, fields))
} }
func formatMessageFieldMessage(compiledFields *plugins.FieldCollection, m *irc.Message, _ *plugins.FieldCollection) { func formatMessageFieldMessage(compiledFields *fieldcollection.FieldCollection, m *irc.Message, _ *fieldcollection.FieldCollection) {
if m == nil { if m == nil {
return return
} }
@ -73,7 +74,7 @@ func formatMessageFieldMessage(compiledFields *plugins.FieldCollection, m *irc.M
compiledFields.Set("msg", m) compiledFields.Set("msg", m)
} }
func formatMessageFieldUserID(compiledFields *plugins.FieldCollection, m *irc.Message, _ *plugins.FieldCollection) { func formatMessageFieldUserID(compiledFields *fieldcollection.FieldCollection, m *irc.Message, _ *fieldcollection.FieldCollection) {
if m == nil { if m == nil {
return return
} }
@ -83,7 +84,7 @@ func formatMessageFieldUserID(compiledFields *plugins.FieldCollection, m *irc.Me
} }
} }
func formatMessageFieldUsername(compiledFields *plugins.FieldCollection, m *irc.Message, fields *plugins.FieldCollection) { func formatMessageFieldUsername(compiledFields *fieldcollection.FieldCollection, m *irc.Message, fields *fieldcollection.FieldCollection) {
compiledFields.Set("username", plugins.DeriveUser(m, fields)) compiledFields.Set("username", plugins.DeriveUser(m, fields))
} }
@ -93,7 +94,7 @@ func validateTemplate(tplString string) error {
_, err := template. _, err := template.
New(tplString). New(tplString).
Funcs(tplFuncs.GetFuncMap(nil, nil, plugins.NewFieldCollection())). Funcs(tplFuncs.GetFuncMap(nil, nil, fieldcollection.NewFieldCollection())).
Parse(tplString) Parse(tplString)
return errors.Wrap(err, "parsing template") return errors.Wrap(err, "parsing template")
} }

View file

@ -119,7 +119,7 @@ func (c connector) readCoreMeta(key string, value any, processor func(string) (s
if err = helpers.Retry(func() error { if err = helpers.Retry(func() error {
err = c.db.First(&data, "name = ?", key).Error err = c.db.First(&data, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCoreMetaNotFound return backoff.NewErrCannotRetry(ErrCoreMetaNotFound)
} }
return errors.Wrap(err, "querying core meta table") return errors.Wrap(err, "querying core meta table")
}); err != nil { }); err != nil {

View file

@ -29,6 +29,8 @@ const (
EventSubEventTypeChannelPollBegin = "channel.poll.begin" EventSubEventTypeChannelPollBegin = "channel.poll.begin"
EventSubEventTypeChannelPollEnd = "channel.poll.end" EventSubEventTypeChannelPollEnd = "channel.poll.end"
EventSubEventTypeChannelPollProgress = "channel.poll.progress" EventSubEventTypeChannelPollProgress = "channel.poll.progress"
EventSubEventTypeChannelSuspiciousUserMessage = "channel.suspicious_user.message"
EventSubEventTypeChannelSuspiciousUserUpdate = "channel.suspicious_user.update"
EventSubEventTypeStreamOffline = "stream.offline" EventSubEventTypeStreamOffline = "stream.offline"
EventSubEventTypeStreamOnline = "stream.online" EventSubEventTypeStreamOnline = "stream.online"
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke" EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
@ -235,6 +237,53 @@ type (
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
} }
// EventSubEventSuspiciousUserMessage contains the payload for a
// channel.suspicious_user.message
EventSubEventSuspiciousUserMessage struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserName string `json:"broadcaster_user_name"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
UserLogin string `json:"user_login"`
LowTrustStatus string `json:"low_trust_status"` // Can be the following: "none", "active_monitoring", or "restricted"
SharedBanChannelIDs []string `json:"shared_ban_channel_ids"`
Types []string `json:"types"` // can be "manual", "ban_evader_detector", or "shared_channel_ban"
BanEvasionEvaluation string `json:"ban_evasion_evaluation"` // can be "unknown", "possible", or "likely"
Message struct {
MessageID string `json:"message_id"`
Text string `json:"text"`
Fragments []struct {
Type string `json:"type"`
Text string `json:"text"`
Cheermote struct {
Prefix string `json:"prefix"`
Bits int `json:"bits"`
Tier int `json:"tier"`
} `json:"Cheermote"`
Emote struct {
ID string `json:"id"`
EmoteSetID string `json:"emote_set_id"`
} `json:"emote"`
} `json:"fragments"`
} `json:"message"`
}
// EventSubEventSuspiciousUserUpdated contains the payload for a
// channel.suspicious_user.update
EventSubEventSuspiciousUserUpdated struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserName string `json:"broadcaster_user_name"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
ModeratorUserID string `json:"moderator_user_id"`
ModeratorUserName string `json:"moderator_user_name"`
ModeratorUserLogin string `json:"moderator_user_login"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
UserLogin string `json:"user_login"`
LowTrustStatus string `json:"low_trust_status"` // Can be the following: "none", "active_monitoring", or "restricted"
}
// EventSubEventUserAuthorizationRevoke contains the payload for an // EventSubEventUserAuthorizationRevoke contains the payload for an
// authorization revoke event // authorization revoke event
EventSubEventUserAuthorizationRevoke struct { EventSubEventUserAuthorizationRevoke struct {
@ -324,7 +373,6 @@ func (c *Client) createEventSubSubscription(ctx context.Context, auth AuthType,
if mustFetchSubsctiption { if mustFetchSubsctiption {
params := make(url.Values) params := make(url.Values)
params.Set("status", "enabled") params.Set("status", "enabled")
params.Set("type", sub.Type)
if err = c.Request(ctx, ClientRequestOpts{ if err = c.Request(ctx, ClientRequestOpts{
AuthType: auth, AuthType: auth,

View file

@ -3,35 +3,36 @@ package twitch
// Collection of known API scopes // Collection of known API scopes
const ( const (
// API Scopes // API Scopes
ScopeChannelBot = "channel:bot" ScopeChannelBot = "channel:bot"
ScopeChannelEditCommercial = "channel:edit:commercial" ScopeChannelEditCommercial = "channel:edit:commercial"
ScopeChannelManageAds = "channel:manage:ads" ScopeChannelManageAds = "channel:manage:ads"
ScopeChannelManageBroadcast = "channel:manage:broadcast" ScopeChannelManageBroadcast = "channel:manage:broadcast"
ScopeChannelManageModerators = "channel:manage:moderators" ScopeChannelManageModerators = "channel:manage:moderators"
ScopeChannelManagePolls = "channel:manage:polls" ScopeChannelManagePolls = "channel:manage:polls"
ScopeChannelManagePredictions = "channel:manage:predictions" ScopeChannelManagePredictions = "channel:manage:predictions"
ScopeChannelManageRaids = "channel:manage:raids" ScopeChannelManageRaids = "channel:manage:raids"
ScopeChannelManageRedemptions = "channel:manage:redemptions" ScopeChannelManageRedemptions = "channel:manage:redemptions"
ScopeChannelManageVIPS = "channel:manage:vips" ScopeChannelManageVIPS = "channel:manage:vips"
ScopeChannelManageWhispers = "user:manage:whispers" ScopeChannelManageWhispers = "user:manage:whispers"
ScopeChannelReadAds = "channel:read:ads" ScopeChannelReadAds = "channel:read:ads"
ScopeChannelReadHypetrain = "channel:read:hype_train" ScopeChannelReadHypetrain = "channel:read:hype_train"
ScopeChannelReadPolls = "channel:read:polls" ScopeChannelReadPolls = "channel:read:polls"
ScopeChannelReadRedemptions = "channel:read:redemptions" ScopeChannelReadRedemptions = "channel:read:redemptions"
ScopeChannelReadSubscriptions = "channel:read:subscriptions" ScopeChannelReadSubscriptions = "channel:read:subscriptions"
ScopeClipsEdit = "clips:edit" ScopeClipsEdit = "clips:edit"
ScopeModeratorManageAnnoucements = "moderator:manage:announcements" ScopeModeratorManageAnnoucements = "moderator:manage:announcements"
ScopeModeratorManageBannedUsers = "moderator:manage:banned_users" ScopeModeratorManageBannedUsers = "moderator:manage:banned_users"
ScopeModeratorManageChatMessages = "moderator:manage:chat_messages" ScopeModeratorManageChatMessages = "moderator:manage:chat_messages"
ScopeModeratorManageChatSettings = "moderator:manage:chat_settings" ScopeModeratorManageChatSettings = "moderator:manage:chat_settings"
ScopeModeratorManageShieldMode = "moderator:manage:shield_mode" ScopeModeratorManageShieldMode = "moderator:manage:shield_mode"
ScopeModeratorManageShoutouts = "moderator:manage:shoutouts" ScopeModeratorManageShoutouts = "moderator:manage:shoutouts"
ScopeModeratorReadFollowers = "moderator:read:followers" ScopeModeratorReadFollowers = "moderator:read:followers"
ScopeModeratorReadShoutouts = "moderator:read:shoutouts" ScopeModeratorReadShoutouts = "moderator:read:shoutouts"
ScopeUserBot = "user:bot" ScopeModeratorReadSuspiciousUsers = "moderator:read:suspicious_users"
ScopeUserManageChatColor = "user:manage:chat_color" ScopeUserBot = "user:bot"
ScopeUserManageWhispers = "user:manage:whispers" ScopeUserManageChatColor = "user:manage:chat_color"
ScopeUserReadChat = "user:read:chat" ScopeUserManageWhispers = "user:manage:whispers"
ScopeUserReadChat = "user:read:chat"
// Deprecated v5 scope but used in chat // Deprecated v5 scope but used in chat
ScopeV5ChannelEditor = "channel_editor" ScopeV5ChannelEditor = "channel_editor"

View file

@ -3,6 +3,7 @@ package plugins
import ( import (
"reflect" "reflect"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -16,9 +17,9 @@ type (
// (not returning ErrValueNotSet) and does not contain zero value // (not returning ErrValueNotSet) and does not contain zero value
// recognized by reflect (to just check whether the field is set // recognized by reflect (to just check whether the field is set
// but allow zero values use HasAll on the FieldCollection) // but allow zero values use HasAll on the FieldCollection)
func (ActorKit) ValidateRequireNonEmpty(attrs *FieldCollection, fields ...string) error { func (ActorKit) ValidateRequireNonEmpty(attrs *fieldcollection.FieldCollection, fields ...string) error {
for _, field := range fields { for _, field := range fields {
v, err := attrs.Any(field) v, err := attrs.Get(field)
if err != nil { if err != nil {
return errors.Wrapf(err, "getting field %s", field) return errors.Wrapf(err, "getting field %s", field)
} }
@ -34,7 +35,7 @@ func (ActorKit) ValidateRequireNonEmpty(attrs *FieldCollection, fields ...string
// ValidateRequireValidTemplate checks whether fields are gettable // ValidateRequireValidTemplate checks whether fields are gettable
// as strings and do have a template which validates (this does not // as strings and do have a template which validates (this does not
// check for empty strings as an empty template is indeed valid) // check for empty strings as an empty template is indeed valid)
func (ActorKit) ValidateRequireValidTemplate(tplValidator TemplateValidatorFunc, attrs *FieldCollection, fields ...string) error { func (ActorKit) ValidateRequireValidTemplate(tplValidator TemplateValidatorFunc, attrs *fieldcollection.FieldCollection, fields ...string) error {
for _, field := range fields { for _, field := range fields {
v, err := attrs.String(field) v, err := attrs.String(field)
if err != nil { if err != nil {
@ -52,11 +53,11 @@ func (ActorKit) ValidateRequireValidTemplate(tplValidator TemplateValidatorFunc,
// ValidateRequireValidTemplateIfSet checks whether the field is // ValidateRequireValidTemplateIfSet checks whether the field is
// either not set or a valid template (this does not // either not set or a valid template (this does not
// check for empty strings as an empty template is indeed valid) // check for empty strings as an empty template is indeed valid)
func (ActorKit) ValidateRequireValidTemplateIfSet(tplValidator TemplateValidatorFunc, attrs *FieldCollection, fields ...string) error { func (ActorKit) ValidateRequireValidTemplateIfSet(tplValidator TemplateValidatorFunc, attrs *fieldcollection.FieldCollection, fields ...string) error {
for _, field := range fields { for _, field := range fields {
v, err := attrs.String(field) v, err := attrs.String(field)
if err != nil { if err != nil {
if errors.Is(err, ErrValueNotSet) { if errors.Is(err, fieldcollection.ErrValueNotSet) {
continue continue
} }
return errors.Wrapf(err, "getting string field %s", field) return errors.Wrapf(err, "getting string field %s", field)

View file

@ -4,11 +4,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestValidateRequireNonEmpty(t *testing.T) { func TestValidateRequireNonEmpty(t *testing.T) {
attrs := FieldCollectionFromData(map[string]any{ attrs := fieldcollection.FieldCollectionFromData(map[string]any{
"str": "", "str": "",
"str_v": "valid", "str_v": "valid",
"int": 0, "int": 0,

View file

@ -1,407 +0,0 @@
package plugins
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/pkg/errors"
)
var (
// ErrValueNotSet is used to notify the value is not available in the FieldCollection
ErrValueNotSet = errors.New("specified value not found")
// ErrValueMismatch is used to notify the value does not match the requested type
ErrValueMismatch = errors.New("specified value has different format")
)
// FieldCollection holds an map[string]any with conversion functions attached
type FieldCollection struct {
data map[string]any
lock sync.RWMutex
}
// NewFieldCollection creates a new FieldCollection with empty data store
func NewFieldCollection() *FieldCollection {
return &FieldCollection{data: make(map[string]any)}
}
// FieldCollectionFromData is a wrapper around NewFieldCollection and SetFromData
func FieldCollectionFromData(data map[string]any) *FieldCollection {
o := NewFieldCollection()
o.SetFromData(data)
return o
}
// CanBool tries to read key name as bool and checks whether error is nil
func (f *FieldCollection) CanBool(name string) bool {
_, err := f.Bool(name)
return err == nil
}
// CanDuration tries to read key name as time.Duration and checks whether error is nil
func (f *FieldCollection) CanDuration(name string) bool {
_, err := f.Duration(name)
return err == nil
}
// CanInt64 tries to read key name as int64 and checks whether error is nil
func (f *FieldCollection) CanInt64(name string) bool {
_, err := f.Int64(name)
return err == nil
}
// CanString tries to read key name as string and checks whether error is nil
func (f *FieldCollection) CanString(name string) bool {
_, err := f.String(name)
return err == nil
}
// Clone is a wrapper around n.SetFromData(o.Data())
func (f *FieldCollection) Clone() *FieldCollection {
out := new(FieldCollection)
out.SetFromData(f.Data())
return out
}
// Data creates a map-copy of the data stored inside the FieldCollection
func (f *FieldCollection) Data() map[string]any {
if f == nil {
return nil
}
f.lock.RLock()
defer f.lock.RUnlock()
out := make(map[string]any)
for k := range f.data {
out[k] = f.data[k]
}
return out
}
// Expect takes a list of keys and returns an error with all non-found names
func (f *FieldCollection) Expect(keys ...string) error {
if len(keys) == 0 {
return nil
}
if f == nil || f.data == nil {
return errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
var missing []string
for _, k := range keys {
if _, ok := f.data[k]; !ok {
missing = append(missing, k)
}
}
if len(missing) > 0 {
return errors.Errorf("missing key(s) %s", strings.Join(missing, ", "))
}
return nil
}
// HasAll takes a list of keys and returns whether all of them exist inside the FieldCollection
func (f *FieldCollection) HasAll(keys ...string) bool {
return f.Expect(keys...) == nil
}
// MustBool is a wrapper around Bool and panics if an error was returned
func (f *FieldCollection) MustBool(name string, defVal *bool) bool {
v, err := f.Bool(name)
if err != nil {
if defVal != nil {
return *defVal
}
panic(err)
}
return v
}
// MustDuration is a wrapper around Duration and panics if an error was returned
func (f *FieldCollection) MustDuration(name string, defVal *time.Duration) time.Duration {
v, err := f.Duration(name)
if err != nil {
if defVal != nil {
return *defVal
}
panic(err)
}
return v
}
// MustInt64 is a wrapper around Int64 and panics if an error was returned
func (f *FieldCollection) MustInt64(name string, defVal *int64) int64 {
v, err := f.Int64(name)
if err != nil {
if defVal != nil {
return *defVal
}
panic(err)
}
return v
}
// MustString is a wrapper around String and panics if an error was returned
func (f *FieldCollection) MustString(name string, defVal *string) string {
v, err := f.String(name)
if err != nil {
if defVal != nil {
return *defVal
}
panic(err)
}
return v
}
// MustStringSlice is a wrapper around StringSlice and returns nil in case name is not set
func (f *FieldCollection) MustStringSlice(name string) []string {
v, err := f.StringSlice(name)
if err != nil {
return nil
}
return v
}
// Any tries to read key name as any-type (interface)
func (f *FieldCollection) Any(name string) (any, error) {
if f == nil || f.data == nil {
return false, errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, ok := f.data[name]
if !ok {
return false, ErrValueNotSet
}
return v, nil
}
// Bool tries to read key name as bool
func (f *FieldCollection) Bool(name string) (bool, error) {
if f == nil || f.data == nil {
return false, errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, ok := f.data[name]
if !ok {
return false, ErrValueNotSet
}
switch v := v.(type) {
case bool:
return v, nil
case string:
bv, err := strconv.ParseBool(v)
return bv, errors.Wrap(err, "parsing string to bool")
}
return false, ErrValueMismatch
}
// Duration tries to read key name as time.Duration
func (f *FieldCollection) Duration(name string) (time.Duration, error) {
if f == nil || f.data == nil {
return 0, errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, err := f.String(name)
if err != nil {
return 0, errors.Wrap(err, "getting string value")
}
d, err := time.ParseDuration(v)
return d, errors.Wrap(err, "parsing value")
}
// Int64 tries to read key name as int64
func (f *FieldCollection) Int64(name string) (int64, error) {
if f == nil || f.data == nil {
return 0, errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, ok := f.data[name]
if !ok {
return 0, ErrValueNotSet
}
switch v := v.(type) {
case float64:
return int64(v), nil
case int:
return int64(v), nil
case int16:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
}
return 0, ErrValueMismatch
}
// Set sets a single key to specified value
func (f *FieldCollection) Set(key string, value any) {
if f == nil {
f = NewFieldCollection()
}
f.lock.Lock()
defer f.lock.Unlock()
if f.data == nil {
f.data = make(map[string]any)
}
f.data[key] = value
}
// SetFromData takes a map of data and copies all data into the FieldCollection
func (f *FieldCollection) SetFromData(data map[string]any) {
if f == nil {
f = NewFieldCollection()
}
f.lock.Lock()
defer f.lock.Unlock()
if f.data == nil {
f.data = make(map[string]any)
}
for key, value := range data {
f.data[key] = value
}
}
// String tries to read key name as string
func (f *FieldCollection) String(name string) (string, error) {
if f == nil || f.data == nil {
return "", errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, ok := f.data[name]
if !ok {
return "", ErrValueNotSet
}
if sv, ok := v.(string); ok {
return sv, nil
}
if iv, ok := v.(fmt.Stringer); ok {
return iv.String(), nil
}
return fmt.Sprintf("%v", v), nil
}
// StringSlice tries to read key name as []string
func (f *FieldCollection) StringSlice(name string) ([]string, error) {
if f == nil || f.data == nil {
return nil, errors.New("uninitialized field collection")
}
f.lock.RLock()
defer f.lock.RUnlock()
v, ok := f.data[name]
if !ok {
return nil, ErrValueNotSet
}
switch v := v.(type) {
case []string:
return v, nil
case []any:
var out []string
for _, iv := range v {
sv, ok := iv.(string)
if !ok {
return nil, errors.New("value in slice was not string")
}
out = append(out, sv)
}
return out, nil
}
return nil, ErrValueMismatch
}
// Implement JSON marshalling to plain underlying map[string]any
// MarshalJSON implements the json.Marshaller interface
func (f *FieldCollection) MarshalJSON() ([]byte, error) {
if f == nil || f.data == nil {
return []byte("{}"), nil
}
f.lock.RLock()
defer f.lock.RUnlock()
data, err := json.Marshal(f.data)
if err != nil {
return nil, fmt.Errorf("marshalling data to json: %w", err)
}
return data, nil
}
// UnmarshalJSON implements the json.Unmarshaller interface
func (f *FieldCollection) UnmarshalJSON(raw []byte) error {
data := make(map[string]any)
if err := json.Unmarshal(raw, &data); err != nil {
return errors.Wrap(err, "unmarshalling from JSON")
}
f.SetFromData(data)
return nil
}
// Implement YAML marshalling to plain underlying map[string]any
// MarshalYAML implements the yaml.Marshaller interface
func (f *FieldCollection) MarshalYAML() (any, error) {
return f.Data(), nil
}
// UnmarshalYAML implements the yaml.Unmarshaller interface
func (f *FieldCollection) UnmarshalYAML(unmarshal func(any) error) error {
data := make(map[string]any)
if err := unmarshal(&data); err != nil {
return errors.Wrap(err, "unmarshalling from YAML")
}
f.SetFromData(data)
return nil
}

View file

@ -1,93 +0,0 @@
package plugins
import (
"bytes"
"encoding/json"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestFieldCollectionJSONMarshal(t *testing.T) {
var (
buf = new(bytes.Buffer)
raw = `{"key1":"test1","key2":"test2"}`
f = NewFieldCollection()
)
if err := json.NewDecoder(strings.NewReader(raw)).Decode(f); err != nil {
t.Fatalf("Unable to unmarshal: %s", err)
}
if err := json.NewEncoder(buf).Encode(f); err != nil {
t.Fatalf("Unable to marshal: %s", err)
}
if raw != strings.TrimSpace(buf.String()) {
t.Errorf("Marshalled JSON does not match expectation: res=%s exp=%s", buf.String(), raw)
}
}
func TestFieldCollectionYAMLMarshal(t *testing.T) {
var (
buf = new(bytes.Buffer)
raw = "key1: test1\nkey2: test2"
f = NewFieldCollection()
)
if err := yaml.NewDecoder(strings.NewReader(raw)).Decode(f); err != nil {
t.Fatalf("Unable to unmarshal: %s", err)
}
if err := yaml.NewEncoder(buf).Encode(f); err != nil {
t.Fatalf("Unable to marshal: %s", err)
}
if raw != strings.TrimSpace(buf.String()) {
t.Errorf("Marshalled YAML does not match expectation: res=%s exp=%s", buf.String(), raw)
}
}
func TestFieldCollectionNilModify(_ *testing.T) {
var f *FieldCollection
f.Set("foo", "bar")
f = nil
f.SetFromData(map[string]interface{}{"foo": "bar"})
}
func TestFieldCollectionNilClone(_ *testing.T) {
var f *FieldCollection
f.Clone()
}
func TestFieldCollectionNilDataGet(t *testing.T) {
var f *FieldCollection
for name, fn := range map[string]func(name string) bool{
"bool": f.CanBool,
"duration": f.CanDuration,
"int64": f.CanInt64,
"string": f.CanString,
} {
if fn("foo") {
t.Errorf("%s key is available", name)
}
}
}
func TestFieldCollectionIntToString(t *testing.T) {
val := 123
fc := FieldCollectionFromData(map[string]interface{}{"test": val})
if !fc.CanString("test") {
t.Fatalf("cannot convert %T to string", val)
}
if v := fc.MustString("test", nil); v != "123" {
t.Errorf("unexpected value: 123 != %s", v)
}
}

View file

@ -4,12 +4,13 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
) )
// DeriveChannel takes an irc.Message and a FieldCollection and tries // DeriveChannel takes an irc.Message and a FieldCollection and tries
// to extract from them the channel the event / message has taken place // to extract from them the channel the event / message has taken place
func DeriveChannel(m *irc.Message, evtData *FieldCollection) string { func DeriveChannel(m *irc.Message, evtData *fieldcollection.FieldCollection) string {
if m != nil && len(m.Params) > 0 && strings.HasPrefix(m.Params[0], "#") { if m != nil && len(m.Params) > 0 && strings.HasPrefix(m.Params[0], "#") {
return m.Params[0] return m.Params[0]
} }
@ -23,7 +24,7 @@ func DeriveChannel(m *irc.Message, evtData *FieldCollection) string {
// DeriveUser takes an irc.Message and a FieldCollection and tries // DeriveUser takes an irc.Message and a FieldCollection and tries
// to extract from them the user causing the event / message // to extract from them the user causing the event / message
func DeriveUser(m *irc.Message, evtData *FieldCollection) string { func DeriveUser(m *irc.Message, evtData *fieldcollection.FieldCollection) string {
if m != nil && m.User != "" { if m != nil && m.User != "" {
return m.User return m.User
} }

View file

@ -7,6 +7,7 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
) )
@ -15,7 +16,7 @@ type (
// Actor defines an interface to implement in the plugin for actors // Actor defines an interface to implement in the plugin for actors
Actor interface { Actor interface {
// Execute will be called after the config was read into the Actor // Execute will be called after the config was read into the Actor
Execute(c *irc.Client, m *irc.Message, r *Rule, evtData *FieldCollection, attrs *FieldCollection) (preventCooldown bool, err error) Execute(c *irc.Client, m *irc.Message, r *Rule, evtData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (preventCooldown bool, err error)
// IsAsync may return true if the Execute function is to be executed // IsAsync may return true if the Execute function is to be executed
// in a Go routine as of long runtime. Normally it should return false // in a Go routine as of long runtime. Normally it should return false
// except in very specific cases // except in very specific cases
@ -26,7 +27,7 @@ type (
// Validate will be called to validate the loaded configuration. It should // Validate will be called to validate the loaded configuration. It should
// return an error if required keys are missing from the AttributeStore // return an error if required keys are missing from the AttributeStore
// or if keys contain broken configs // or if keys contain broken configs
Validate(TemplateValidatorFunc, *FieldCollection) error Validate(TemplateValidatorFunc, *fieldcollection.FieldCollection) error
} }
// ActorCreationFunc is a function to return a new instance of the // ActorCreationFunc is a function to return a new instance of the
@ -62,7 +63,7 @@ type (
// EventHandlerFunc defines the type of function required to listen // EventHandlerFunc defines the type of function required to listen
// for events // for events
EventHandlerFunc func(evt string, eventData *FieldCollection) error EventHandlerFunc func(evt string, eventData *fieldcollection.FieldCollection) error
// EventHandlerRegisterFunc is passed from the bot to the // EventHandlerRegisterFunc is passed from the bot to the
// plugins RegisterFunc to register a new event handler function // plugins RegisterFunc to register a new event handler function
// which is then fed with all events occurring in the bot // which is then fed with all events occurring in the bot
@ -76,12 +77,12 @@ type (
// ModuleConfigGetterFunc is passed from the bot to the // ModuleConfigGetterFunc is passed from the bot to the
// plugins RegisterFunc to fetch module generic or channel specific // plugins RegisterFunc to fetch module generic or channel specific
// configuration from the module configuration // configuration from the module configuration
ModuleConfigGetterFunc func(module, channel string) *FieldCollection ModuleConfigGetterFunc func(module, channel string) *fieldcollection.FieldCollection
// MsgFormatter is passed from the bot to the // MsgFormatter is passed from the bot to the
// plugins RegisterFunc to format messages using all registered and // plugins RegisterFunc to format messages using all registered and
// available template functions // available template functions
MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields *FieldCollection) (string, error) MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields *fieldcollection.FieldCollection) (string, error)
// MsgModificationFunc can be used to modify messages between the // MsgModificationFunc can be used to modify messages between the
// plugins generating them and the bot sending them to the Twitch // plugins generating them and the bot sending them to the Twitch
@ -160,7 +161,7 @@ type (
// TemplateFuncGetter is the type of function to implement in the // TemplateFuncGetter is the type of function to implement in the
// plugin to create a new template function on request of the bot // plugin to create a new template function on request of the bot
TemplateFuncGetter func(*irc.Message, *Rule, *FieldCollection) any TemplateFuncGetter func(*irc.Message, *Rule, *fieldcollection.FieldCollection) any
// TemplateFuncRegister is passed from the bot to the // TemplateFuncRegister is passed from the bot to the
// plugins RegisterFunc to register a new TemplateFuncGetter // plugins RegisterFunc to register a new TemplateFuncGetter
TemplateFuncRegister func(name string, fg TemplateFuncGetter, doc ...TemplateFuncDocumentation) TemplateFuncRegister func(name string, fg TemplateFuncGetter, doc ...TemplateFuncDocumentation)
@ -184,5 +185,5 @@ var ErrSkipSendingMessage = errors.New("skip sending message")
// requiring access to the irc.Message, Rule or FieldCollection to // requiring access to the irc.Message, Rule or FieldCollection to
// satisfy the TemplateFuncGetter interface // satisfy the TemplateFuncGetter interface
func GenericTemplateFunctionGetter(f any) TemplateFuncGetter { func GenericTemplateFunctionGetter(f any) TemplateFuncGetter {
return func(*irc.Message, *Rule, *FieldCollection) any { return f } return func(*irc.Message, *Rule, *fieldcollection.FieldCollection) any { return f }
} }

View file

@ -1,6 +1,10 @@
package plugins package plugins
import "strings" import (
"strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
)
// DefaultConfigName is the name the default configuration must have // DefaultConfigName is the name the default configuration must have
// when defined // when defined
@ -9,16 +13,16 @@ const DefaultConfigName = "default"
type ( type (
// ModuleConfig represents a mapping of configurations per channel // ModuleConfig represents a mapping of configurations per channel
// and module // and module
ModuleConfig map[string]map[string]*FieldCollection ModuleConfig map[string]map[string]*fieldcollection.FieldCollection
) )
// GetChannelConfig reads the channel specific configuration for the // GetChannelConfig reads the channel specific configuration for the
// given module. This is created by taking an empty FieldCollection, // given module. This is created by taking an empty FieldCollection,
// merging in the default configuration and finally overwriting all // merging in the default configuration and finally overwriting all
// existing channel configurations. // existing channel configurations.
func (m ModuleConfig) GetChannelConfig(module, channel string) *FieldCollection { func (m ModuleConfig) GetChannelConfig(module, channel string) *fieldcollection.FieldCollection {
channel = strings.TrimLeft(channel, "#@") channel = strings.TrimLeft(channel, "#@")
composed := NewFieldCollection() composed := fieldcollection.NewFieldCollection()
for _, i := range []string{DefaultConfigName, channel} { for _, i := range []string{DefaultConfigName, channel} {
f := m[module][i] f := m[module][i]

View file

@ -3,6 +3,7 @@ package plugins
import ( import (
"testing" "testing"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -10,12 +11,12 @@ import (
func TestModuleConfigGet(t *testing.T) { func TestModuleConfigGet(t *testing.T) {
strPtrEmpty := func(v string) *string { return &v }("") strPtrEmpty := func(v string) *string { return &v }("")
m := ModuleConfig{ m := ModuleConfig{
"test": map[string]*FieldCollection{ "test": map[string]*fieldcollection.FieldCollection{
DefaultConfigName: FieldCollectionFromData(map[string]any{ DefaultConfigName: fieldcollection.FieldCollectionFromData(map[string]any{
"setindefault": DefaultConfigName, "setindefault": DefaultConfigName,
"setinboth": DefaultConfigName, "setinboth": DefaultConfigName,
}), }),
"test": FieldCollectionFromData(map[string]any{ "test": fieldcollection.FieldCollectionFromData(map[string]any{
"setinchannel": "channel", "setinchannel": "channel",
"setinboth": "channel", "setinboth": "channel",
}), }),

View file

@ -17,6 +17,7 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
) )
@ -73,8 +74,8 @@ type (
// RuleAction represents an action to be executed when running a Rule // RuleAction represents an action to be executed when running a Rule
RuleAction struct { RuleAction struct {
Type string `json:"type" yaml:"type,omitempty"` Type string `json:"type" yaml:"type,omitempty"`
Attributes *FieldCollection `json:"attributes" yaml:"attributes,omitempty"` Attributes *fieldcollection.FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
} }
) )
@ -89,7 +90,7 @@ func (r Rule) MatcherID() string {
} }
// Matches checks whether the Rule should be executed for the given parameters // Matches checks whether the Rule should be executed for the given parameters
func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *FieldCollection) bool { func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *fieldcollection.FieldCollection) bool {
r.msgFormatter = msgFormatter r.msgFormatter = msgFormatter
r.timerStore = timerStore r.timerStore = timerStore
r.twitchClient = twitchClient r.twitchClient = twitchClient
@ -102,7 +103,7 @@ func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msg
}) })
) )
for _, matcher := range []func(*logrus.Entry, *irc.Message, *string, twitch.BadgeCollection, *FieldCollection) bool{ for _, matcher := range []func(*logrus.Entry, *irc.Message, *string, twitch.BadgeCollection, *fieldcollection.FieldCollection) bool{
r.allowExecuteDisable, r.allowExecuteDisable,
r.allowExecuteChannelWhitelist, r.allowExecuteChannelWhitelist,
r.allowExecuteUserWhitelist, r.allowExecuteUserWhitelist,
@ -144,7 +145,7 @@ func (r *Rule) GetMatchMessage() *regexp.Regexp {
// SetCooldown uses the given TimerStore to set the cooldowns for the // SetCooldown uses the given TimerStore to set the cooldowns for the
// Rule after execution // Rule after execution
func (r *Rule) SetCooldown(timerStore TimerStore, m *irc.Message, evtData *FieldCollection) { func (r *Rule) SetCooldown(timerStore TimerStore, m *irc.Message, evtData *fieldcollection.FieldCollection) {
var err error var err error
if r.Cooldown != nil { if r.Cooldown != nil {
@ -250,7 +251,7 @@ func (r Rule) Validate(tplValidate TemplateValidatorFunc) error {
return nil return nil
} }
func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
for _, b := range r.DisableOn { for _, b := range r.DisableOn {
if badges.Has(b) { if badges.Has(b) {
logger.Tracef("Non-Match: Disable-Badge %s", b) logger.Tracef("Non-Match: Disable-Badge %s", b)
@ -261,7 +262,7 @@ func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message,
return true return true
} }
func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
if len(r.EnableOn) == 0 { if len(r.EnableOn) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -276,7 +277,7 @@ func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *st
return false return false
} }
func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if r.ChannelCooldown == nil || DeriveChannel(m, evtData) == "" { if r.ChannelCooldown == nil || DeriveChannel(m, evtData) == "" {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -301,7 +302,7 @@ func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message,
return false return false
} }
func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if len(r.MatchChannels) == 0 { if len(r.MatchChannels) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -315,7 +316,7 @@ func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message
return true return true
} }
func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *string, _ twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
if r.Disable == nil { if r.Disable == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -329,7 +330,7 @@ func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *stri
return true return true
} }
func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if r.DisableOnOffline == nil || !*r.DisableOnOffline || DeriveChannel(m, evtData) == "" { if r.DisableOnOffline == nil || !*r.DisableOnOffline || DeriveChannel(m, evtData) == "" {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -348,7 +349,7 @@ func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message
return true return true
} }
func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
hasPermit, err := r.timerStore.HasPermit(DeriveChannel(m, evtData), DeriveUser(m, evtData)) hasPermit, err := r.timerStore.HasPermit(DeriveChannel(m, evtData), DeriveUser(m, evtData))
if err != nil { if err != nil {
logger.WithError(err).Error("checking permit") logger.WithError(err).Error("checking permit")
@ -363,7 +364,7 @@ func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message,
return true return true
} }
func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" { if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -384,7 +385,7 @@ func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Messag
return true return true
} }
func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, event *string, _ twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, event *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
// The user defines either no event to match or they define an // The user defines either no event to match or they define an
// event to match. We now need to ensure this match is valid for // event to match. We now need to ensure this match is valid for
// the current execution: // the current execution:
@ -430,7 +431,7 @@ func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, even
return false return false
} }
func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
if len(r.DisableOnMatchMessages) == 0 { if len(r.DisableOnMatchMessages) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -459,7 +460,7 @@ func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.
return true return true
} }
func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
if r.MatchMessage == nil { if r.MatchMessage == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -484,7 +485,7 @@ func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.
return true return true
} }
func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *FieldCollection) bool { func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
if r.Cooldown == nil { if r.Cooldown == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -509,7 +510,7 @@ func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _
return false return false
} }
func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if r.UserCooldown == nil { if r.UserCooldown == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -534,7 +535,7 @@ func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _
return false return false
} }
func (r *Rule) allowExecuteUserWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *FieldCollection) bool { func (r *Rule) allowExecuteUserWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
if len(r.MatchUsers) == 0 { if len(r.MatchUsers) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
) )
@ -141,7 +142,7 @@ func TestAllowExecuteDisableOnTemplate(t *testing.T) {
} { } {
// We don't test the message formatter here but only the disable functionality // We don't test the message formatter here but only the disable functionality
// so we fake the result of the evaluation // so we fake the result of the evaluation
r.msgFormatter = func(string, *irc.Message, *Rule, *FieldCollection) (string, error) { r.msgFormatter = func(string, *irc.Message, *Rule, *fieldcollection.FieldCollection) (string, error) {
return msg, nil return msg, nil
} }

View file

@ -10,6 +10,7 @@ import (
"gopkg.in/irc.v4" "gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/backoff" "github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/actors/announce" "github.com/Luzifer/twitch-bot/v3/internal/actors/announce"
"github.com/Luzifer/twitch-bot/v3/internal/actors/ban" "github.com/Luzifer/twitch-bot/v3/internal/actors/ban"
@ -181,12 +182,12 @@ func getRegistrationArguments() plugins.RegistrationArguments {
SendMessage: sendMessage, SendMessage: sendMessage,
ValidateToken: authService.ValidateTokenFor, ValidateToken: authService.ValidateTokenFor,
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error { CreateEvent: func(evt string, eventData *fieldcollection.FieldCollection) error {
handleMessage(ircHdl.Client(), nil, &evt, eventData) handleMessage(ircHdl.Client(), nil, &evt, eventData)
return nil return nil
}, },
GetModuleConfigForChannel: func(module, channel string) *plugins.FieldCollection { GetModuleConfigForChannel: func(module, channel string) *fieldcollection.FieldCollection {
return config.ModuleConfig.GetChannelConfig(module, channel) return config.ModuleConfig.GetChannelConfig(module, channel)
}, },

View file

@ -4,20 +4,21 @@ import "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
var ( var (
channelExtendedScopes = map[string]string{ channelExtendedScopes = map[string]string{
twitch.ScopeChannelEditCommercial: "run commercial", twitch.ScopeChannelEditCommercial: "run commercial",
twitch.ScopeChannelManageBroadcast: "modify category / title", twitch.ScopeChannelManageBroadcast: "modify category / title",
twitch.ScopeChannelManagePolls: "manage polls", twitch.ScopeChannelManagePolls: "manage polls",
twitch.ScopeChannelManagePredictions: "manage predictions", twitch.ScopeChannelManagePredictions: "manage predictions",
twitch.ScopeChannelManageRaids: "start raids", twitch.ScopeChannelManageRaids: "start raids",
twitch.ScopeChannelManageVIPS: "manage VIPs", twitch.ScopeChannelManageVIPS: "manage VIPs",
twitch.ScopeChannelReadAds: "see when an ad-break starts", twitch.ScopeChannelReadAds: "see when an ad-break starts",
twitch.ScopeChannelReadHypetrain: "see Hype-Train events", twitch.ScopeChannelReadHypetrain: "see Hype-Train events",
twitch.ScopeChannelReadRedemptions: "see channel-point redemptions", twitch.ScopeChannelReadRedemptions: "see channel-point redemptions",
twitch.ScopeChannelReadSubscriptions: "see subscribed users / sub count / points", twitch.ScopeChannelReadSubscriptions: "see subscribed users / sub count / points",
twitch.ScopeClipsEdit: "create clips on behalf of this user", twitch.ScopeClipsEdit: "create clips on behalf of this user",
twitch.ScopeModeratorReadFollowers: "see who follows this channel", twitch.ScopeModeratorReadFollowers: "see who follows this channel",
twitch.ScopeModeratorReadShoutouts: "see shoutouts created / received", twitch.ScopeModeratorReadShoutouts: "see shoutouts created / received",
twitch.ScopeUserManageWhispers: "send whispers on behalf of this user", twitch.ScopeModeratorReadSuspiciousUsers: "see users marked suspicious / restricted",
twitch.ScopeUserManageWhispers: "send whispers on behalf of this user",
} }
botDefaultScopes = []string{ botDefaultScopes = []string{

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