Compare commits

...

16 Commits

Author SHA1 Message Date
4186e16451
prepare release v3.18.0 2023-09-21 19:19:21 +02:00
1ac20921a1
[core] Add user:manage:whispers extended scope
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-13 22:41:46 +02:00
4a15a7bf35
[core] Utilize clean shutdown in go-irc v4
in order to maybe less often throw errors when re-using closed connections

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-12 10:49:35 +02:00
bcc5b4eba7
[core] Update go-irc to v4.0.0
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-11 19:51:38 +02:00
2bec4f82ed
Mark twitch-token flag / envvar deprecated
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-09 11:49:26 +02:00
eac3b0ea34
[docs] Add raffle module as feature to start page
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-04 22:41:31 +02:00
e16c0367bb
[docs] Add raffle documentation
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-04 22:33:38 +02:00
7d19bee9a4
[docs] Fix broken preparations image
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-04 22:33:36 +02:00
220a501ab8
[templating] Refactor: Move twitch functions into library package
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-03 13:01:57 +02:00
a5df68d921
[templating] Add idForUsername function
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-03 12:26:07 +02:00
fb57cb9304
[templating] Add usernameForID function
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-03 12:09:33 +02:00
0a53863b69
[core] Add channel specific module configuration interface
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-02 14:12:08 +02:00
dc648d1dba
[ci] Update dependencies
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-09-02 13:31:07 +02:00
a3a134fe36
[ci] Add integration tests for database servers
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-08-26 22:40:17 +02:00
db3c4f4efa
[raffle] Insert newly created raffles with NULL reminder time
as MySQL 8 in opposite to MariaDB 10 does not support the time `0000-00-00 00:00:00`

fixes #51

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-08-26 19:11:49 +02:00
29df9e59b5
[core] Remove v2 migration
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-08-26 00:20:10 +02:00
86 changed files with 902 additions and 881 deletions

View File

@ -85,4 +85,106 @@ jobs:
draft: false
generateReleaseNotes: false
database-integration:
# Only execute db-server integration tests when sqlite based tests did run successfully
needs: [test-and-build]
defaults:
run:
shell: bash
container:
image: luzifer/archlinux
env:
CGO_ENABLED: 0
GOPATH: /go
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
database: [mariadb, mysql, postgres]
services:
mariadb:
image: mariadb:11
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
mysql:
image: mysql:8
env:
MYSQL_PASSWORD: twitch-bot-pass
MYSQL_ROOT_PASSWORD: root-pass
MYSQL_USER: twitch-bot
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: twitch-bot-pass
steps:
- name: Enable custom AUR package repo
run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf
- name: Install required packages
run: |
pacman -Syy --noconfirm \
docker \
git \
go \
make \
mariadb-clients
- uses: actions/checkout@v3
- name: Marking workdir safe
run: git config --global --add safe.directory /__w/twitch-bot/twitch-bot
# --- MySQL
- name: Set up MySQL service
if: matrix.database == 'mysql'
run: |
mariadb -h mysql -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MySQL
if: matrix.database == 'mysql'
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mysql:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
# --- MariaDB
- name: Set up MariaDB service
if: matrix.database == 'mariadb'
run: |
mariadb -h mariadb -u root --password=root-pass <<EOF
CREATE DATABASE integration DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
GRANT ALL ON integration.* TO 'twitch-bot'@'%';
EOF
- name: Run tests against MariaDB
if: matrix.database == 'mariadb'
env:
GO_TEST_DB_ENGINE: mysql
GO_TEST_DB_DSN: twitch-bot:twitch-bot-pass@tcp(mariadb:3306)/integration?charset=utf8mb4&parseTime=True
run: make test
# --- PostgreSQL
- name: Run tests against PostgreSQL
if: matrix.database == 'postgres'
env:
GO_TEST_DB_ENGINE: postgres
GO_TEST_DB_DSN: host=postgres user=postgres password=twitch-bot-pass dbname=postgres port=5432 sslmode=disable timezone=UTC
run: make test
...

View File

@ -1,3 +1,27 @@
# 3.18.0 / 2023-09-21
* New Features
* [core] Add channel specific module configuration interface
* [templating] Add `idForUsername` function
* [templating] Add `usernameForID` function
* Improvements
* [core] Add `user:manage:whispers` extended scope
* [core] Update go-irc to v4.0.0
* Bugfixes
* [ci] Update dependencies
* [raffle] Insert newly created raffles with `NULL` reminder time
* Documentation
* [docs] Add raffle documentation
* [docs] Add raffle module as feature to start page
* [docs] Fix broken preparations image
* Deprecations
* [core] Mark twitch-token flag / envvar deprecated
* [core] Remove v2 migration
# 3.17.0 / 2023-08-25
* New Features

View File

@ -38,8 +38,8 @@ Usage of twitch-bot:
Supported sub-commands are:
actor-docs Generate markdown documentation for available actors
api-token <token-name> <scope> [...scope] Generate an api-token to be entered into the config
migrate-v2 <old-file> Migrate old (*.json.gz) storage file into new database
reset-secrets Remove encrypted data to reset encryption passphrase
tpl-docs Generate markdown documentation for available template functions
validate-config Try to load configuration file and report errors if any
```
@ -93,54 +93,3 @@ Just pass the filename you want to use.
--storage-conn-string 'storage.db' \
...
```
## Upgrade from `v2.x` to `v3.x`
With the release of `v3.0.0` the bot changed a lot introducing a new storage format. As that storage backend is not compatible with the `v2.x` storage you need to migrate it manually before starting a `v3.x` bot version the first time.
**Before starting the migration make sure to fully stop the bot!**
This section assumes you were starting your `v2.x` bot the following way:
```console
# twitch-bot \
--storage-file storage.json.gz
--twitch-client <clientid> \
--twitch-client-secret <secret>
```
To execute the migration we need to provide the same `storage-encryption-pass` or `twitch-client` / `twitch-client-secret` combination if no `storage-encryption-pass` was used.
```console
# twitch-bot \
--storage-conn-type <database type> \
--storage-conn-string <database connection string> \
--twitch-client <clientid> \
--twitch-client-secret <secret> \
migrate-v2 storage.json.gz
WARN[0000] No storage encryption passphrase was set, falling back to client-id:client-secret
WARN[0000] Module registered unhandled query-param type module=status type=integer
WARN[0000] Overlays dir not specified, no dir or non existent dir=
INFO[0000] Starting migration... module=variables
INFO[0000] Starting migration... module=mod_punish
INFO[0000] Starting migration... module=mod_overlays
INFO[0000] Starting migration... module=mod_quotedb
INFO[0000] Starting migration... module=core
INFO[0000] Starting migration... module=counter
INFO[0000] Starting migration... module=permissions
INFO[0000] Starting migration... module=timers
INFO[0000] v2 storage file was migrated
```
If you see the `v2 storage file was migrated` message the contents of your old storage file were migrated to the new database. The old file is not modified in this step.
Afterwards your need to adjust the start parameters of the bot:
```console
# twitch-bot \
--storage-conn-type <database type> \
--storage-conn-string <database connection string> \
--twitch-client <clientid> \
--twitch-client-secret <secret> \
```

View File

@ -7,8 +7,8 @@ import (
"os"
"os/exec"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -3,9 +3,9 @@ package main
import (
"sync"
"github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -6,11 +6,11 @@ import (
"sync"
"time"
"github.com/go-irc/irc"
"github.com/mitchellh/hashstructure/v2"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,7 +4,7 @@ import (
"strings"
"sync"
"github.com/go-irc/irc"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)
@ -31,10 +31,10 @@ func newTwitchUserStateStore() *twitchUserStateStore {
func parseTwitchUserState(m *irc.Message) (*twitchUserState, error) {
var (
color, _ = m.GetTag("color")
displayName, _ = m.GetTag("display-name")
color, _ = m.Tags["color"]
displayName, _ = m.Tags["display-name"]
emoteSets []string
rawSets, _ = m.GetTag("emote-sets")
rawSets, _ = m.Tags["emote-sets"]
)
if rawSets != "" {

View File

@ -4,9 +4,9 @@ import (
"strings"
"sync"
"github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -1,33 +0,0 @@
package main
import (
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/twitch-bot/v3/internal/v2migrator"
)
func init() {
cli.Add(cliRegistryEntry{
Name: "migrate-v2",
Description: "Migrate old (*.json.gz) storage file into new database",
Params: []string{"<old-file>"},
Run: func(args []string) error {
if len(args) < 2 { //nolint:gomnd // Just a count of parameters
return errors.New("Usage: twitch-bot migrate-v2 <old storage file>")
}
v2s := v2migrator.NewStorageFile()
if err := v2s.Load(args[1], cfg.StorageEncryptionPass); err != nil {
return errors.Wrap(err, "loading v2 storage file")
}
if err := v2s.Migrate(db); err != nil {
return errors.Wrap(err, "migrating v2 storage file")
}
log.Info("v2 storage file was migrated")
return nil
},
})
}

View File

@ -11,12 +11,12 @@ import (
"strings"
"time"
"github.com/go-irc/irc"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"gopkg.in/irc.v4"
"gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/str"
@ -56,6 +56,7 @@ type (
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
PermitTimeout time.Duration `yaml:"permit_timeout"`
RawLog string `yaml:"raw_log"`
ModuleConfig plugins.ModuleConfig `yaml:"module_config"`
Rules []*plugins.Rule `yaml:"rules"`
Variables map[string]interface{} `yaml:"variables"`

View File

@ -10,10 +10,12 @@ You are tired of all those cloud-bots working only sometimes, messing up at rand
**Open-Source:** This means you (or any developer you trust) can look up how things work inside the bot, can modify the bot yourself and be sure the bot does not use the access you are granting it to do stuff you don't want it to do. Also you are not dependent on some company to keep the bot running for you but are in control over it. In case I'm no longer willing to develop the bot, it will not cease to exist but can be developed further by anyone.
**Overlays:** The bot contains a web-server to host custom overlays which can be built like any website with **HTML and Javascript**. Some default overlays are included ready to use and for everything not available in the default distribution there is a helper library available to connect to the bot and work with events and bot state.
[**Overlays:**]({{< ref "overlays/_index.md" >}}) The bot contains a web-server to host custom overlays which can be built like any website with **HTML and Javascript**. Some default overlays are included ready to use and for everything not available in the default distribution there is a helper library available to connect to the bot and work with events and bot state.
**YAML Configuration:** The whole configuration is stored in a single YAML file, not in any proprietary format. You can simply create a backup of that file and even if some mistake or broken server happens you simply put back the configuration file and all of your rules, auto-messages, API-keys are instantly back again. (What's not in the configuration file is the data the bot stores like counters, events and variables.)
**Common Database Formats:** All the data mentioned in the last point is stored in a common database format like **SQLite**, **MySQL** or **PostgreSQL**. With exception of the credentials all the data is stored in a plain format which means you can use well-known database tooling to create backups. This also means you can use custom tooling to do with the data what you want! (Though a warning for this point: The database schema is not guaranteed to be stable! While it's possible I do not recommend directly accessing the data in the database for other tools.)
**API-First Design:** The bot is built to have an API and an included documentation for this API. Most of its functionality is exposed through the API and you can easily build tooling against that API to make to bot do your bidding.
[**Raffle-Module:**]({{< ref "modules/raffle.md" >}}) You don't need any other tool to do giveaways, the bot contains a raffle management including entrant restrictions, random picks, luck modifiers, automated text posts and of course entrant management.

View File

@ -77,6 +77,17 @@ auto_messages:
# Disable message using templating, must yield string `true` to disable the automated message
disable_on_template: '{{ ne .myvariable true }}'
# 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.
module_config:
some-module: # Name of the module to configure
default: # Bot-wide, fallback for all channels
some_option: true
mychannel: # Channel-specific, only valid for this channel
some_option: false
# List of rules. See documentation for details or use web-interface
# to configure.
rules: # See below for examples

View File

@ -232,6 +232,19 @@ Example:
< test - oops
```
### `idForUsername`
Returns the user-id for the given username
Syntax: `idForUsername <username>`
Example:
```
# {{ idForUsername "twitch" }}
* 12826
```
### `inList`
Tests whether a string is in a given list of strings (for conditional templates).
@ -376,7 +389,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: 84%
< Your int this hour: 46%
```
### `streamUptime`
@ -446,6 +459,19 @@ Example:
* Weather for Hamburg, DE: Few clouds with a temperature of 22 C (71.6 F). [...]
```
### `usernameForID`
Returns the current login name of an user-id
Syntax: `usernameForID <user-id>`
Example:
```
# {{ usernameForID "12826" }}
* twitch
```
### `variable`
Returns the variable value or default in case it is empty
@ -458,12 +484,3 @@ Example:
# {{ variable "foo" "fallback" }} - {{ variable "unsetvar" "fallback" }}
* test - fallback
```
## Upgrade from `v2.x` to `v3.x`
When adding [sprig](https://masterminds.github.io/sprig/) function collection some functions collided and needed replacement. You need to adapt your templates accordingly:
- Math functions (`add`, `div`, `mod`, `mul`, `multiply`, `sub`) were replaced with their sprig-equivalent and are now working with integers instead of floats. If you need them to continue to work with floats you need to use their [float-variants](https://masterminds.github.io/sprig/mathf.html).
- `now` does no longer format the current date as a string but return the current date. You need to replace this: `now "2006-01-02"` becomes `now | date "2006-01-02"`.
- `concat` is now used to concat arrays. To join strings you will need to modify your code: `concat ":" "string1" "string2"` becomes `lists "string1" "string2" | join ":"`.
- `toLower` / `toUpper` need to be replaced with their sprig equivalent `lower` and `upper`.

View File

@ -15,5 +15,5 @@ Registering your application is a relatively straight-forward process:
- Go to https://dev.twitch.tv/console/apps/create
- Fill out the form you are presented with. You can choose any **Name** you want for your bot. I'd recommend using one you later will recognize your bot under. For the **OAuth Redirect URL** choose the URL you want to have the bot available under later. If you want to have it running locally you can choose `http://localhost:3000/` for this field.
![](/screen-twitch-console-register-app.png)
![]({{< static "screen-twitch-console-register-app.png" >}})
- After registering your application go into the application you've just created and click the **New Secret** button. Note down the **Client-Id** and **Client-Secret** in a safe place. You will need them in the [Configuration]({{< ref "configuration.md" >}}) step.

View File

@ -0,0 +1,10 @@
---
title: Modules
---
{{< lead >}}
Aside of the core functionality of being a bot in a Twitch channel the bot contains additional modules to make channel management easier.
{{< /lead >}}
- The bot can serve all of your [**Overlays**]({{< ref "../overlays/_index.md" >}}) for you providing you with sound-alerts, alerts for various events and everything you can imagine yourself using Custom Events
- With the [**Raffle**]({{< ref "raffle.md" >}}) module you can create giveaways with various settings

View File

@ -0,0 +1,66 @@
---
title: Raffle
---
{{< lead >}}
Using the raffle module you can create giveaways with various settings, timers and pick one or multiple winners. You just have to send the good yourself…
{{< /lead >}}
## General Overview
![]({{< static "raffle-overview.png" >}})
In the overview you can see a list of your raffles and their status. You can edit, start / stop, copy, delete them or access the list of entrants from here.
The screenshot above shows one draft of a raffle together with one currently active.
![]({{< static "raffle-entrants.png" >}})
You can access the entrants list through the "group of people" button in the raffle overview. This becomes available as soon as the raffle has started.
In this list you can see the status, the nickname and the time of entry for each entrant. The status will be a person (<i class="fas fa-user"></i>) for someone joined through the **Everyone** allowance, a heart (<i class="fas fa-heart"></i>) for a follower, a star (<i class="fas fa-star"></i>) for a subscriber and a diamond (<i class="fas fa-gem"></i>) for a VIP. The list will update itself when there are changes in the entree-list.
![]({{< static "raffle-entrants-closed.png" >}})
After the raffle has been closed (either through the timer or by clicking the button) a winner can be picked through the "Pick Winner" button. A winner will display a crown before their status, the first chat message after being picked below the name and a "recycle" button to re-draw them. If you choose to re-draw a winner the crown will get striked and greyed out. A re-drawn winner can not be picked again (re-rdrawing without any candidates will cause an error and void the slot)!
### Recommendations
- As you can see below there are many options to configure and you probably will use the same texts (and maybe even some other options too) in every raffle. That's why you can see a `TEMPLATE` raffle in the screenshot above. That's a fully configured and never started raffle I keep around. When starting a new raffle I will just use the "copy" button to create a copy of that raffle, edit the copy, adjust the title and maybe options I don't like and can start the raffle saving a lot of work in the process especially creating the texts. You can have as many templates as you like if you're doing different raffles over and over.
## Raffle Configuration
### General Settings
![]({{< static "raffle-general-config.png" >}})
Within the general settings you will configure how your raffle behaves once started:
- **Channel** configures where it will take place: Straight forward, put your channel without the leading `#`.
- **Keyword** is what users must type in order to participate. In general they are used to type commands like `!enter` or `!key` for Steam-Key giveaways so I'd advice to stick to a command format. You should ensure not to use the same command in two raffles active at the same time though it is possible: if two raffles are using the same keyword, the user writing the keyword once will enter **both** raffles.
- **Title** should reflect what you're giving away and is available in the texts (more below). So for example this could be `Steam-Key: Starfield`.
- **Allowed Entries** configure who can take part in your giveaway. Pay attention these conditions are **or**-connected, so the chatter must only have one condition matching and not all of them!
- **Everyone** is straight forward: No conditions are imposed.
- **Followers, since `X` min** means all followers can participate if they are followed at least `X` minutes ago.
- **Subscribers** is straight forward again: If they have a subscriber-badge, they can join.
- **VIPs** is the same just they do need a VIP badge.
- **Luck Modifiers** are kinda tricky as they configure the size of each ticket and therefore manipulate the probability to be chosen. As stated in the text below the base modifier for **Everyone** is `1.0` and you can modifiy the "luck" for all others. Pay attention with this: if you for example disable the VIPs checkbox in **Allowed Entries** the VIPs luck modifier will **not** be used! That VIP will then enter as a subscriber or follower or even as "everyone" and get the respective modifier.
- **Times** configure when and for how long the raffle will take place:
- **Auto-Start** can be configured and if it is, the raffle will open itself at that point of time (within 1 minute). If you don't set this you need to start the raffle yourself.
- **Duration** configures how long the raffle will run. This is only relevant if **Close At** is unset. (Internally on starting the raffle **Close At** will be set to `now + duration` if **Close At** is empty.)
- **Close At** marks the end of the raffle. The raffle will automatically get closed at that point of time (within 1 minute).
- **Respond in** adds a time window where the bot will record the first message of the picked user after they have been picked. You will see that message in the entrants list: Useful for channels with a lot going on in the chat so you don't miss their response. After this time window is over no response will be recorded.
### Texts
![]({{< static "raffle-texts.png" >}})
The texts do support templating and do have the same format like other templates i.e. in rules. You can enable or disable each of them though I'd recommend to keep all of them enabled (maybe except the "failed entry" message).
- **Message on successful entry** will be posted as soon as the chatter is added to the entrants list.
- **Message on failed entry** will be posted in case the chatter is not entered (could be they are already entered or the bot encountered any other error while adding them).
- **Message on winner draw** will be posted for the chatter getting picked when drawing the winner: if you disable this you still can tell them they won when picking them.
- **Periodic reminder every `X` min** is a message to remember chatters (and tell new ones) there is a raffle open. It will be posted every `X` minutes, first time when opening the raffle.
- **Message on raffle close** will be posted when the raffle closes (either you closed it manually or the **Close At** time is reached).
Within the templates you do have access to the variables `.user` and `.raffle` (which represents the raffle object). Have a look at the default templates for examples what you can do with them.

View File

@ -0,0 +1,5 @@
{{- .Scratch.Set "path" (.Get 0) -}}
{{- if hasPrefix (.Scratch.Get "path") "/" -}}
{{- .Scratch.Set "path" (slicestr (.Scratch.Get "path") 1) -}}
{{- end -}}
{{- .Scratch.Get "path" | absLangURL -}}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a188c2de21f24c3db3ba56d123e1f76fb6920456d5e6f388af7eeecf3e1d85df
size 14721

3
docs/static/raffle-entrants.png vendored Normal file
View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ef7d42174f56cdb00e028b3f23b4ab9e027fd9572d44bd066b4692e7940c1e0
size 12866

3
docs/static/raffle-general-config.png vendored Normal file
View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ed4fbfafd57ae86821ca2ed8ea6a383e0b4453dc3ee7942010e23f886f5846c
size 78876

3
docs/static/raffle-overview.png vendored Normal file
View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f47ddf30ae4211d7a0a323182b1331521a09915f73f5a0bcc5b7c6f01ca7310
size 16172

3
docs/static/raffle-texts.png vendored Normal file
View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c484cda001007c2817c4e0f989c2614bc11213f361b07c9b7ad57fb15c27c07d
size 68284

View File

@ -8,8 +8,8 @@ import (
"time"
"github.com/Masterminds/sprig/v3"
"github.com/go-irc/irc"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str"
korvike "github.com/Luzifer/korvike/functions"

View File

@ -3,8 +3,8 @@ package main
import (
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -95,10 +95,7 @@ func init() {
)
tplFuncs.Register("tag", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} {
return func(tag string) string {
s, _ := m.GetTag(tag)
return s
}
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",
Syntax: "tag <tagname>",

View File

@ -1,227 +0,0 @@
package main
import (
"context"
"strings"
"time"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
func init() {
tplFuncs.Register("displayName", plugins.GenericTemplateFunctionGetter(tplTwitchDisplayName), plugins.TemplateFuncDocumentation{
Description: "Returns the display name the specified user set for themselves",
Syntax: "displayName <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ displayName "luziferus" }} - {{ displayName "notexistinguser" "foobar" }}`,
FakedOutput: "Luziferus - foobar",
},
})
tplFuncs.Register("doesFollowLongerThan", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollowLongerThan), plugins.TemplateFuncDocumentation{
Description: "Returns whether `from` follows `to` for more than `duration`",
Syntax: "doesFollowLongerThan <from> <to> <duration>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ doesFollowLongerThan "tezrian" "luziferus" "168h" }}`,
FakedOutput: "true",
},
})
tplFuncs.Register("doesFollow", plugins.GenericTemplateFunctionGetter(tplTwitchDoesFollow), plugins.TemplateFuncDocumentation{
Description: "Returns whether `from` follows `to`",
Syntax: "doesFollow <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ doesFollow "tezrian" "luziferus" }}`,
FakedOutput: "true",
},
})
tplFuncs.Register("followAge", plugins.GenericTemplateFunctionGetter(tplTwitchFollowAge), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to` and returns the duration between then and now",
Syntax: "followAge <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ followAge "tezrian" "luziferus" }}`,
FakedOutput: "15004h14m59.116620989s",
},
})
tplFuncs.Register("followDate", plugins.GenericTemplateFunctionGetter(tplTwitchFollowDate), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to`",
Syntax: "followDate <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ followDate "tezrian" "luziferus" }}`,
FakedOutput: "2021-04-10 16:07:07 +0000 UTC",
},
})
tplFuncs.Register("lastPoll", plugins.GenericTemplateFunctionGetter(tplTwitchLastPoll), plugins.TemplateFuncDocumentation{
Description: "Gets the last (currently running or archived) poll for the given channel (the channel must have given extended permission for poll access!)",
Syntax: "lastPoll <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `Last Poll: {{ (lastPoll .channel).Title }}`,
FakedOutput: "Last Poll: Und wie siehts im Template aus?",
},
Remarks: "See schema of returned object in [`pkg/twitch/polls.go#L13`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/polls.go#L13)",
})
tplFuncs.Register("profileImage", plugins.GenericTemplateFunctionGetter(tplTwitchProfileImage), plugins.TemplateFuncDocumentation{
Description: "Gets the URL of the given users profile image",
Syntax: "profileImage <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ profileImage .username }}`,
FakedOutput: "https://static-cdn.jtvnw.net/jtv_user_pictures/[...].png",
},
})
tplFuncs.Register("recentGame", plugins.GenericTemplateFunctionGetter(tplTwitchRecentGame), plugins.TemplateFuncDocumentation{
Description: "Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentGame <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`,
FakedOutput: "Metro Exodus - none",
},
})
tplFuncs.Register("recentTitle", plugins.GenericTemplateFunctionGetter(tplTwitchRecentTitle), plugins.TemplateFuncDocumentation{
Description: "Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentTitle <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`,
FakedOutput: "Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none",
},
})
tplFuncs.Register("streamUptime", plugins.GenericTemplateFunctionGetter(tplTwitchStreamUptime), plugins.TemplateFuncDocumentation{
Description: "Returns the duration the stream is online (causes an error if no current stream is found)",
Syntax: "streamUptime <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ formatDuration (streamUptime "luziferus") "hours" "minutes" "" }}`,
FakedOutput: "3 hours, 56 minutes",
},
})
}
func tplTwitchDisplayName(username string, v ...string) (string, error) {
displayName, err := twitchClient.GetDisplayNameForUser(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || displayName == "") {
return v[0], nil //nolint:nilerr // Default value, no need to return error
}
return displayName, err
}
func tplTwitchDoesFollow(from, to string) (bool, error) {
_, err := twitchClient.GetFollowDate(from, to)
switch {
case err == nil:
return true, nil
case errors.Is(err, twitch.ErrUserDoesNotFollow):
return false, nil
default:
return false, errors.Wrap(err, "getting follow date")
}
}
func tplTwitchFollowAge(from, to string) (time.Duration, error) {
since, err := twitchClient.GetFollowDate(from, to)
return time.Since(since), errors.Wrap(err, "getting follow date")
}
func tplTwitchFollowDate(from, to string) (time.Time, error) {
return twitchClient.GetFollowDate(from, to)
}
func tplTwitchDoesFollowLongerThan(from, to string, t any) (bool, error) {
var (
age time.Duration
err error
)
switch v := t.(type) {
case int64:
age = time.Duration(v) * time.Second
case string:
if age, err = time.ParseDuration(v); err != nil {
return false, errors.Wrap(err, "parsing duration")
}
default:
return false, errors.Errorf("unexpected input for duration %t", t)
}
fd, err := twitchClient.GetFollowDate(from, to)
switch {
case err == nil:
return time.Since(fd) > age, nil
case errors.Is(err, twitch.ErrUserDoesNotFollow):
return false, nil
default:
return false, errors.Wrap(err, "getting follow date")
}
}
func tplTwitchLastPoll(username string) (*twitch.PollInfo, error) {
hasPollAccess, err := accessService.HasAnyPermissionForChannel(username, twitch.ScopeChannelReadPolls, twitch.ScopeChannelManagePolls)
if err != nil {
return nil, errors.Wrap(err, "checking read-poll-permission")
}
if !hasPollAccess {
return nil, errors.Errorf("not authorized to read polls for channel %s", username)
}
tc, err := accessService.GetTwitchClientForChannel(strings.TrimLeft(username, "#"), access.ClientConfig{
TwitchClient: cfg.TwitchClient,
TwitchClientSecret: cfg.TwitchClientSecret,
})
if err != nil {
return nil, errors.Wrap(err, "getting twitch client for user")
}
poll, err := tc.GetLatestPoll(context.Background(), strings.TrimLeft(username, "#"))
return poll, errors.Wrap(err, "getting last poll")
}
func tplTwitchProfileImage(username string) (string, error) {
user, err := twitchClient.GetUserInformation(strings.TrimLeft(username, "#@"))
if err != nil {
return "", errors.Wrap(err, "getting user info")
}
return user.ProfileImageURL, nil
}
func tplTwitchRecentGame(username string, v ...string) (string, error) {
game, _, err := twitchClient.GetRecentStreamInfo(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || game == "") {
return v[0], nil
}
return game, err
}
func tplTwitchRecentTitle(username string, v ...string) (string, error) {
_, title, err := twitchClient.GetRecentStreamInfo(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || title == "") {
return v[0], nil
}
return title, err
}
func tplTwitchStreamUptime(username string) (time.Duration, error) {
si, err := twitchClient.GetCurrentStreamInfo(strings.TrimLeft(username, "#"))
if err != nil {
return 0, err
}
return time.Since(si.StartedAt), nil
}

37
go.mod
View File

@ -1,17 +1,16 @@
module github.com/Luzifer/twitch-bot/v3
go 1.20
go 1.21
require (
github.com/Luzifer/go-openssl/v4 v4.1.0
github.com/Luzifer/go-openssl/v4 v4.2.0
github.com/Luzifer/go_helpers/v2 v2.20.0
github.com/Luzifer/korvike/functions v0.11.0
github.com/Luzifer/rconfig/v2 v2.4.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/getsentry/sentry-go v0.22.0
github.com/getsentry/sentry-go v0.23.0
github.com/glebarez/sqlite v1.9.0
github.com/go-git/go-git/v5 v5.8.0
github.com/go-irc/irc v2.1.0+incompatible
github.com/go-git/go-git/v5 v5.8.1
github.com/go-sql-driver/mysql v1.7.1
github.com/gofrs/uuid v4.4.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
@ -25,18 +24,20 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.11.0
golang.org/x/crypto v0.12.0
gopkg.in/irc.v4 v4.0.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2
gorm.io/gorm v1.25.4
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
@ -49,7 +50,7 @@ require (
github.com/go-git/go-billy/v5 v5.4.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.4.0 // indirect
@ -58,7 +59,7 @@ require (
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/vault/api v1.9.2 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
@ -66,7 +67,7 @@ require (
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.2 // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@ -83,21 +84,21 @@ require (
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/skeema/knownhosts v1.1.1 // indirect
github.com/skeema/knownhosts v1.2.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.11.0 // indirect
golang.org/x/tools v0.12.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/sqlite v1.24.0 // indirect
modernc.org/memory v1.7.1 // indirect
modernc.org/sqlite v1.25.0 // indirect
)

102
go.sum
View File

@ -1,7 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Luzifer/go-openssl/v4 v4.1.0 h1:8qi3Z6f8Aflwub/Cs4FVSmKUEg/lC8GlODbR2TyZ+nM=
github.com/Luzifer/go-openssl/v4 v4.1.0/go.mod h1:3i1T3Pe6eQK19d86WhuQzjLyMwBaNmGmt3ZceWpWVa4=
github.com/Luzifer/go-openssl/v4 v4.2.0 h1:39/cZnBMg+/YC3hn1eVI02KyJfNwhiRO3AlxwZAvu0c=
github.com/Luzifer/go-openssl/v4 v4.2.0/go.mod h1:CZZZWY0buCtkxrkqDPQYigC4Kn55UuO97TEoV+hwz2s=
github.com/Luzifer/go_helpers/v2 v2.20.0 h1:OyCUs7TFGwfJpGqD21KEKKOXy92jetw2l7dlmG7HZnA=
github.com/Luzifer/go_helpers/v2 v2.20.0/go.mod h1:KPGjImwm51SmOTZMd9XUsT241gHYJuEyLrS/omQ4/Dw=
github.com/Luzifer/korvike/functions v0.11.0 h1:2hr3nnt9hy8Esu1W3h50+RggcLRXvrw92kVQLvxzd2Q=
@ -18,20 +20,21 @@ github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBa
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek=
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M=
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -41,6 +44,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@ -49,24 +53,27 @@ github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/getsentry/sentry-go v0.22.0 h1:XNX9zKbv7baSEI65l+H1GEJgSeIC1c7EN5kluWaP6dM=
github.com/getsentry/sentry-go v0.22.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/getsentry/sentry-go v0.23.0 h1:dn+QRCeJv4pPt9OjVXiMcGIBIefaTJPw/h0bZWO05nE=
github.com/getsentry/sentry-go v0.23.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
github.com/go-git/go-git/v5 v5.8.0 h1:Rc543s6Tyq+YcyPwZRvU4jzZGM8rB/wWu94TnTIYALQ=
github.com/go-git/go-git/v5 v5.8.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
github.com/go-irc/irc v2.1.0+incompatible h1:pg7pMVq5OYQbqTxceByD/EN8VIsba7DtKn49rsCnG8Y=
github.com/go-irc/irc v2.1.0+incompatible/go.mod h1:jJILTRy8s/qOvusiKifAEfhQMVwft1ZwQaVJnnzmyX4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=
github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
@ -75,6 +82,7 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY=
@ -89,10 +97,12 @@ github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@ -125,8 +135,9 @@ github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-sockaddr v1.0.4 h1:NJY/hSAoWy0EhQQdDxxoBlwyJex/xC2qNWXD0up6D48=
github.com/hashicorp/go-sockaddr v1.0.4/go.mod h1:LPGW7TbF+cTE2o/bBlBWD4XG8rgRJeIurURxH5kEHr8=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
@ -154,8 +165,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.2 h1:u1gmGDwbdRUZiwisBm/Ky2M14uQyUP65bG8+20nnyrg=
github.com/jackc/pgx/v5 v5.4.2/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -167,6 +178,7 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -177,6 +189,7 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@ -202,14 +215,18 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc=
github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ=
github.com/orandin/sentrus v1.0.0 h1:rMZKTUdwuhIaC7C6VbvhQPQeO9hBpliODrj7o/NmipM=
github.com/orandin/sentrus v1.0.0/go.mod h1:Mqa1Dcat0IcuD/XPMXUolzuZ74NWptnnX8eRq3gLaSU=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -222,6 +239,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0-pre.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -237,21 +255,24 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.1.1 h1:MTk78x9FPgDFVFkDLTrsnnfCJl7g1C/nnKvePgrIngE=
github.com/skeema/knownhosts v1.1.1/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb h1:G0Rrif8QdbAz7Xy53H4Xumy6TuyKHom8pu8z/jdLwwM=
@ -261,13 +282,13 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -288,8 +309,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -299,6 +320,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -314,7 +336,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -323,14 +344,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -339,9 +361,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -352,8 +375,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@ -368,8 +391,11 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/irc.v4 v4.0.0 h1:5jsLkU2Tg+R2nGNqmkGCrciasyi4kNkDXhyZD+C31yY=
gopkg.in/irc.v4 v4.0.0/go.mod h1:BfjDz9MmuWW6OZY7iq4naOhudO8+QQCdO4Ko18jcsRE=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
@ -386,15 +412,15 @@ gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw=
gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
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.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/memory v1.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M=
modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=

View File

@ -3,8 +3,8 @@ package announce
import (
"regexp"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,9 +4,9 @@ import (
"net/http"
"regexp"
"github.com/go-irc/irc"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,8 +4,8 @@ import (
"context"
"regexp"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/internal/actors/linkdetector"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"

View File

@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -6,9 +6,9 @@ import (
"strconv"
"strings"
"github.com/go-irc/irc"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,7 +4,7 @@ import (
"math/rand"
"time"
"github.com/go-irc/irc"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -1,8 +1,8 @@
package deleteactor
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -29,7 +29,7 @@ func Register(args plugins.RegistrationArguments) error {
type actor struct{}
func (a actor) Execute(_ *irc.Client, m *irc.Message, _ *plugins.Rule, eventData *plugins.FieldCollection, _ *plugins.FieldCollection) (preventCooldown bool, err error) {
msgID, ok := m.Tags.GetTag("id")
msgID, ok := m.Tags["id"]
if !ok || msgID == "" {
return false, nil
}

View File

@ -3,8 +3,8 @@ package eventmod
import (
"encoding/json"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -7,8 +7,8 @@ import (
"net/url"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -1,7 +1,7 @@
package linkdetector
import (
"github.com/go-irc/irc"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/internal/linkcheck"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -5,8 +5,8 @@ import (
"strings"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -172,7 +172,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
case "delete":
msgID, ok := m.Tags.GetTag("id")
msgID, ok := m.Tags["id"]
if !ok || msgID == "" {
return false, errors.New("found no mesage id")
}

View File

@ -1,8 +1,8 @@
package log
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/sirupsen/logrus"

View File

@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -4,8 +4,8 @@ import (
"net/http"
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -4,8 +4,8 @@ import (
"context"
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -6,9 +6,9 @@ import (
"sync"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -210,7 +210,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
enforcement := strings.NewReplacer(
"$msgid", string(stMsg.Msg.Tags["id"]),
"$msgid", stMsg.Msg.Tags["id"],
"$user", plugins.DeriveUser(stMsg.Msg, nil),
).Replace(actionName)
@ -218,7 +218,7 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
continue
}
if err = action(channel, rawMatch, string(stMsg.Msg.Tags["id"]), plugins.DeriveUser(stMsg.Msg, nil)); err != nil {
if err = action(channel, rawMatch, stMsg.Msg.Tags["id"], plugins.DeriveUser(stMsg.Msg, nil)); err != nil {
return false, errors.Wrap(err, "executing action")
}

View File

@ -5,8 +5,8 @@ import (
"strings"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -16,7 +16,6 @@ import (
const (
actorNamePunish = "punish"
actorNameResetPunish = "reset-punish"
moduleUUID = "44ab4646-ce50-4e16-9353-c1f0eb68962b"
oneWeek = 168 * time.Hour
)
@ -173,7 +172,7 @@ func (a actorPunish) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eve
}
case "delete":
msgID, ok := m.Tags.GetTag("id")
msgID, ok := m.Tags["id"]
if !ok || msgID == "" {
return false, errors.New("found no mesage id")
}

View File

@ -3,16 +3,15 @@ package quotedb
import (
"strconv"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const (
actorName = "quotedb"
moduleUUID = "917c83ee-ed40-41e4-a558-1c2e59fdf1f5"
actorName = "quotedb"
)
var (

View File

@ -1,8 +1,8 @@
package raw
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -6,10 +6,10 @@ import (
"net/http"
"strings"
"github.com/go-irc/irc"
"github.com/gorilla/mux"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -124,12 +124,12 @@ func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
if attrs.MustBool("as_reply", ptrBoolFalse) {
id, ok := m.GetTag("id")
id, ok := m.Tags["id"]
if ok {
if ircMessage.Tags == nil {
ircMessage.Tags = make(irc.Tags)
}
ircMessage.Tags["reply-parent-msg-id"] = irc.TagValue(id)
ircMessage.Tags["reply-parent-msg-id"] = id
}
}

View File

@ -3,8 +3,8 @@ package shield
import (
"context"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -3,8 +3,8 @@ package shoutout
import (
"regexp"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -1,8 +1,8 @@
package stopexec
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -5,8 +5,8 @@ import (
"strconv"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,9 +4,9 @@ import (
"fmt"
"net/http"
"github.com/go-irc/irc"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -4,8 +4,8 @@ import (
"context"
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -1,8 +1,8 @@
package whisper
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"

View File

@ -3,8 +3,8 @@ package customevent
import (
"strings"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -5,8 +5,8 @@ import (
"sync"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -56,7 +56,7 @@ type (
TextEntryFailPost bool `json:"textEntryFailPost"`
TextReminder string `json:"textReminder"`
TextReminderInterval time.Duration `json:"textReminderInterval"`
TextReminderNextSend time.Time `json:"-"`
TextReminderNextSend *time.Time `json:"-"`
TextReminderPost bool `json:"textReminderPost"`
TextWin string `json:"textWin"`
TextWinPost bool `json:"textWinPost"`
@ -143,7 +143,7 @@ func (d *dbClient) AutoSendReminders() (err error) {
var rr []raffle
if err = d.db.DB().
Where("status = ? AND text_reminder_post = ? AND text_reminder_next_send < ?", raffleStatusActive, true, time.Now().UTC()).
Where("status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)", raffleStatusActive, true, time.Now().UTC()).
Find(&rr).
Error; err != nil {
return errors.Wrap(err, "fetching raffles to send reminders")

View File

@ -4,9 +4,9 @@ import (
"strings"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -84,9 +84,9 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
re := raffleEntry{
RaffleID: r.ID,
UserID: string(m.Tags["user-id"]),
UserID: m.Tags["user-id"],
UserLogin: user,
UserDisplayName: string(m.Tags["display-name"]),
UserDisplayName: m.Tags["display-name"],
EnteredAt: time.Now().UTC(),
}
@ -95,7 +95,7 @@ func handleRaffleEntry(m *irc.Message, channel, user string) error {
}
raffleEventFields := plugins.FieldCollectionFromData(map[string]any{
"user_id": string(m.Tags["user-id"]),
"user_id": m.Tags["user-id"],
"user": user,
})

View File

@ -23,7 +23,7 @@ type (
TwitchClient string
TwitchClientSecret string
FallbackToken string
FallbackToken string // DEPRECATED
TokenUpdateHook func()
}
@ -228,20 +228,6 @@ func (s Service) RemoveExendedTwitchCredentials(channel string) error {
)
}
// Deprecated: Use SetBotUsername and SetExtendedTwitchCredentials
// instead. This function is only required for the v2 migration tool.
func (s Service) SetBotTwitchCredentials(accessToken, refreshToken string) (err error) {
if err = s.db.StoreEncryptedCoreMeta(coreMetaKeyBotToken, accessToken); err != nil {
return errors.Wrap(err, "storing bot access token")
}
if err = s.db.StoreEncryptedCoreMeta(coreMetaKeyBotRefreshToken, refreshToken); err != nil {
return errors.Wrap(err, "storing bot refresh token")
}
return nil
}
func (s Service) SetBotUsername(channel string) (err error) {
return errors.Wrap(
s.db.StoreCoreMeta(coreMetaKeyBotUsername, strings.TrimLeft(channel, "#")),

View File

@ -0,0 +1,110 @@
package twitch
import (
"time"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
)
func init() {
regFn = append(
regFn,
tplTwitchDoesFollow,
tplTwitchDoesFollowLongerThan,
tplTwitchFollowAge,
tplTwitchFollowDate,
)
}
func tplTwitchDoesFollowLongerThan(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("doesFollowLongerThan", plugins.GenericTemplateFunctionGetter(func(from, to string, t any) (bool, error) {
var (
age time.Duration
err error
)
switch v := t.(type) {
case int64:
age = time.Duration(v) * time.Second
case string:
if age, err = time.ParseDuration(v); err != nil {
return false, errors.Wrap(err, "parsing duration")
}
default:
return false, errors.Errorf("unexpected input for duration %t", t)
}
fd, err := args.GetTwitchClient().GetFollowDate(from, to)
switch {
case err == nil:
return time.Since(fd) > age, nil
case errors.Is(err, twitch.ErrUserDoesNotFollow):
return false, nil
default:
return false, errors.Wrap(err, "getting follow date")
}
}), plugins.TemplateFuncDocumentation{
Description: "Returns whether `from` follows `to` for more than `duration`",
Syntax: "doesFollowLongerThan <from> <to> <duration>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ doesFollowLongerThan "tezrian" "luziferus" "168h" }}`,
FakedOutput: "true",
},
})
}
func tplTwitchDoesFollow(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("doesFollow", plugins.GenericTemplateFunctionGetter(func(from, to string) (bool, error) {
_, err := args.GetTwitchClient().GetFollowDate(from, to)
switch {
case err == nil:
return true, nil
case errors.Is(err, twitch.ErrUserDoesNotFollow):
return false, nil
default:
return false, errors.Wrap(err, "getting follow date")
}
}), plugins.TemplateFuncDocumentation{
Description: "Returns whether `from` follows `to`",
Syntax: "doesFollow <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ doesFollow "tezrian" "luziferus" }}`,
FakedOutput: "true",
},
})
}
func tplTwitchFollowAge(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("followAge", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Duration, error) {
since, err := args.GetTwitchClient().GetFollowDate(from, to)
return time.Since(since), errors.Wrap(err, "getting follow date")
}), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to` and returns the duration between then and now",
Syntax: "followAge <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ followAge "tezrian" "luziferus" }}`,
FakedOutput: "15004h14m59.116620989s",
},
})
}
func tplTwitchFollowDate(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("followDate", plugins.GenericTemplateFunctionGetter(func(from, to string) (time.Time, error) {
return args.GetTwitchClient().GetFollowDate(from, to)
}), plugins.TemplateFuncDocumentation{
Description: "Looks up when `from` followed `to`",
Syntax: "followDate <from> <to>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ followDate "tezrian" "luziferus" }}`,
FakedOutput: "2021-04-10 16:07:07 +0000 UTC",
},
})
}

View File

@ -0,0 +1,46 @@
package twitch
import (
"context"
"strings"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
)
func init() {
regFn = append(
regFn,
tplTwitchLastPoll,
)
}
func tplTwitchLastPoll(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("lastPoll", plugins.GenericTemplateFunctionGetter(func(username string) (*twitch.PollInfo, error) {
hasPollAccess, err := args.HasAnyPermissionForChannel(username, twitch.ScopeChannelReadPolls, twitch.ScopeChannelManagePolls)
if err != nil {
return nil, errors.Wrap(err, "checking read-poll-permission")
}
if !hasPollAccess {
return nil, errors.Errorf("not authorized to read polls for channel %s", username)
}
tc, err := args.GetTwitchClientForChannel(strings.TrimLeft(username, "#"))
if err != nil {
return nil, errors.Wrap(err, "getting twitch client for user")
}
poll, err := tc.GetLatestPoll(context.Background(), strings.TrimLeft(username, "#"))
return poll, errors.Wrap(err, "getting last poll")
}), plugins.TemplateFuncDocumentation{
Description: "Gets the last (currently running or archived) poll for the given channel (the channel must have given extended permission for poll access!)",
Syntax: "lastPoll <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `Last Poll: {{ (lastPoll .channel).Title }}`,
FakedOutput: "Last Poll: Und wie siehts im Template aus?",
},
Remarks: "See schema of returned object in [`pkg/twitch/polls.go#L13`](https://github.com/Luzifer/twitch-bot/blob/master/pkg/twitch/polls.go#L13)",
})
}

View File

@ -0,0 +1,70 @@
package twitch
import (
"strings"
"time"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
func init() {
regFn = append(
regFn,
tplTwitchRecentGame,
tplTwitchRecentTitle,
tplTwitchStreamUptime,
)
}
func tplTwitchRecentGame(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("recentGame", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
game, _, err := args.GetTwitchClient().GetRecentStreamInfo(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || game == "") {
return v[0], nil
}
return game, err
}), plugins.TemplateFuncDocumentation{
Description: "Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentGame <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`,
FakedOutput: "Metro Exodus - none",
},
})
}
func tplTwitchRecentTitle(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("recentTitle", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
_, title, err := args.GetTwitchClient().GetRecentStreamInfo(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || title == "") {
return v[0], nil
}
return title, err
}), plugins.TemplateFuncDocumentation{
Description: "Returns the last stream title of the specified user or the `fallback` if the title could not be fetched. If no fallback was supplied the message will fail and not be sent.",
Syntax: "recentTitle <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ recentGame "luziferus" "none" }} - {{ recentGame "thisuserdoesnotexist123" "none" }}`,
FakedOutput: "Die Oper haben wir überlebt, mal sehen was uns sonst noch alles töten möchte… - none",
},
})
}
func tplTwitchStreamUptime(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("streamUptime", plugins.GenericTemplateFunctionGetter(func(username string) (time.Duration, error) {
si, err := args.GetTwitchClient().GetCurrentStreamInfo(strings.TrimLeft(username, "#"))
if err != nil {
return 0, err
}
return time.Since(si.StartedAt), nil
}), plugins.TemplateFuncDocumentation{
Description: "Returns the duration the stream is online (causes an error if no current stream is found)",
Syntax: "streamUptime <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ formatDuration (streamUptime "luziferus") "hours" "minutes" "" }}`,
FakedOutput: "3 hours, 56 minutes",
},
})
}

View File

@ -0,0 +1,17 @@
// Package twitch defines Twitch related template functions not having
// their place in any other package
package twitch
import (
"github.com/Luzifer/twitch-bot/v3/plugins"
)
var regFn []func(plugins.RegistrationArguments)
func Register(args plugins.RegistrationArguments) error {
for _, fn := range regFn {
fn(args)
}
return nil
}

View File

@ -0,0 +1,81 @@
package twitch
import (
"context"
"strings"
"github.com/Luzifer/twitch-bot/v3/plugins"
"github.com/pkg/errors"
)
func init() {
regFn = append(
regFn,
tplTwitchDisplayName,
tplTwitchIDForUsername,
tplTwitchProfileImage,
tplTwitchUsernameForID,
)
}
func tplTwitchDisplayName(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("displayName", plugins.GenericTemplateFunctionGetter(func(username string, v ...string) (string, error) {
displayName, err := args.GetTwitchClient().GetDisplayNameForUser(strings.TrimLeft(username, "#"))
if len(v) > 0 && (err != nil || displayName == "") {
return v[0], nil //nolint:nilerr // Default value, no need to return error
}
return displayName, err
}), plugins.TemplateFuncDocumentation{
Description: "Returns the display name the specified user set for themselves",
Syntax: "displayName <username> [fallback]",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ displayName "luziferus" }} - {{ displayName "notexistinguser" "foobar" }}`,
FakedOutput: "Luziferus - foobar",
},
})
}
func tplTwitchIDForUsername(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("idForUsername", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) {
return args.GetTwitchClient().GetIDForUsername(username)
}), plugins.TemplateFuncDocumentation{
Description: "Returns the user-id for the given username",
Syntax: "idForUsername <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ idForUsername "twitch" }}`,
FakedOutput: "12826",
},
})
}
func tplTwitchProfileImage(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("profileImage", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) {
user, err := args.GetTwitchClient().GetUserInformation(strings.TrimLeft(username, "#@"))
if err != nil {
return "", errors.Wrap(err, "getting user info")
}
return user.ProfileImageURL, nil
}), plugins.TemplateFuncDocumentation{
Description: "Gets the URL of the given users profile image",
Syntax: "profileImage <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ profileImage .username }}`,
FakedOutput: "https://static-cdn.jtvnw.net/jtv_user_pictures/[...].png",
},
})
}
func tplTwitchUsernameForID(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("usernameForID", plugins.GenericTemplateFunctionGetter(func(id string) (string, error) {
return args.GetTwitchClient().GetUsernameForID(context.Background(), id)
}), plugins.TemplateFuncDocumentation{
Description: "Returns the current login name of an user-id",
Syntax: "usernameForID <user-id>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ usernameForID "12826" }}`,
FakedOutput: "twitch",
},
})
}

View File

@ -1,84 +0,0 @@
package v2migrator
import (
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v3/internal/actors/counter"
"github.com/Luzifer/twitch-bot/v3/internal/actors/variables"
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
)
func (s storageFile) migrateCoreKV(db database.Connector) (err error) {
as, err := access.New(db)
if err != nil {
return errors.Wrap(err, "creating access service")
}
//nolint:staticcheck // Use of deprecated function is fine for this purpose
if err = as.SetBotTwitchCredentials(s.BotAccessToken, s.BotRefreshToken); err != nil {
return errors.Wrap(err, "setting bot credentials")
}
if err = db.StoreEncryptedCoreMeta("event_sub_secret", s.EventSubSecret); err != nil {
return errors.Wrap(err, "storing bot eventsub token")
}
return nil
}
func (s storageFile) migrateCounters(db database.Connector) (err error) {
for counterName, value := range s.Counters {
if err = counter.UpdateCounter(db, counterName, value, true); err != nil {
return errors.Wrap(err, "storing counter value")
}
}
return nil
}
func (s storageFile) migratePermissions(db database.Connector) (err error) {
as, err := access.New(db)
if err != nil {
return errors.Wrap(err, "creating access service")
}
for channel, perms := range s.ExtendedPermissions {
if err = as.SetExtendedTwitchCredentials(
channel,
perms.AccessToken,
perms.RefreshToken,
perms.Scopes,
); err != nil {
return errors.Wrapf(err, "storing channel %q credentials", channel)
}
}
return nil
}
func (s storageFile) migrateTimers(db database.Connector) (err error) {
ts, err := timer.New(db, nil)
if err != nil {
return errors.Wrap(err, "creating timer service")
}
for id, expiry := range s.Timers {
if err := ts.SetTimer(id, expiry.Time); err != nil {
return errors.Wrap(err, "storing counter in database")
}
}
return nil
}
func (s storageFile) migrateVariables(db database.Connector) (err error) {
for key, value := range s.Variables {
if err := variables.SetVariable(db, key, value); err != nil {
return errors.Wrap(err, "updating value in database")
}
}
return nil
}

View File

@ -1,139 +0,0 @@
package crypt
import (
"reflect"
"strings"
"time"
"github.com/pkg/errors"
"github.com/Luzifer/go-openssl/v4"
)
const encryptedValuePrefix = "enc:"
type encryptAction uint8
const (
handleTagsDecrypt encryptAction = iota
handleTagsEncrypt
)
var osslClient = openssl.New()
// DecryptFields iterates through the given struct and decrypts all
// fields marked with a struct tag of `encrypt:"true"`. The fields
// are directly manipulated and the value is replaced.
//
// The input object needs to be a pointer to a struct!
func DecryptFields(obj interface{}, passphrase string) error {
return handleEncryptedTags(obj, passphrase, handleTagsDecrypt)
}
// EncryptFields iterates through the given struct and encrypts all
// fields marked with a struct tag of `encrypt:"true"`. The fields
// are directly manipulated and the value is replaced.
//
// The input object needs to be a pointer to a struct!
func EncryptFields(obj interface{}, passphrase string) error {
return handleEncryptedTags(obj, passphrase, handleTagsEncrypt)
}
//nolint:gocognit,gocyclo // Reflect loop, cannot reduce complexity
func handleEncryptedTags(obj interface{}, passphrase string, action encryptAction) error {
// Check we got a pointer and can manipulate the struct
if kind := reflect.TypeOf(obj).Kind(); kind != reflect.Ptr {
return errors.Errorf("expected pointer to struct, got %s", kind)
}
// Check we got a struct in the pointer
if kind := reflect.ValueOf(obj).Elem().Kind(); kind != reflect.Struct {
return errors.Errorf("expected pointer to struct, got pointer to %s", kind)
}
// Iterate over fields to find encrypted fields to manipulate
st := reflect.ValueOf(obj).Elem()
for i := 0; i < st.NumField(); i++ {
v := st.Field(i)
t := st.Type().Field(i)
if t.PkgPath != "" && !t.Anonymous {
// Caught us an non-exported field, ignore that one
continue
}
hasEncryption := t.Tag.Get("encrypt") == "true"
switch t.Type.Kind() {
// Type: Map - see whether value is struct
case reflect.Map:
if t.Type.Elem().Kind() == reflect.Ptr && t.Type.Elem().Elem().Kind() == reflect.Struct {
for _, k := range v.MapKeys() {
if err := handleEncryptedTags(v.MapIndex(k).Interface(), passphrase, action); err != nil {
return err
}
}
}
// Type: Pointer - Recurse if not nil and struct inside
case reflect.Ptr:
if !v.IsNil() && v.Elem().Kind() == reflect.Struct && t.Type != reflect.TypeOf(&time.Time{}) {
if err := handleEncryptedTags(v.Interface(), passphrase, action); err != nil {
return err
}
}
// Type: String - Replace value if required
case reflect.String:
if hasEncryption {
newValue, err := manipulateValue(v.String(), passphrase, action)
if err != nil {
return errors.Wrap(err, "manipulating value")
}
v.SetString(newValue)
}
// Type: Struct - Welcome to recursion
case reflect.Struct:
if t.Type != reflect.TypeOf(time.Time{}) {
if err := handleEncryptedTags(v.Addr().Interface(), passphrase, action); err != nil {
return err
}
}
// We don't support anything else. Yet.
default:
if hasEncryption {
return errors.Errorf("unsupported field type for encyption: %s", t.Type.Kind())
}
}
}
return nil
}
func manipulateValue(val, passphrase string, action encryptAction) (string, error) {
switch action {
case handleTagsDecrypt:
if !strings.HasPrefix(val, encryptedValuePrefix) {
// This is not an encrypted string: Return the value itself for
// working with legacy values in storage
return val, nil
}
d, err := osslClient.DecryptBytes(passphrase, []byte(strings.TrimPrefix(val, encryptedValuePrefix)), openssl.PBKDF2SHA256)
return string(d), errors.Wrap(err, "decrypting value")
case handleTagsEncrypt:
if strings.HasPrefix(val, encryptedValuePrefix) {
// This is an encrypted string: shouldn't happen but whatever
return val, nil
}
e, err := osslClient.EncryptBytes(passphrase, []byte(val), openssl.PBKDF2SHA256)
return encryptedValuePrefix + string(e), errors.Wrap(err, "encrypting value")
default:
return "", errors.New("invalid action")
}
}

View File

@ -1,26 +0,0 @@
package v2migrator
import (
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v3/internal/apimodules/overlays"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
)
type (
storageModOverlays struct {
ChannelEvents map[string][]overlays.SocketMessage `json:"channel_events"`
}
)
func (s storageModOverlays) migrate(db database.Connector) (err error) {
for channel, evts := range s.ChannelEvents {
for _, evt := range evts {
if err := overlays.AddChannelEvent(db, channel, evt); err != nil {
return errors.Wrap(err, "storing event to database")
}
}
}
return nil
}

View File

@ -1,24 +0,0 @@
package v2migrator
import (
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v3/internal/actors/quotedb"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
)
type (
storageModQuoteDB struct {
ChannelQuotes map[string][]string `json:"channel_quotes"`
}
)
func (s storageModQuoteDB) migrate(db database.Connector) (err error) {
for channel, quotes := range s.ChannelQuotes {
if err := quotedb.SetQuotes(db, channel, quotes); err != nil {
return errors.Wrap(err, "setting quotes for channel")
}
}
return nil
}

View File

@ -1,117 +0,0 @@
package v2migrator
import (
"compress/gzip"
"encoding/json"
"os"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/Luzifer/twitch-bot/v3/internal/v2migrator/crypt"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
type (
Migrator interface {
Load(filename, encryptionPass string) error
Migrate(db database.Connector) error
}
storageExtendedPermission struct {
AccessToken string `encrypt:"true" json:"access_token,omitempty"`
RefreshToken string `encrypt:"true" json:"refresh_token,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
storageFile struct {
Counters map[string]int64 `json:"counters"`
Timers map[string]plugins.TimerEntry `json:"timers"`
Variables map[string]string `json:"variables"`
ModuleStorage struct {
ModOverlays storageModOverlays `json:"f9ca2b3a-baf6-45ea-a347-c626168665e8"`
ModQuoteDB storageModQuoteDB `json:"917c83ee-ed40-41e4-a558-1c2e59fdf1f5"`
} `json:"module_storage"`
ExtendedPermissions map[string]*storageExtendedPermission `json:"extended_permissions"`
EventSubSecret string `encrypt:"true" json:"event_sub_secret,omitempty"`
BotAccessToken string `encrypt:"true" json:"bot_access_token,omitempty"`
BotRefreshToken string `encrypt:"true" json:"bot_refresh_token,omitempty"`
}
)
func NewStorageFile() Migrator {
return &storageFile{
Counters: map[string]int64{},
Timers: map[string]plugins.TimerEntry{},
Variables: map[string]string{},
ExtendedPermissions: map[string]*storageExtendedPermission{},
}
}
func (s *storageFile) Load(filename, encryptionPass string) error {
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
// Store init state
return nil
}
return errors.Wrap(err, "open storage file")
}
defer f.Close()
zf, err := gzip.NewReader(f)
if err != nil {
return errors.Wrap(err, "create gzip reader")
}
defer zf.Close()
if err = json.NewDecoder(zf).Decode(s); err != nil {
return errors.Wrap(err, "decode storage object")
}
if err = crypt.DecryptFields(s, encryptionPass); err != nil {
return errors.Wrap(err, "decrypting storage object")
}
return nil
}
func (s storageFile) Migrate(db database.Connector) error {
var bat string
err := db.ReadCoreMeta("bot_access_token", &bat)
switch {
case err == nil:
return errors.New("Access token is set, database already initialized")
case errors.Is(err, database.ErrCoreMetaNotFound):
// This is the expected state
default:
return errors.Wrap(err, "checking for bot access token")
}
for name, fn := range map[string]func(database.Connector) error{
// Core
"core": s.migrateCoreKV,
"counter": s.migrateCounters,
"permissions": s.migratePermissions,
"timers": s.migrateTimers,
"variables": s.migrateVariables,
// Modules
"mod_overlays": s.ModuleStorage.ModOverlays.migrate,
"mod_quotedb": s.ModuleStorage.ModQuoteDB.migrate,
} {
logrus.WithField("module", name).Info("Starting migration...")
if err = fn(db); err != nil {
return errors.Wrapf(err, "executing %q migration", name)
}
}
return nil
}

26
irc.go
View File

@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/tls"
"fmt"
"math"
@ -9,9 +10,9 @@ import (
"sync"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -45,9 +46,11 @@ func registerRawMessageHandler(fn plugins.RawMessageHandlerFunc) error {
}
type ircHandler struct {
conn *tls.Conn
c *irc.Client
user string
c *irc.Client
conn *tls.Conn
ctx context.Context
ctxCancelFn func()
user string
}
func newIRCHandler() (*ircHandler, error) {
@ -58,6 +61,8 @@ func newIRCHandler() (*ircHandler, error) {
return nil, errors.Wrap(err, "fetching username")
}
h.ctx, h.ctxCancelFn = context.WithCancel(context.Background())
conn, err := tls.Dial("tcp", "irc.chat.twitch.tv:6697", nil)
if err != nil {
return nil, errors.Wrap(err, "connect to IRC server")
@ -86,7 +91,10 @@ func newIRCHandler() (*ircHandler, error) {
func (i ircHandler) Client() *irc.Client { return i.c }
func (i ircHandler) Close() error { return i.conn.Close() }
func (i ircHandler) Close() error {
i.ctxCancelFn()
return nil
}
func (i ircHandler) ExecuteJoins(channels []string) {
for _, ch := range channels {
@ -197,7 +205,7 @@ func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
}
}
func (i ircHandler) Run() error { return errors.Wrap(i.c.Run(), "running IRC client") }
func (i ircHandler) Run() error { return errors.Wrap(i.c.RunContext(i.ctx), "running IRC client") }
func (i ircHandler) SendMessage(m *irc.Message) error { return i.c.WriteMessage(m) }
@ -209,8 +217,8 @@ func (ircHandler) getChannel(m *irc.Message) string {
}
func (i ircHandler) handleClearChat(m *irc.Message) {
seconds, secondsErr := strconv.Atoi(string(m.Tags["ban-duration"]))
targetUserID, hasTargetUserID := m.Tags.GetTag("target-user-id")
seconds, secondsErr := strconv.Atoi(m.Tags["ban-duration"])
targetUserID, hasTargetUserID := m.Tags["target-user-id"]
var (
evt *string
@ -493,7 +501,7 @@ func (i ircHandler) handleTwitchWhisper(m *irc.Message) {
}
func (ircHandler) tagToNumeric(m *irc.Message, tag string, fallback int64) int64 {
tv := string(m.Tags[tag])
tv := m.Tags[tag]
if tv == "" {
return fallback
}

View File

@ -51,7 +51,7 @@ 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)"`
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"`
@ -113,6 +113,10 @@ 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
}

View File

@ -13,9 +13,22 @@ import (
)
func TestMain(m *testing.M) {
var err error
var (
dbEngine = "sqlite"
dbDSN = "file::memory:?cache=shared"
if db, err = database.New("sqlite", "file::memory:?cache=shared", "encpass"); err != nil {
err error
)
if v := os.Getenv("GO_TEST_DB_ENGINE"); v != "" {
dbEngine = v
}
if v := os.Getenv("GO_TEST_DB_DSN"); v != "" {
dbDSN = v
}
if db, err = database.New(dbEngine, dbDSN, "go-test-static-encryption"); err != nil {
log.WithError(err).Fatal("opening storage backend")
}

View File

@ -7,8 +7,8 @@ import (
"text/template"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)

View File

@ -4,7 +4,7 @@ import (
"strconv"
"strings"
"github.com/go-irc/irc"
"gopkg.in/irc.v4"
)
const (
@ -32,7 +32,7 @@ func ParseBadgeLevels(m *irc.Message) BadgeCollection {
return out
}
badgeString, ok := m.GetTag("badges")
badgeString, ok := m.Tags["badges"]
if !ok || len(badgeString) == 0 {
return out
}

View File

@ -24,6 +24,7 @@ const (
ScopeModeratorReadFollowers = "moderator:read:followers"
ScopeModeratorReadShoutouts = "moderator:read:shoutouts"
ScopeUserManageChatColor = "user:manage:chat_color"
ScopeUserManageWhispers = "user:manage:whispers"
// Deprecated v5 scope but used in chat
ScopeV5ChannelEditor = "channel_editor"

View File

@ -159,6 +159,39 @@ func (c *Client) GetIDForUsername(username string) (string, error) {
return payload.Data[0].ID, nil
}
// GetUsernameForID retrieves the login name (not the display name)
// for the given user ID
func (c *Client) GetUsernameForID(ctx context.Context, id string) (string, error) {
cacheKey := []string{"usernameForID", id}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.(string), nil
}
var payload struct {
Data []User `json:"data"`
}
if err := c.Request(ClientRequestOpts{
AuthType: AuthTypeAppAccessToken,
Context: ctx,
Method: http.MethodGet,
OKStatus: http.StatusOK,
Out: &payload,
URL: fmt.Sprintf("https://api.twitch.tv/helix/users?id=%s", id),
}); err != nil {
return "", errors.Wrap(err, "request channel info")
}
if l := len(payload.Data); l != 1 {
return "", errors.Errorf("unexpected number of users returned: %d", l)
}
// The username for an ID will not change (often), cache for a long time
c.apiCache.Set(cacheKey, timeDay, payload.Data[0].Login)
return payload.Data[0].Login, nil
}
func (c *Client) GetUserInformation(user string) (*User, error) {
user = strings.TrimLeft(user, "#@")

View File

@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/go-irc/irc"
"gopkg.in/irc.v4"
)
func DeriveChannel(m *irc.Message, evtData *FieldCollection) string {

View File

@ -1,10 +1,10 @@
package plugins
import (
"github.com/go-irc/irc"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
@ -43,6 +43,8 @@ type (
LoggerCreationFunc func(moduleName string) *log.Entry
ModuleConfigGetterFunc func(module, channel string) *FieldCollection
MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields *FieldCollection) (string, error)
MsgModificationFunc func(*irc.Message) error
@ -65,6 +67,8 @@ type (
GetDatabaseConnector func() database.Connector
// GetLogger returns a sirupsen log.Entry pre-configured with the module name
GetLogger LoggerCreationFunc
// GetModuleConfigForChannel returns the module configuration for the given channel if available
GetModuleConfigForChannel ModuleConfigGetterFunc
// GetTwitchClient retrieves a fully configured Twitch client with initialized cache
GetTwitchClient func() *twitch.Client
// GetTwitchClientForChannel retrieves a fully configured Twitch client with initialized cache for extended permission channels

37
plugins/moduleConfig.go Normal file
View File

@ -0,0 +1,37 @@
package plugins
import "strings"
// DefaultConfigName is the name the default configuration must have
// when defined
const DefaultConfigName = "default"
type (
// ModuleConfig represents a mapping of configurations per channel
// and module
ModuleConfig map[string]map[string]*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 {
channel = strings.TrimLeft(channel, "#@")
composed := NewFieldCollection()
for _, i := range []string{DefaultConfigName, channel} {
f := m[module][i]
if f == nil {
// That config does not exist, don't apply
continue
}
for k, v := range f.Data() {
// Overwrite all keys defined in this config
composed.Set(k, v)
}
}
return composed
}

View File

@ -0,0 +1,38 @@
package plugins
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestModuleConfigGet(t *testing.T) {
strPtrEmpty := func(v string) *string { return &v }("")
m := ModuleConfig{
"test": map[string]*FieldCollection{
DefaultConfigName: FieldCollectionFromData(map[string]any{
"setindefault": DefaultConfigName,
"setinboth": DefaultConfigName,
}),
"test": FieldCollectionFromData(map[string]any{
"setinchannel": "channel",
"setinboth": "channel",
}),
},
}
fields := m.GetChannelConfig("module_does_not_exist", "test")
require.NotNil(t, fields, "must always return a valid FieldCollection")
assert.Len(t, fields.Data(), 0)
fields = m.GetChannelConfig("test", "test")
assert.Equal(t, DefaultConfigName, fields.MustString("setindefault", strPtrEmpty))
assert.Equal(t, "channel", fields.MustString("setinchannel", strPtrEmpty))
assert.Equal(t, "channel", fields.MustString("setinboth", strPtrEmpty))
fields = m.GetChannelConfig("test", "channel_not_configured")
assert.Equal(t, DefaultConfigName, fields.MustString("setindefault", strPtrEmpty))
assert.Equal(t, "", fields.MustString("setinchannel", strPtrEmpty))
assert.Equal(t, DefaultConfigName, fields.MustString("setinboth", strPtrEmpty))
}

View File

@ -11,10 +11,10 @@ import (
"strings"
"time"
"github.com/go-irc/irc"
"github.com/mitchellh/hashstructure/v2"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"gopkg.in/yaml.v3"
"github.com/Luzifer/go_helpers/v2/str"

View File

@ -5,8 +5,8 @@ import (
"testing"
"time"
"github.com/go-irc/irc"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)

View File

@ -4,9 +4,9 @@ import (
"fmt"
"net/http"
"github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/str"
@ -48,6 +48,7 @@ import (
"github.com/Luzifer/twitch-bot/v3/internal/template/slice"
"github.com/Luzifer/twitch-bot/v3/internal/template/strings"
"github.com/Luzifer/twitch-bot/v3/internal/template/subscriber"
twitchFns "github.com/Luzifer/twitch-bot/v3/internal/template/twitch"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
@ -93,6 +94,7 @@ var (
slice.Register,
strings.Register,
subscriber.Register,
twitchFns.Register,
// API-only modules
customevent.Register,
@ -148,10 +150,6 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
func getRegistrationArguments() plugins.RegistrationArguments {
return plugins.RegistrationArguments{
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
handleMessage(ircHdl.Client(), nil, &evt, eventData)
return nil
},
FormatMessage: formatMessage,
FrontendNotify: func(mt string) { frontendNotifyHooks.Ping(mt) },
GetDatabaseConnector: func() database.Connector { return db },
@ -170,6 +168,15 @@ func getRegistrationArguments() plugins.RegistrationArguments {
SendMessage: sendMessage,
ValidateToken: validateAuthToken,
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
handleMessage(ircHdl.Client(), nil, &evt, eventData)
return nil
},
GetModuleConfigForChannel: func(module, channel string) *plugins.FieldCollection {
return config.ModuleConfig.GetChannelConfig(module, channel)
},
GetTwitchClientForChannel: func(channel string) (*twitch.Client, error) {
return accessService.GetTwitchClientForChannel(channel, access.ClientConfig{
TwitchClient: cfg.TwitchClient,

View File

@ -15,6 +15,7 @@ var (
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",
}
botDefaultScopes = []string{

View File

@ -8,9 +8,9 @@ import (
"text/template"
"time"
"github.com/go-irc/irc"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
@ -63,7 +63,7 @@ func generateTplDocsRender(e *plugins.TemplateFuncDocumentationExample) (string,
User: "exampleuser",
Host: "exampleuser.tmi.twitch.tv",
},
Tags: map[string]irc.TagValue{
Tags: map[string]string{
"badge-info": "subscriber/26",
"badges": "moderator/1,subscriber/24",
"color": "#8A2BE2",

View File

@ -67,13 +67,4 @@ Example:
{{ end -}}
{{- end -}}
## Upgrade from `v2.x` to `v3.x`
When adding [sprig](https://masterminds.github.io/sprig/) function collection some functions collided and needed replacement. You need to adapt your templates accordingly:
- Math functions (`add`, `div`, `mod`, `mul`, `multiply`, `sub`) were replaced with their sprig-equivalent and are now working with integers instead of floats. If you need them to continue to work with floats you need to use their [float-variants](https://masterminds.github.io/sprig/mathf.html).
- `now` does no longer format the current date as a string but return the current date. You need to replace this: `now "2006-01-02"` becomes `now | date "2006-01-02"`.
- `concat` is now used to concat arrays. To join strings you will need to modify your code: `concat ":" "string1" "string2"` becomes `lists "string1" "string2" | join ":"`.
- `toLower` / `toUpper` need to be replaced with their sprig equivalent `lower` and `upper`.
{{ if false }}<!-- vim: set ft=markdown: -->{{ end }}