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
* New Features

View file

@ -15,6 +15,9 @@ lint:
publish: frontend_prod
bash ./ci/build.sh
short_test:
go test -cover -test.short -v ./...
test:
go test -cover -v ./...
@ -33,7 +36,7 @@ frontend_lint: node_modules
src
node_modules:
npm ci
npm ci --include dev
# --- Tools
@ -41,6 +44,9 @@ update_ua_list:
# 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
gh-workflow:
bash ci/create-workflow.sh
# -- Vulnerability scanning --
trivy:

View file

@ -4,12 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/pkg/errors"
"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/plugins"
)
@ -49,7 +51,7 @@ func init() {
type ActorScript struct{}
// 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")
if err != nil {
return false, errors.Wrap(err, "getting command")
@ -130,13 +132,16 @@ func (ActorScript) IsAsync() bool { return false }
func (ActorScript) Name() string { return "script" }
// Validate implements actor interface
func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
cmd, err := attrs.StringSlice("command")
if err != nil || len(cmd) == 0 {
return errors.New("command must be slice of strings with length > 0")
func (ActorScript) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "command", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeStringSlice}),
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 {
return errors.Wrapf(err, "validating cmd template (element %d)", i)
}

View file

@ -7,6 +7,7 @@ import (
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -41,7 +42,7 @@ func registerAction(name string, acf plugins.ActorCreationFunc) {
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()
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")
}
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
if event != nil {
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 (
ruleEventData = plugins.NewFieldCollection()
ruleEventData = fieldcollection.NewFieldCollection()
preventCooldown bool
)

View file

@ -13,8 +13,8 @@ import (
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"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)
@ -174,7 +174,7 @@ func (a *autoMessage) allowExecuteDisableOnTemplate() bool {
return true
}
fields := plugins.NewFieldCollection()
fields := fieldcollection.NewFieldCollection()
fields.Set("channel", a.Channel)
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)
;;
tag)
# Build to latest & stable: Older tags are not intended to rebuild
tags+=(latest stable ${GITHUB_REF_NAME})
# Build to latest: Older tags are not intended to rebuild
tags+=(develop latest ${GITHUB_REF_NAME})
;;
*)
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/yaml.v3"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -303,7 +304,7 @@ func (c *configFile) CloseRawMessageWriter() (err error) {
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()
defer configLock.RUnlock()

View file

@ -8,10 +8,10 @@ Ad-break has begun and ads are playing now in mentioned channel.
Fields:
- `channel` - The channel the event occurred in
- `duration` - Duration of the ads in seconds
- `is_automatic` - Were the ads started by the ad-manager?
- `started_at` - When did the ad-break start
- `channel` _string_ - The channel the event occurred in
- `duration` _int64_ - Duration of the ads in seconds
- `is_automatic` _bool_ - Were the ads started by the ad-manager?
- `started_at` _time.Time_ - When did the ad-break start
## `ban`
@ -21,9 +21,9 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields:
- `channel` - The channel the event occurred in
- `target_id` - The ID of the user being banned
- `target_name` - The login-name of the user being banned
- `channel` _string_ - The channel the event occurred in
- `target_id` _string_ - The ID of the user being banned
- `target_name` _string_ - The login-name of the user being banned
## `bits`
@ -31,9 +31,9 @@ User spent bits in the channel. The full message is available like in a normal c
Fields:
- `bits` - Total amount of bits spent in the message
- `channel` - The channel the event occurred in
- `username` - The login-name of the user who spent the bits
- `bits` _int64_ - Total amount of bits spent in the message
- `channel` _string_ - The channel the event occurred in
- `username` _string_ - The login-name of the user who spent the bits
## `category_update`
@ -41,8 +41,8 @@ The current category for the channel was changed. (This event has some delay to
Fields:
- `category` - The name of the new game / category
- `channel` - The channel the event occurred in
- `category` _string_ - The name of the new game / category
- `channel` _string_ - The channel the event occurred in
## `channelpoint_redeem`
@ -50,14 +50,14 @@ A custom channel-point reward was redeemed in the given channel. (Only available
Fields:
- `channel` - The channel the event occurred in
- `reward_cost` - Number of points the user paid for the reward
- `reward_id` - ID of the reward the user redeemed
- `reward_title` - Title of the reward the user redeemed
- `status` - Status of the reward (one of `unknown`, `unfulfilled`, `fulfilled`, and `canceled`)
- `user_id` - The ID of the user who redeemed the reward
- `user_input` - The text the user entered into the input for the reward
- `user` - The login-name of the user who redeemed the reward
- `channel` _string_ - The channel the event occurred in
- `reward_cost` _int64_ - Number of points the user paid for the reward
- `reward_id` _string_ - ID of the reward the user redeemed
- `reward_title` _string_ - Title of the reward the user redeemed
- `status` _string_ - Status of the reward (one of `unknown`, `unfulfilled`, `fulfilled`, and `canceled`)
- `user_id` _string_ - The ID of the user who redeemed the reward
- `user_input` _string_ - The text the user entered into the input for the reward
- `user` _string_ - The login-name of the user who redeemed the reward
## `clearchat`
@ -67,7 +67,7 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields:
- `channel` - The channel the event occurred in
- `channel` _string_ - The channel the event occurred in
## `delete`
@ -77,9 +77,9 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields:
- `channel` - The channel the event occurred in
- `message_id` - The UUID of the message being deleted
- `target_name` - Login name of the author of the deleted message
- `channel` _string_ - The channel the event occurred in
- `message_id` _string_ - The UUID of the message being deleted
- `target_name` _string_ - Login name of the author of the deleted message
## `follow`
@ -87,10 +87,10 @@ User followed the channel. This event is not de-duplicated and therefore might b
Fields:
- `channel` - The channel the event occurred in
- `followed_at` - Time object of the follow date
- `user_id` - ID of the newly following user
- `user` - The login-name of the user who followed
- `channel` _string_ - The channel the event occurred in
- `followed_at` _time.Time_ - Time object of the follow date
- `user_id` _string_ - ID of the newly following user
- `user` _string_ - The login-name of the user who followed
## `giftpaidupgrade`
@ -98,9 +98,9 @@ User upgraded their gifted subscription into a paid one. This event does not con
Fields:
- `channel` - The channel the event occurred in
- `gifter` - The login-name of the user who gifted the subscription
- `username` - The login-name of the user who upgraded their subscription
- `channel` _string_ - The channel the event occurred in
- `gifter` _string_ - The login-name of the user who gifted the subscription
- `username` _string_ - The login-name of the user who upgraded their subscription
## `hypetrain_begin`, `hypetrain_end`, `hypetrain_progress`
@ -108,10 +108,10 @@ An Hype-Train has begun, ended or progressed in the given channel.
Fields:
- `channel` - The channel the event occurred in
- `level` - 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`)
- `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)
- `channel` _string_ - The channel the event occurred in
- `level` _int64_ - The current level of the Hype-Train
- `levelProgress` _float64_ - Percentage of reached "points" in the current level to complete the level (not available on `hypetrain_end`)
- `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`
@ -119,8 +119,8 @@ User joined the channel-chat. This is **NOT** an indicator they are viewing, the
Fields:
- `channel` - The channel the event occurred in
- `user` - The login-name of the user who joined
- `channel` _string_ - The channel the event occurred in
- `user` _string_ - The login-name of the user who joined
## `kofi_donation`
@ -128,14 +128,14 @@ A Ko-fi donation was received through the API-Webhook.
Fields:
- `channel` - The channel the event occurred for
- `from` - The name submitted by Ko-fi (can be arbitrarily entered)
- `amount` - The amount donated as submitted by Ko-fi (i.e. 27.95)
- `currency` - The currency of the amount (i.e. USD)
- `isSubscription` - Boolean, true on monthly subscriptions, false on single-donations
- `isFirstSubPayment` - Boolean, true on first montly payment, false otherwise
- `message` - 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?)
- `channel` _string_ - The channel the event occurred for
- `from` _string_ - The name submitted by Ko-fi (can be arbitrarily entered)
- `amount` _float64_ - The amount donated as submitted by Ko-fi (i.e. 27.95)
- `currency` _string_ - The currency of the amount (i.e. USD)
- `isSubscription` _bool_ - true on monthly subscriptions, false on single-donations
- `isFirstSubPayment` _bool_ - true on first montly payment, false otherwise
- `message` _string_ - The message entered by the donator (**not** present when donation was marked as private!)
- `tier` _string_ - The tier the subscriber subscribed to (seems not to be filled on the first transaction?)
## `outbound_raid`
@ -143,10 +143,10 @@ The channel has raided another channel. (The event is issued in the moment the r
Fields:
- `channel` - The channel the raid originated at
- `to` - The login-name of the channel the viewers are sent to
- `to_id` - The ID of the channel the viewers are sent to
- `viewers` - The number of viewers included in the raid
- `channel` _string_ - The channel the raid originated at
- `to` _string_ - The login-name of the channel the viewers are sent to
- `to_id` _string_ - The ID of the channel the viewers are sent to
- `viewers` _int64_ - The number of viewers included in the raid
## `part`
@ -154,8 +154,8 @@ User left the channel-chat. This is **NOT** an indicator they are no longer view
Fields:
- `channel` - The channel the event occurred in
- `user` - The login-name of the user who left
- `channel` _string_ - The channel the event occurred in
- `user` _string_ - The login-name of the user who left
## `permit`
@ -163,9 +163,9 @@ User received a permit, which means they are no longer affected by rules which a
Fields:
- `channel` - The channel the event occurred in
- `user` - The login-name of the user who **gave** the permit
- `to` - The username who got the permit
- `channel` _string_ - The channel the event occurred in
- `user` _string_ - The login-name of the user who **gave** the permit
- `to` _string_ - The username who got the permit
## `poll_begin` / `poll_end` / `poll_progress`
@ -173,10 +173,10 @@ A poll was started / was ended / had changes in the given channel.
Fields:
- `channel` - 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)
- `status` - 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
- `channel` _string_ - The channel the event occurred in
- `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` _string_ - The status of the poll (one of `completed`, `terminated` or `archived`) - only available in `poll_end`
- `title` _string_ - The title of the poll the event was generated for
## `raid`
@ -184,9 +184,9 @@ The channel was raided by another user.
Fields:
- `channel` - The channel the event occurred in
- `username` - 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)
- `channel` _string_ - The channel the event occurred in
- `username` _string_ - The login-name of the user who raided the channel
- `viewercount` _int64_ - The amount of users who have been raided (this number is not fully accurate)
## `resub`
@ -194,10 +194,10 @@ The user shared their resubscription. (This event is triggered manually by the u
Fields:
- `channel` - The channel the event occurred in
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` - How long have they been subscribed
- `username` - The login-name of the user who resubscribed
- `channel` _string_ - The channel the event occurred in
- `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` _int64_ - How long have they been subscribed
- `username` _string_ - The login-name of the user who resubscribed
## `shoutout_created`
@ -205,10 +205,10 @@ The channel gave another streamer a (Twitch native) shoutout
Fields:
- `channel` - The channel the event occurred in
- `to_id` - The ID of the channel who received the shoutout
- `to` - The login-name of the channel who received the shoutout
- `viewers` - The amount of viewers the shoutout was shown to
- `channel` _string_ - The channel the event occurred in
- `to_id` _string_ - The ID of the channel who received the shoutout
- `to` _string_ - The login-name of the channel who received the shoutout
- `viewers` _int64_ - The amount of viewers the shoutout was shown to
## `shoutout_received`
@ -216,10 +216,10 @@ The channel received a (Twitch native) shoutout by another channel.
Fields:
- `channel` - The channel the event occurred in
- `from_id` - The ID of the channel who issued the shoutout
- `from` - The login-name of the channel who issued the shoutout
- `viewers` - The amount of viewers the shoutout was shown to
- `channel` _string_ - The channel the event occurred in
- `from_id` _string_ - The ID of the channel who issued the shoutout
- `from` _string_ - The login-name of the channel who issued the shoutout
- `viewers` _int64_ - The amount of viewers the shoutout was shown to
## `stream_offline`
@ -227,7 +227,7 @@ The channels stream went offline. (This event has some delay to the real categor
Fields:
- `channel` - The channel the event occurred in
- `channel` _string_ - The channel the event occurred in
## `stream_online`
@ -235,7 +235,7 @@ The channels stream went offline. (This event has some delay to the real categor
Fields:
- `channel` - The channel the event occurred in
- `channel` _string_ - The channel the event occurred in
## `sub`
@ -243,9 +243,9 @@ The user newly subscribed on their own. (This event is triggered automatically a
Fields:
- `channel` - The channel the event occurred in
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `username` - The login-name of the user who subscribed
- `channel` _string_ - The channel the event occurred in
- `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `username` _string_ - The login-name of the user who subscribed
## `subgift`
@ -253,13 +253,14 @@ The user gifted the subscription to a specific user. (This event **DOES** occur
Fields:
- `channel` - The channel the event occurred in
- `gifted_months` - 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)
- `plan` - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` - How long the recipient has been subscribed
- `to` - The user who received the sub
- `username` - The login-name of the user who gifted the subscription
- `channel` _string_ - The channel the event occurred in
- `gifted_months` _int64_ - Number of months the user gifted
- `origin_id` _string_ - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event)
- `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `subscribed_months` _int64_ - How long the recipient has been subscribed
- `to` _string_ - The user who received the sub
- `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`
@ -267,11 +268,34 @@ The user gifted multiple subs to the community. (This event is followed by `numb
Fields:
- `channel` - The channel the event occurred in
- `number` - The amount of gifted subs
- `origin_id` - 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`)
- `username` - The login-name of the user who gifted the subscription
- `channel` _string_ - The channel the event occurred in
- `number` _int64_ - The amount of gifted subs
- `origin_id` _string_ - ID unique to the gift-event (can be used to match `subgift` events to corresponding `submysterygift` event)
- `plan` _string_ - The sub-plan they are using (`1000` = T1, `2000` = T2, `3000` = T3, `Prime`)
- `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`
@ -281,11 +305,11 @@ Note: This event does **not** contain the acting user! You cannot use the `{{.us
Fields:
- `channel` - The channel the event occurred in
- `duration` - The timeout duration (`time.Duration`, nanoseconds)
- `seconds` - The timeout duration (`int`, seconds)
- `target_id` - The ID of the user being timed out
- `target_name` - The login-name of the user being timed out
- `channel` _string_ - The channel the event occurred in
- `duration` _time.Duration_ - The timeout duration (nanoseconds)
- `seconds` _int_ - The timeout duration (seconds)
- `target_id` _string_ - The ID of the user being timed out
- `target_name` _string_ - The login-name of the user being timed out
## `title_update`
@ -293,8 +317,8 @@ The current title for the channel was changed. (This event has some delay to the
Fields:
- `channel` - The channel the event occurred in
- `title` - The title of the stream
- `channel` _string_ - The channel the event occurred in
- `title` _string_ - The title of the stream
## `whisper`
@ -302,4 +326,4 @@ The bot received a whisper message. (You can use `(.*)` as message match and `{{
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`
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:
@ -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: 70%
< Your int this hour: 88%
```
### `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 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"
- Check the ToS box (of course after reading those!)
- 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.
{{< 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:
```yaml
# Module configuration by channel or defining bot-wide defaults. See
# module specific documentation for options to configure in this
# section. All modules come with internal defaults so there is no
# need to configure this but you can overwrite the internal defaults.
# section.
module_config:
spotify:
# Use one client-id for all channels (invite users)
default:
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".

View file

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

View file

@ -11,6 +11,7 @@ import (
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str"
korvike "github.com/Luzifer/korvike/functions"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -37,7 +38,7 @@ func newTemplateFuncProvider() *templateFuncProvider {
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()
defer t.lock.RUnlock()

View file

@ -6,12 +6,13 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
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) {
msgParts := strings.Split(m.Trailing(), " ")
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 {
badges := twitch.ParseBadgeLevels(m)
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) {
fields := r.GetMatchMessage().FindStringSubmatch(m.Trailing())
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] }
}, plugins.TemplateFuncDocumentation{
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 (
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/rconfig/v2 v2.5.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.27.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/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.8.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/orandin/sentrus v1.0.0
github.com/pkg/errors v0.9.1
@ -31,7 +31,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.6
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.25.8
gorm.io/gorm v1.25.9
)
require (
@ -87,7 +87,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.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/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/cast v1.6.0 // indirect
@ -105,6 +105,6 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.49.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
)

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/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_helpers/v2 v2.23.0 h1:VowDwOCl6nOt+GVqKUX/do6a94pEeqNTRHb29MsoGX4=
github.com/Luzifer/go_helpers/v2 v2.23.0/go.mod h1:BSGkJ/dxqs7AxsfZt8zjJb4R6YB5dONS+/ad7foLUrk=
github.com/Luzifer/go_helpers/v2 v2.24.0 h1:abACOhsn6a6c6X22jq42mZM1wuOM0Ihfa6yzssrjrOg=
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/go.mod h1:osumwH64mWgbwZIfE7rE0BB7Y5HXxrzyO4JfO7fhduU=
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/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
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.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
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/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
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-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
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 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
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/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
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.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
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.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI=
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/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
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/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
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.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
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.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
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/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
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.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
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-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
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/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
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/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=

View file

@ -14,14 +14,14 @@ import (
)
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
args.RegisterMessageModFunc("/announce", handleChatCommand)
@ -36,7 +36,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -11,6 +11,8 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
@ -18,7 +20,7 @@ import (
const actorName = "ban"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter
banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`)
@ -26,7 +28,7 @@ var (
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) (err error) {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -87,7 +89,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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 }("")
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(
botTwitchClient.BanUser(
botTwitchClient().BanUser(
context.Background(),
plugins.DeriveChannel(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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
reasonTemplate, err := attrs.String("reason")
if err != nil || reasonTemplate == "" {
return errors.New("reason must be non-empty string")
}
if err = tplValidator(reasonTemplate); err != nil {
return errors.Wrap(err, "validating reason template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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)
}
return nil
@ -131,7 +132,7 @@ func handleAPIBan(w http.ResponseWriter, r *http.Request) {
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)
return
}
@ -147,7 +148,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -9,6 +9,8 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
@ -19,9 +21,6 @@ var (
formatMessage plugins.MsgFormatter
hasPerm plugins.ChannelPermissionCheckFunc
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
@ -71,7 +70,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if channel, err = formatMessage(attrs.MustString("channel", &channel), m, r, eventData); err != nil {
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)
}
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 {
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) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
for _, field := range []string{"channel", "creator"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
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

View file

@ -9,6 +9,7 @@ import (
"github.com/pkg/errors"
"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/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -17,13 +18,13 @@ import (
const actorName = "clipdetector"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
clipIDScanner = regexp.MustCompile(`(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/([A-Za-z0-9_-]+)`)
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -40,7 +41,7 @@ func Register(args plugins.RegistrationArguments) error {
type Actor struct{}
// 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") {
// We already detected clips, lets not do it again
return false, nil
@ -63,7 +64,7 @@ func (Actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
continue
}
clipInfo, err := botTwitchClient.GetClipByID(context.Background(), clipIDMatch[1])
clipInfo, err := botTwitchClient().GetClipByID(context.Background(), clipIDMatch[1])
if err != nil {
return false, errors.Wrap(err, "getting clip info")
}
@ -82,4 +83,6 @@ func (Actor) IsAsync() bool { return false }
func (Actor) Name() string { return actorName }
// 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 (
"context"
"fmt"
"regexp"
"strconv"
"strings"
@ -10,6 +11,8 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
@ -61,7 +64,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
durationTemplate, err := attrs.String("duration")
if err != nil || durationTemplate == "" {
return errors.New("duration must be non-empty string")
}
if err = tplValidator(durationTemplate); err != nil {
return errors.Wrap(err, "validating duration template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "duration", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "duration"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -7,12 +7,15 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"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/plugins"
)
@ -21,7 +24,7 @@ var (
db database.Connector
formatMessage plugins.MsgFormatter
ptrStringEmpty = func(s string) *string { return &s }("")
errNotAValue = fmt.Errorf("not a value")
)
// Register provides the plugins.RegisterFunc
@ -135,7 +138,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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) {
channel, err := fields.String("channel")
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) {
return getCounterTopList(db, prefix, n)
args.RegisterTemplateFunction("counterTopList", plugins.GenericTemplateFunctionGetter(func(prefix string, n int, orderBy string) ([]counter, error) {
return getCounterTopList(db, prefix, n, orderBy)
}), plugins.TemplateFuncDocumentation{
Description: "Returns the top n counters for the given prefix as objects with Name and Value fields",
Syntax: `counterTopList <prefix> <n>`,
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> [orderBy]`,
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ range (counterTopList (list .channel "test" "" | join ":") 3) }}{{ .Name }}: {{ .Value }} - {{ end }}`,
FakedOutput: "#example:test:foo: 5 - #example:test:bar: 4 - ",
@ -193,7 +196,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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")
}
@ -212,65 +215,114 @@ func Register(args plugins.RegistrationArguments) (err error) {
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)
if err != nil {
return false, errors.Wrap(err, "preparing response")
}
if counterSet := attrs.MustString("counter_set", ptrStringEmpty); counterSet != "" {
parseValue, err := formatMessage(counterSet, m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "execute counter value template")
// First lets look whether we shall set the counter (counter_set is
// defined and the template evaluates into something which is not
// an empty string)
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)
if err != nil {
return false, errors.Wrap(err, "parse counter value")
}
case errors.Is(err, errNotAValue):
// Nope, not a set but that's fine, we just go to step-adjustment
return false, errors.Wrap(
updateCounter(db, counterName, counterValue, true),
"set counter",
)
default:
// B0rked
return false, fmt.Errorf("parsing counter-set: %w", err)
}
var counterStep int64 = 1
if s := attrs.MustString("counter_step", ptrStringEmpty); s != "" {
parseStep, err := formatMessage(s, m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "execute counter step template")
// Second check whether we do have a template in counter_step and it
// evaluates into a non-empty string and then adjust the counter
// accordingly
counterStep, err := a.parseAttributeTemplateToNumber(m, r, eventData, attrs, "counter_step", 1)
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)
if err != nil {
return false, errors.Wrap(err, "parse counter step")
}
default:
// B0rked
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) Name() string { return "counter" }
func (actorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if cn, err := attrs.String("counter"); err != nil || cn == "" {
return errors.New("counter name must be non-empty string")
}
for _, field := range []string{"counter", "counter_step", "counter_set"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actorCounter) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "counter", "counter_step", "counter_set"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
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) {
template := r.FormValue("template")
if template == "" {
@ -299,7 +351,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
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)
return
}

View file

@ -1,18 +1,25 @@
package counter
import (
"fmt"
"strings"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"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/pkg/database"
)
type (
counter struct {
Name string `gorm:"primaryKey"`
Value int64
Name string `gorm:"primaryKey"`
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
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 {
cv, err := getCounterValue(db, counterName)
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 {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(counter{Name: counterName, Value: value}).Error
DoUpdates: clause.AssignmentColumns([]string{"last_modified", "value"}),
}).Create(counter{Name: counterName, Value: value, FirstSeen: atTime.UTC(), LastModified: atTime.UTC()}).Error
}),
"storing counter value",
)
@ -75,12 +82,39 @@ func getCounterRank(db database.Connector, prefix, name string) (rank, count int
return rank, count, nil
}
func getCounterTopList(db database.Connector, prefix string, n int) ([]counter, error) {
var cc []counter
func getCounterTopList(db database.Connector, prefix string, n int, orderBy ...string) ([]counter, error) {
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 {
return db.DB().
Order("value DESC").
Order(strings.Join([]string{col, dir}, " ")).
Limit(n).
Find(&cc, "name LIKE ?", prefix+"%").
Error

View file

@ -3,6 +3,7 @@ package counter
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -20,12 +21,19 @@ func TestCounterStoreLoop(t *testing.T) {
assert.NoError(t, err, "reading 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")
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, dbc.DB().First(&rawCounter, "name = ?", counterName).Error)
assert.NotEqual(t, rawCounter.FirstSeen, rawCounter.LastModified)
v, err = getCounterValue(dbc, counterName)
assert.NoError(t, err, "reading existent 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)
require.NoError(t, dbc.DB().AutoMigrate(&counter{}))
testTime := time.Now().UTC()
counterTemplate := `#example:test:%v`
for i := 0; i < 6; i++ {
require.NoError(
t,
updateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true),
updateCounter(dbc, fmt.Sprintf(counterTemplate, i), int64(i), true, testTime),
"inserting counter %d", i,
)
}
@ -49,11 +59,40 @@ func TestCounterTopListAndRank(t *testing.T) {
assert.Len(t, cc, 3)
assert.Equal(t, []counter{
{Name: "#example:test:5", Value: 5},
{Name: "#example:test:4", Value: 4},
{Name: "#example:test:3", Value: 3},
{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 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,
fmt.Sprintf(counterTemplate, ""),
fmt.Sprintf(counterTemplate, 4))

View file

@ -2,11 +2,14 @@
package delay
import (
"fmt"
"math/rand"
"time"
"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"
)
@ -48,11 +51,10 @@ func Register(args plugins.RegistrationArguments) error {
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 (
ptrZeroDuration = func(v time.Duration) *time.Duration { return &v }(0)
delay = attrs.MustDuration("delay", ptrZeroDuration)
jitter = attrs.MustDuration("jitter", ptrZeroDuration)
delay = attrs.MustDuration("delay", helpers.Ptr(time.Duration(0)))
jitter = attrs.MustDuration("jitter", helpers.Ptr(time.Duration(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) 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
}

View file

@ -7,17 +7,18 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
const actorName = "delete"
var botTwitchClient *twitch.Client
var botTwitchClient func() *twitch.Client
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -32,14 +33,14 @@ func Register(args plugins.RegistrationArguments) error {
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"]
if !ok || msgID == "" {
return false, nil
}
return false, errors.Wrap(
botTwitchClient.DeleteMessage(
botTwitchClient().DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData),
msgID,
@ -51,6 +52,6 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *
func (actor) IsAsync() bool { return false }
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
}

View file

@ -4,10 +4,13 @@ package eventmod
import (
"encoding/json"
"fmt"
"github.com/pkg/errors"
"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"
)
@ -44,7 +47,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
fieldsTemplate, err := attrs.String("fields")
if err != nil || fieldsTemplate == "" {
return errors.New("fields must be non-empty string")
}
if err = tplValidator(fieldsTemplate); err != nil {
return errors.Wrap(err, "validating fields template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "fields", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "fields"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -5,6 +5,7 @@ package filesay
import (
"bufio"
"context"
"fmt"
"net/http"
"net/url"
"time"
@ -13,6 +14,8 @@ import (
"github.com/sirupsen/logrus"
"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"
)
@ -57,7 +60,7 @@ func Register(args plugins.RegistrationArguments) error {
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 }("")
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error {
sourceTpl, err := attrs.String("source")
if err != nil || sourceTpl == "" {
return errors.New("source is expected to be non-empty string")
}
if err = tplValidator(sourceTpl); err != nil {
return errors.Wrap(err, "validating source template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "source", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "source"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -3,16 +3,18 @@
package linkdetector
import (
"fmt"
"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/plugins"
)
const actorName = "linkdetector"
var ptrFalse = func(v bool) *bool { return &v }(false)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
args.RegisterActor(actorName, func() plugins.Actor { return &Actor{} })
@ -42,13 +44,13 @@ func Register(args plugins.RegistrationArguments) error {
type Actor struct{}
// 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") {
// We already detected links, lets not do it again
return false, nil
}
if attrs.MustBool("heuristic", ptrFalse) {
if attrs.MustBool("heuristic", helpers.Ptr(false)) {
eventData.Set("links", linkcheck.New().HeuristicScanForLinks(m.Trailing()))
} else {
eventData.Set("links", linkcheck.New().ScanForLinks(m.Trailing()))
@ -64,4 +66,13 @@ func (Actor) IsAsync() bool { return false }
func (Actor) Name() string { return actorName }
// 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 (
"context"
"fmt"
"regexp"
"strings"
"time"
@ -11,7 +12,9 @@ import (
"github.com/pkg/errors"
"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/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -19,15 +22,13 @@ import (
const actorName = "linkprotect"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
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
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -127,7 +128,7 @@ const (
)
//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
if preventCooldown, err = (clipdetector.Actor{}).Execute(c, m, r, eventData, attrs); err != nil {
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 there are no links there is nothing to protect and there
// 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, nil
}
clipsInterface, err := eventData.Any("clips")
clipsInterface, err := eventData.Get("clips")
if err != nil {
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 attrs.MustBool("stop_on_no_action", ptrBoolFalse) {
if attrs.MustBool("stop_on_no_action", helpers.Ptr(false)) {
return false, plugins.ErrStopRuleExecution
}
return false, nil
}
// 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":
if err = botTwitchClient.BanUser(
if err = botTwitchClient().BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
0,
attrs.MustString("reason", ptrStringEmpty),
attrs.MustString("reason", helpers.Ptr("")),
); err != nil {
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")
}
if err = botTwitchClient.DeleteMessage(
if err = botTwitchClient().DeleteMessage(
context.Background(),
plugins.DeriveChannel(m, eventData),
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")
}
if err = botTwitchClient.BanUser(
if err = botTwitchClient().BanUser(
context.Background(),
plugins.DeriveChannel(m, eventData),
strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"),
to,
attrs.MustString("reason", ptrStringEmpty),
attrs.MustString("reason", helpers.Ptr("")),
); err != nil {
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
}
@ -218,41 +219,49 @@ func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName }
func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error {
if v, err := attrs.String("action"); err != nil || v == "" {
return errors.New("action must be non-empty string")
}
if v, err := attrs.String("reason"); err != nil || v == "" {
return errors.New("reason must be non-empty string")
}
if len(attrs.MustStringSlice("allowed_links"))+
len(attrs.MustStringSlice("disallowed_links"))+
len(attrs.MustStringSlice("allowed_clip_channels"))+
len(attrs.MustStringSlice("disallowed_clip_channels")) == 0 {
return errors.New("no conditions are provided")
func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "disallowed_links", Type: fieldcollection.SchemaFieldTypeStringSlice}),
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}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "stop_on_no_action", Type: fieldcollection.SchemaFieldTypeBool}),
fieldcollection.MustHaveNoUnknowFields,
func(attrs, _ *fieldcollection.FieldCollection) error {
if len(attrs.MustStringSlice("allowed_links", helpers.Ptr([]string{})))+
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
}
func (a actor) check(links []string, clips []twitch.ClipInfo, attrs *plugins.FieldCollection) (v verdict) {
hasClipDefinition := len(attrs.MustStringSlice("allowed_clip_channels"))+len(attrs.MustStringSlice("disallowed_clip_channels")) > 0
func (a actor) check(links []string, clips []twitch.ClipInfo, attrs *fieldcollection.FieldCollection) (v verdict) {
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
}
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
}
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
}
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
}

View file

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

View file

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

View file

@ -7,6 +7,8 @@ import (
"github.com/pkg/errors"
"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"
)
@ -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
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")
}
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")
}
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")
}
@ -69,14 +71,14 @@ func (d discordActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, ev
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) 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 {
return err //nolint:wrapcheck
}
@ -89,7 +91,7 @@ func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs
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
// this case the content is mandatory
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
func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (err error) {
if !attrs.MustBool("add_embed", ptrBoolFalse) {
func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.FieldCollection) (err error) {
if !attrs.MustBool("add_embed", helpers.Ptr(false)) {
// No embed? No problem!
return nil
}
@ -122,45 +124,45 @@ func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins
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")
}
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")
}
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")
}
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")
} else if 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")
} else if 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")
} else if 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")
}
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")
}
}
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")
} else if sv != "" {
var flds []discordPayloadEmbedField

View file

@ -7,6 +7,8 @@ import (
"github.com/pkg/errors"
"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"
)
@ -14,14 +16,14 @@ type slackCompatibleActor struct {
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)
if err != nil {
return false, errors.Wrap(err, "parsing text")
}
return sendPayload(
s.fixHookURL(attrs.MustString("hook_url", ptrStringEmpty)),
s.fixHookURL(attrs.MustString("hook_url", helpers.Ptr(""))),
map[string]string{
"text": text,
},
@ -33,7 +35,7 @@ func (slackCompatibleActor) IsAsync() bool { return false }
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 {
return err //nolint:wrapcheck
}

View file

@ -4,11 +4,14 @@ package modchannel
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"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/plugins"
)
@ -18,8 +21,6 @@ const actorName = "modchannel"
var (
formatMessage plugins.MsgFormatter
tcGetter func(string) (*twitch.Client, error)
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
@ -70,10 +71,10 @@ func Register(args plugins.RegistrationArguments) error {
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 (
game = attrs.MustString("game", ptrStringEmpty)
title = attrs.MustString("title", ptrStringEmpty)
game = attrs.MustString("game", helpers.Ptr(""))
title = attrs.MustString("title", helpers.Ptr(""))
)
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("channel"); err != nil || v == "" {
return errors.New("channel must be non-empty string")
}
for _, field := range []string{"channel", "game", "title"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "channel", "game", "title"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

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

View file

@ -4,6 +4,7 @@
package nuke
import (
"fmt"
"regexp"
"strings"
"sync"
@ -13,7 +14,9 @@ import (
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"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/plugins"
)
@ -24,20 +27,16 @@ const (
)
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter
messageStore = map[string][]*storedMessage{}
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
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage
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)
if err != nil {
return false, errors.Wrap(err, "formatting match")
}
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 {
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
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 {
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("match"); err != nil || v == "" {
return errors.New("match must be non-empty string")
}
for _, field := range []string{"scan", "action", "match"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "scan", "action", "match"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

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

View file

@ -10,6 +10,8 @@ import (
"gopkg.in/irc.v4"
"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/plugins"
)
@ -23,9 +25,9 @@ var (
formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
ptrStringEmpty = func(v string) *string { return &v }("")
ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}")
ptrStringZero = func(v string) *string { return &v }("0")
// ptrStringEmpty = func(v string) *string { return &v }("")
// ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}")
// ptrStringZero = func(v string) *string { return &v }("0")
)
// Register provides the plugins.RegisterFunc
@ -93,7 +95,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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 getMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
}
@ -113,11 +115,11 @@ 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 (
action = attrs.MustString("action", ptrStringEmpty)
indexStr = attrs.MustString("index", ptrStringZero)
quote = attrs.MustString("quote", ptrStringEmpty)
action = attrs.MustString("action", helpers.Ptr(""))
indexStr = attrs.MustString("index", helpers.Ptr("0"))
quote = attrs.MustString("quote", helpers.Ptr(""))
)
if indexStr == "" {
@ -166,7 +168,7 @@ func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *
fields.Set("index", idx)
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)
if err != nil {
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
action := attrs.MustString("action", ptrStringEmpty)
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
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 {
case "add":
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":
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":
// No requirements
default:
return errors.New("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 fmt.Errorf("action must be one of add, del or get")
}
return nil

View file

@ -2,9 +2,13 @@
package raw
import (
"fmt"
"github.com/pkg/errors"
"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"
)
@ -13,8 +17,6 @@ const actorName = "raw"
var (
formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
@ -47,7 +49,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string")
}
if err = tplValidator(attrs.MustString("message", ptrStringEmpty)); err != nil {
return errors.Wrap(err, "validating message template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "message"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus"
"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"
)
@ -20,9 +22,6 @@ const actorName = "respond"
var (
formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
ptrBoolFalse = func(v bool) *bool { return &v }(false)
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
@ -102,7 +101,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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)
if err != 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"]
if ok {
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string")
}
for _, field := range []string{"message", "fallback"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "to_channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "message", "fallback"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

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

View file

@ -4,11 +4,14 @@ package shoutout
import (
"context"
"fmt"
"regexp"
"github.com/pkg/errors"
"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/plugins"
)
@ -16,7 +19,7 @@ import (
const actorName = "shoutout"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("")
@ -25,7 +28,7 @@ var (
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -55,14 +58,14 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
return false, errors.Wrap(err, "executing user template")
}
return false, errors.Wrap(
botTwitchClient.SendShoutout(
botTwitchClient().SendShoutout(
context.Background(),
plugins.DeriveChannel(m, eventData),
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("user"); err != nil || v == "" {
return errors.New("user must be non-empty string")
}
if err = tplValidator(attrs.MustString("user", ptrStringEmpty)); err != nil {
return errors.Wrap(err, "validating user template")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "user"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil
@ -94,7 +97,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@ -24,6 +25,12 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
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)
defer cancel()
@ -42,14 +49,34 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
}
}()
defer func() {
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
logrus.WithError(err).Error("storing back Spotify auth token")
}
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return track, fmt.Errorf("reading response body: %w", err)
}
if err = json.NewDecoder(resp.Body).Decode(&track); err != nil {
return track, fmt.Errorf("decoding response: %w", err)
switch resp.StatusCode {
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

View file

@ -2,21 +2,33 @@ package spotify
import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/pbkdf2"
"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) {
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})
conf, err := oauthConfig(channel, strings.Split(redirURL.String(), "?")[0])
@ -30,13 +42,20 @@ func handleStartAuth(w http.ResponseWriter, r *http.Request) {
if code == "" {
http.Redirect(
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,
)
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 {
logrus.WithError(err).Error("getting Spotify oauth token")
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)
}
clientSecret, err := getModuleConfig(actorName, channel).String("clientSecret")
if err != nil {
return nil, fmt.Errorf("getting clientSecret for channel: %w", err)
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
ClientID: clientID,
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.spotify.com/authorize",
TokenURL: "https://accounts.spotify.com/api/token",

View file

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

View file

@ -3,6 +3,7 @@ package timeout
import (
"context"
"fmt"
"regexp"
"strconv"
"time"
@ -10,6 +11,8 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
@ -17,7 +20,7 @@ import (
const actorName = "timeout"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("")
@ -26,7 +29,7 @@ var (
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
@ -65,14 +68,14 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
return false, errors.Wrap(err, "executing reason template")
}
return false, errors.Wrap(
botTwitchClient.BanUser(
botTwitchClient().BanUser(
context.Background(),
plugins.DeriveChannel(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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.Duration("duration"); err != nil || v < time.Second {
return errors.New("duration must be of type duration greater or equal one second")
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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 == "" {
return errors.New("reason must be non-empty string")
}
if err = tplValidator(attrs.MustString("reason", ptrStringEmpty)); err != nil {
return errors.Wrap(err, "validating reason template")
if attrs.MustDuration("duration", nil) < time.Second {
return errors.New("duration must be greater or equal one second")
}
return nil
@ -115,7 +119,7 @@ func handleChatCommand(m *irc.Message) error {
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")
}

View file

@ -11,6 +11,8 @@ import (
"gopkg.in/irc.v4"
"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/plugins"
)
@ -144,7 +146,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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)
if err != nil {
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) Name() string { return "setvariable" }
func (actorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("variable"); err != nil || v == "" {
return errors.New("variable name must be non-empty string")
}
for _, field := range []string{"set", "variable"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actorSetVariable) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "set", "variable"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -3,11 +3,14 @@ package vip
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"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/plugins"
)
@ -16,8 +19,6 @@ var (
formatMessage plugins.MsgFormatter
permCheckFn plugins.ChannelPermissionCheckFunc
tcGetter func(string) (*twitch.Client, error)
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
@ -98,21 +99,20 @@ type (
)
func (actor) IsAsync() bool { return false }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
for _, field := range []string{"channel", "user"} {
if v, err := attrs.String(field); err != nil || v == "" {
return errors.Errorf("%s must be non-empty string", field)
}
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "user", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "channel", "user"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
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 {
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
}
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)
if err != nil {
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 (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)
if err != nil {
return false, errors.Wrap(err, "getting parameters")

View file

@ -3,10 +3,13 @@ package whisper
import (
"context"
"fmt"
"github.com/pkg/errors"
"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/plugins"
)
@ -14,21 +17,19 @@ import (
const actorName = "whisper"
var (
botTwitchClient *twitch.Client
botTwitchClient func() *twitch.Client
formatMessage plugins.MsgFormatter
ptrStringEmpty = func(s string) *string { return &s }("")
)
// Register provides the plugins.RegisterFunc
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
botTwitchClient = args.GetTwitchClient
formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Send a whisper (requires a verified bot!)",
Description: "Send a whisper",
Name: "Send Whisper",
Type: "whisper",
@ -59,7 +60,7 @@ func Register(args plugins.RegistrationArguments) error {
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)
if err != nil {
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(
botTwitchClient.SendWhisper(context.Background(), to, msg),
botTwitchClient().SendWhisper(context.Background(), to, msg),
"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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("to"); err != nil || v == "" {
return errors.New("to must be non-empty string")
}
if v, err := attrs.String("message"); err != nil || v == "" {
return errors.New("message must be non-empty string")
}
for _, field := range []string{"message", "to"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "message", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "to", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "message", "to"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -1,17 +1,20 @@
package customevent
import (
"fmt"
"strings"
"github.com/pkg/errors"
"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"
)
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)
if err != nil {
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) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("fields"); err != nil || v == "" {
return errors.New("fields is expected to be non-empty string")
}
for _, field := range []string{"fields", "schedule_in"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
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,
helpers.SchemaValidateTemplateField(tplValidator, "fields", "schedule_in"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -14,6 +14,7 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -130,14 +131,14 @@ func handleCreateEvent(w http.ResponseWriter, r *http.Request) {
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)
if err := json.NewDecoder(fieldData).Decode(&payload); err != nil {
return nil, errors.Wrap(err, "parsing event payload")
}
fields := plugins.FieldCollectionFromData(payload)
fields := fieldcollection.FieldCollectionFromData(payload)
fields.Set("channel", "#"+strings.TrimLeft(channel, "#"))
return fields, nil

View file

@ -9,9 +9,9 @@ import (
"github.com/pkg/errors"
"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/plugins"
)
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)
if err := json.NewEncoder(fieldBuf).Encode(fields); err != nil {
return errors.Wrap(err, "marshalling fields")

View file

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

View file

@ -10,9 +10,9 @@ import (
"gorm.io/gorm"
"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/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
type (
@ -86,7 +86,7 @@ func getEventByID(db database.Connector, eventID uint64) (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 {
return socketMessage{}, errors.Wrap(err, "decoding fields")
}

View file

@ -7,8 +7,8 @@ import (
"github.com/stretchr/testify/assert"
"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/plugins"
)
func TestEventDatabaseRoundtrip(t *testing.T) {
@ -30,7 +30,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true,
Time: tEvent2,
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.NoError(t, err, "adding second event")
@ -39,7 +39,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true,
Time: tEvent1,
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.NoError(t, err, "adding first event")
@ -48,7 +48,7 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: true,
Time: tEvent1,
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.NoError(t, err, "adding other channel event")
@ -66,6 +66,6 @@ func TestEventDatabaseRoundtrip(t *testing.T) {
IsLive: false,
Time: tEvent1,
Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
Fields: fieldcollection.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}, evt)
}

View file

@ -21,6 +21,7 @@ import (
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -41,12 +42,12 @@ type (
// socketMessage represents the message overlay sockets will receive
socketMessage struct {
EventID uint64 `json:"event_id"`
IsLive bool `json:"is_live"`
Reason sendReason `json:"reason"`
Time time.Time `json:"time"`
Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"`
EventID uint64 `json:"event_id"`
IsLive bool `json:"is_live"`
Reason sendReason `json:"reason"`
Time time.Time `json:"time"`
Type string `json:"type"`
Fields *fieldcollection.FieldCollection `json:"fields"`
}
)
@ -180,7 +181,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
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()
defer subscribersLock.RUnlock()

View file

@ -1,8 +1,10 @@
package raffle
import (
"fmt"
"time"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@ -17,7 +19,7 @@ var ptrStrEmpty = ptrStr("")
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) == "" {
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(),
}
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{
raffleEventFields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": re.UserID,
"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) Name() string { return "enter-raffle" }
func (enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
keyword, err := attrs.String("keyword")
if err != nil || keyword == "" {
return errors.New("keyword must be non-empty string")
func (enterRaffleActor) Validate(_ plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.MustHaveField(fieldcollection.SchemaField{Name: "keyword", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil

View file

@ -9,9 +9,9 @@ import (
"gopkg.in/irc.v4"
"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/plugins"
)
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.lock.Unlock()
fields := plugins.FieldCollectionFromData(map[string]any{
fields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": winner.UserID,
"user": winner.UserLogin,
"winner": winner,
@ -636,9 +636,9 @@ func (d *dbClient) Update(r raffle) error {
// SendEvent processes the text template and sends the message if
// 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 {
fields = plugins.NewFieldCollection()
fields = fieldcollection.NewFieldCollection()
}
fields.Set("raffle", r) // Make raffle available to templating

View file

@ -9,6 +9,7 @@ import (
"github.com/sirupsen/logrus"
"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/plugins"
)
@ -95,7 +96,7 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
re.UserDisplayName = re.UserLogin
}
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{
raffleEventFields := fieldcollection.FieldCollectionFromData(map[string]any{
"user_id": m.Tags["user-id"],
"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
{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're following since 12.12.2020 DogChamp", ExpectedLinks: []string{"http://You.re"}},
{Heuristic: true, Message: "You are following since 12.12.2020 DogChamp", 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) {

View file

@ -2,7 +2,7 @@
package access
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
@ -29,8 +29,6 @@ type (
TwitchClient string
TwitchClientSecret string
FallbackToken string // DEPRECATED
TokenUpdateHook func()
}
@ -93,72 +91,11 @@ func (s Service) GetChannelPermissions(channel string) ([]string, error) {
// bot user
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
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 {
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 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)
return s.GetTwitchClientForChannel(botUsername, cfg)
}
// GetTwitchClientForChannel returns a twitch.Client configured to act

View file

@ -3,6 +3,7 @@
package userstate
import (
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
@ -16,7 +17,7 @@ func Register(args plugins.RegistrationArguments) error {
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 {
state := userState.Get(plugins.DeriveChannel(m, fields))
if state == nil {

15
irc.go
View file

@ -13,6 +13,7 @@ import (
"github.com/sirupsen/logrus"
"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/plugins"
)
@ -226,7 +227,7 @@ func (i ircHandler) handleClearChat(m *irc.Message) {
var (
evt *string
fields = plugins.NewFieldCollection()
fields = fieldcollection.NewFieldCollection()
)
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) {
fields := plugins.FieldCollectionFromData(map[string]interface{}{
fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
"message_id": m.Tags["target-msg-id"],
"target_name": m.Tags["login"],
@ -270,7 +271,7 @@ func (i ircHandler) handleClearMessage(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
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) {
fields := plugins.FieldCollectionFromData(map[string]interface{}{
fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser
})
@ -299,7 +300,7 @@ func (i ircHandler) handlePermit(m *irc.Message) {
username := msgParts[1]
fields := plugins.FieldCollectionFromData(map[string]interface{}{
fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.User, // Compatibility to plugins.DeriveUser
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 {
fields := plugins.FieldCollectionFromData(map[string]interface{}{
fields := fieldcollection.FieldCollectionFromData(map[string]interface{}{
"bits": bits,
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
"message": m.Trailing(),
@ -380,7 +381,7 @@ func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
"trailing": m.Trailing(),
}).Trace("IRC USERNOTICE event")
evtData := plugins.FieldCollectionFromData(map[string]any{
evtData := fieldcollection.FieldCollectionFromData(map[string]any{
eventFieldChannel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
eventFieldUserName: m.Tags["login"], // Compatibility to plugins.DeriveUser
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)"`
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"`
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"`
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"`
@ -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
}
@ -151,29 +146,6 @@ func main() {
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
updCron := updateConfigCron()
if _, err = cronService.AddFunc(updCron, updateConfigFromRemote); err != nil {
@ -257,6 +229,29 @@ func main() {
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)
go watchConfigChanges(cfg.Config, fsEvents)

View file

@ -10,6 +10,7 @@ import (
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -19,7 +20,7 @@ var (
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,
formatMessageFieldMessage,
formatMessageFieldUserID,
@ -27,8 +28,8 @@ var (
}
)
func formatMessage(tplString string, m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) (string, error) {
compiledFields := plugins.NewFieldCollection()
func formatMessage(tplString string, m *irc.Message, r *plugins.Rule, fields *fieldcollection.FieldCollection) (string, error) {
compiledFields := fieldcollection.NewFieldCollection()
if config != nil {
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")
}
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))
}
func formatMessageFieldMessage(compiledFields *plugins.FieldCollection, m *irc.Message, _ *plugins.FieldCollection) {
func formatMessageFieldMessage(compiledFields *fieldcollection.FieldCollection, m *irc.Message, _ *fieldcollection.FieldCollection) {
if m == nil {
return
}
@ -73,7 +74,7 @@ func formatMessageFieldMessage(compiledFields *plugins.FieldCollection, m *irc.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 {
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))
}
@ -93,7 +94,7 @@ func validateTemplate(tplString string) error {
_, err := template.
New(tplString).
Funcs(tplFuncs.GetFuncMap(nil, nil, plugins.NewFieldCollection())).
Funcs(tplFuncs.GetFuncMap(nil, nil, fieldcollection.NewFieldCollection())).
Parse(tplString)
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 {
err = c.db.First(&data, "name = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCoreMetaNotFound
return backoff.NewErrCannotRetry(ErrCoreMetaNotFound)
}
return errors.Wrap(err, "querying core meta table")
}); err != nil {

View file

@ -29,6 +29,8 @@ const (
EventSubEventTypeChannelPollBegin = "channel.poll.begin"
EventSubEventTypeChannelPollEnd = "channel.poll.end"
EventSubEventTypeChannelPollProgress = "channel.poll.progress"
EventSubEventTypeChannelSuspiciousUserMessage = "channel.suspicious_user.message"
EventSubEventTypeChannelSuspiciousUserUpdate = "channel.suspicious_user.update"
EventSubEventTypeStreamOffline = "stream.offline"
EventSubEventTypeStreamOnline = "stream.online"
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
@ -235,6 +237,53 @@ type (
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
// authorization revoke event
EventSubEventUserAuthorizationRevoke struct {
@ -324,7 +373,6 @@ func (c *Client) createEventSubSubscription(ctx context.Context, auth AuthType,
if mustFetchSubsctiption {
params := make(url.Values)
params.Set("status", "enabled")
params.Set("type", sub.Type)
if err = c.Request(ctx, ClientRequestOpts{
AuthType: auth,

View file

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

View file

@ -3,6 +3,7 @@ package plugins
import (
"reflect"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors"
)
@ -16,9 +17,9 @@ type (
// (not returning ErrValueNotSet) and does not contain zero value
// recognized by reflect (to just check whether the field is set
// 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 {
v, err := attrs.Any(field)
v, err := attrs.Get(field)
if err != nil {
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
// as strings and do have a template which validates (this does not
// 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 {
v, err := attrs.String(field)
if err != nil {
@ -52,11 +53,11 @@ func (ActorKit) ValidateRequireValidTemplate(tplValidator TemplateValidatorFunc,
// ValidateRequireValidTemplateIfSet checks whether the field is
// either not set or a valid template (this does not
// 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 {
v, err := attrs.String(field)
if err != nil {
if errors.Is(err, ErrValueNotSet) {
if errors.Is(err, fieldcollection.ErrValueNotSet) {
continue
}
return errors.Wrapf(err, "getting string field %s", field)

View file

@ -4,11 +4,12 @@ import (
"strings"
"testing"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/stretchr/testify/assert"
)
func TestValidateRequireNonEmpty(t *testing.T) {
attrs := FieldCollectionFromData(map[string]any{
attrs := fieldcollection.FieldCollectionFromData(map[string]any{
"str": "",
"str_v": "valid",
"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"
"strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"gopkg.in/irc.v4"
)
// DeriveChannel takes an irc.Message and a FieldCollection and tries
// 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], "#") {
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
// 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 != "" {
return m.User
}

View file

@ -7,6 +7,7 @@ import (
"gopkg.in/irc.v4"
"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/twitch"
)
@ -15,7 +16,7 @@ type (
// Actor defines an interface to implement in the plugin for actors
Actor interface {
// 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
// in a Go routine as of long runtime. Normally it should return false
// except in very specific cases
@ -26,7 +27,7 @@ type (
// Validate will be called to validate the loaded configuration. It should
// return an error if required keys are missing from the AttributeStore
// 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
@ -62,7 +63,7 @@ type (
// EventHandlerFunc defines the type of function required to listen
// 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
// plugins RegisterFunc to register a new event handler function
// which is then fed with all events occurring in the bot
@ -76,12 +77,12 @@ type (
// ModuleConfigGetterFunc is passed from the bot to the
// plugins RegisterFunc to fetch module generic or channel specific
// 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
// plugins RegisterFunc to format messages using all registered and
// 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
// 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
// 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
// plugins RegisterFunc to register a new TemplateFuncGetter
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
// satisfy the TemplateFuncGetter interface
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
import "strings"
import (
"strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
)
// DefaultConfigName is the name the default configuration must have
// when defined
@ -9,16 +13,16 @@ const DefaultConfigName = "default"
type (
// ModuleConfig represents a mapping of configurations per channel
// and module
ModuleConfig map[string]map[string]*FieldCollection
ModuleConfig map[string]map[string]*fieldcollection.FieldCollection
)
// GetChannelConfig reads the channel specific configuration for the
// given module. This is created by taking an empty FieldCollection,
// merging in the default configuration and finally overwriting all
// existing channel configurations.
func (m ModuleConfig) GetChannelConfig(module, channel string) *FieldCollection {
func (m ModuleConfig) GetChannelConfig(module, channel string) *fieldcollection.FieldCollection {
channel = strings.TrimLeft(channel, "#@")
composed := NewFieldCollection()
composed := fieldcollection.NewFieldCollection()
for _, i := range []string{DefaultConfigName, channel} {
f := m[module][i]

View file

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

View file

@ -17,6 +17,7 @@ import (
"gopkg.in/irc.v4"
"gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str"
"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 struct {
Type string `json:"type" yaml:"type,omitempty"`
Attributes *FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
Type string `json:"type" yaml:"type,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
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.timerStore = timerStore
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.allowExecuteChannelWhitelist,
r.allowExecuteUserWhitelist,
@ -144,7 +145,7 @@ func (r *Rule) GetMatchMessage() *regexp.Regexp {
// SetCooldown uses the given TimerStore to set the cooldowns for the
// 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
if r.Cooldown != nil {
@ -250,7 +251,7 @@ func (r Rule) Validate(tplValidate TemplateValidatorFunc) error {
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 {
if badges.Has(b) {
logger.Tracef("Non-Match: Disable-Badge %s", b)
@ -261,7 +262,7 @@ func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message,
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 {
// No match criteria set, does not speak against matching
return true
@ -276,7 +277,7 @@ func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *st
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) == "" {
// No match criteria set, does not speak against matching
return true
@ -301,7 +302,7 @@ func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message,
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 {
// No match criteria set, does not speak against matching
return true
@ -315,7 +316,7 @@ func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message
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 {
// No match criteria set, does not speak against matching
return true
@ -329,7 +330,7 @@ func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *stri
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) == "" {
// No match criteria set, does not speak against matching
return true
@ -348,7 +349,7 @@ func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message
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))
if err != nil {
logger.WithError(err).Error("checking permit")
@ -363,7 +364,7 @@ func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message,
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 == "" {
// No match criteria set, does not speak against matching
return true
@ -384,7 +385,7 @@ func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Messag
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
// event to match. We now need to ensure this match is valid for
// the current execution:
@ -430,7 +431,7 @@ func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, even
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 {
// No match criteria set, does not speak against matching
return true
@ -459,7 +460,7 @@ func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.
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 {
// No match criteria set, does not speak against matching
return true
@ -484,7 +485,7 @@ func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.
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 {
// No match criteria set, does not speak against matching
return true
@ -509,7 +510,7 @@ func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _
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 {
// No match criteria set, does not speak against matching
return true
@ -534,7 +535,7 @@ func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _
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 {
// No match criteria set, does not speak against matching
return true

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"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
// 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
}

View file

@ -10,6 +10,7 @@ import (
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/actors/announce"
"github.com/Luzifer/twitch-bot/v3/internal/actors/ban"
@ -181,12 +182,12 @@ func getRegistrationArguments() plugins.RegistrationArguments {
SendMessage: sendMessage,
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)
return nil
},
GetModuleConfigForChannel: func(module, channel string) *plugins.FieldCollection {
GetModuleConfigForChannel: func(module, channel string) *fieldcollection.FieldCollection {
return config.ModuleConfig.GetChannelConfig(module, channel)
},

View file

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

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