├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── lint.yml │ └── web.yml ├── .gitignore ├── .gitmodules ├── .make ├── README.md └── test.mk ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── bot │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── application.go │ ├── botconfig.go │ ├── example.config.json │ ├── main.go │ ├── main_test.go │ ├── minit.go │ ├── minit_csharp.go │ ├── mkswag.sh │ ├── start.sh │ ├── twitchstreamstore.go │ ├── twitchusercontext.go │ └── twitchuserstore.go └── swagger-website │ ├── .gitignore │ └── main.go ├── docs ├── ENV.md ├── PLEBLIST.md ├── docs.go └── swagger │ ├── swagger.json │ └── swagger.yaml ├── go.mod ├── go.sum ├── internal ├── commands │ ├── base │ │ └── base.go │ ├── disconnect │ │ └── disconnect.go │ └── getuserid │ │ └── getuserid.go ├── config │ └── config.go ├── twitchuser │ └── twitchuser.go └── utils │ └── db.go ├── migrations ├── .gitignore ├── 000001_create_channel_table.up.sql ├── 000002_create_command_table.up.sql ├── 000003_change_column_name.up.sql ├── 000004_add_channel_enabled_flag.up.sql ├── 000005_create_user_table.up.sql ├── 000006_create_bot_table.up.sql ├── 000007_add_bot_id_to_channel.up.sql ├── 000008_increase_refreshtoken_length.up.sql ├── 000009_modify_user_refresh_token.up.sql ├── 000010_drop_channel_access_token.up.sql ├── 000011_alter_user_table.up.sql ├── 000012_channel_drop_channel_id.up.sql ├── 000013_alter_user_table2.up.sql ├── 000014_add_permissions_table.up.sql ├── 000015_create_twitch_users_table.up.sql ├── 000016_create_twitch_user_permissions_table.up.sql ├── 000017_change_permissions.up.sql ├── 000018_twitch_user_channel_permissions.up.sql ├── 000019_make_reports_table.up.sql ├── 000020_create_user_table.up.sql ├── 000021_remove_user_permission_primary_key.up.sql ├── 000022_report_add_timestamp.up.sql ├── 000023_rename_tables.up.sql ├── 000024_remove_unused_table.up.sql ├── 000025_create_bot_channel_table.up.sql ├── 000026_create_bot_channel_module_table.up.sql ├── 000027_make_moderation_action_table.up.sql ├── 000028_create_user_sessions_table.up.sql ├── 000029_remove_user_twitch_nonce_column.up.sql ├── 000030_create_report_history_table.up.sql ├── 000031_create_warning_scale_table.up.sql ├── 000032_create_banphrase_group_table.up.sql ├── 000033_create_banphrase_table.up.sql ├── 20190118000448_add_expiry_column_to_bot_table.up.sql ├── 20190118204509_add_unique_index_to_bot_table.up.sql ├── 20190118221100_rename_name_column_in_bot_table.up.sql ├── 20190414182945_make_report_history_id_autoincrement.up.sql └── psql │ ├── 1-initial-tables.sql │ ├── 1567332318-command-tables.sql │ └── newmig.sh ├── pkg ├── account.go ├── action.go ├── apirequest │ ├── apirequest.go │ ├── bttv.go │ ├── common.go │ ├── custom.go │ ├── ffz.go │ ├── twitch.go │ ├── twitchratelimit.go │ └── twitchwrapper.go ├── application.go ├── auth.go ├── auth │ └── twitch.go ├── banphrase.go ├── bot.go ├── botchannel.go ├── botstore.go ├── botstore │ ├── botstore.go │ └── botstoreiterator.go ├── channel.go ├── channels │ ├── store.go │ └── twitchchannel.go ├── commandlist │ └── commandlist.go ├── commands.go ├── commands │ ├── base.go │ ├── commands.go │ ├── commands_test.go │ ├── custom.go │ ├── join.go │ ├── leave.go │ ├── module.go │ ├── pajbot1_command.go │ ├── ping.go │ ├── quit.go │ ├── raffle.go │ ├── rank.go │ ├── subcommands.go │ └── user.go ├── commandsubstitution │ ├── Makefile │ ├── commandsubstitution.go │ ├── commandsubstitution_test.go │ └── helpers_test.go ├── common │ ├── common.go │ ├── common_test.go │ ├── config │ │ ├── config.go │ │ └── config_test.go │ ├── dbuser.go │ ├── emoji.go │ ├── emote.go │ ├── sql.go │ └── twitchcredentials.go ├── const.go ├── emote.go ├── emotes.go ├── emotes │ └── emotes.go ├── eventemitter │ ├── Makefile │ ├── eventemitter.go │ ├── eventemitter_test.go │ └── helpers_test.go ├── events.go ├── eventsub.go ├── filters │ ├── pajbot1_banphrase.go │ └── pajbot1_banphrase_test.go ├── message.go ├── mimo.go ├── mimo │ └── mimo.go ├── module.go ├── modules │ ├── bad_character_filter │ │ └── m.go │ ├── banned_names │ │ └── m.go │ ├── base │ │ └── m.go │ ├── basic_commands.go │ ├── bttv_emote_parser.go │ ├── commands │ │ ├── add.go │ │ ├── add_trigger.go │ │ ├── m.go │ │ ├── remove_trigger.go │ │ └── text.go │ ├── custom_commands.go │ ├── datastructures │ │ └── transparentlist.go │ ├── debug.go │ ├── emote_limit.go │ ├── giveaway │ │ ├── giveaway_config.go │ │ ├── giveaway_draw.go │ │ ├── giveaway_start.go │ │ ├── giveaway_stop.go │ │ └── m.go │ ├── goodbye.go │ ├── latin_filter.go │ ├── link_filter │ │ ├── link_filter_test.go │ │ └── m.go │ ├── message_height_limit │ │ └── m.go │ ├── message_length_limit.go │ ├── nuke │ │ ├── m.go │ │ ├── parse.go │ │ └── parse_test.go │ ├── other_commands.go │ ├── pajbot1_banphrase.go │ ├── pajbot1_commands.go │ ├── param.go │ ├── param_bool.go │ ├── param_float.go │ ├── param_string.go │ ├── punisher │ │ ├── m.go │ │ └── timeout.go │ ├── registry.go │ ├── report.go │ ├── server.go │ ├── spec.go │ ├── system │ │ └── m.go │ ├── test_module.go │ ├── tusecommands │ │ └── m.go │ ├── value.go │ └── welcome │ │ └── m.go ├── pajbot │ └── pajbot.go ├── permissions.go ├── pubsub.go ├── pubsub │ ├── listener.go │ └── pubsub.go ├── report │ └── report.go ├── reportaction.go ├── stream.go ├── streamstore.go ├── twitch │ ├── account.go │ ├── action.go │ ├── bot.go │ ├── botchannel.go │ ├── stream.go │ └── user.go ├── twitchaccount.go ├── twitchactions │ ├── ban.go │ ├── base.go │ ├── delete.go │ ├── message.go │ ├── mute.go │ ├── timeout.go │ ├── twitchactions.go │ ├── unmute.go │ └── whisper.go ├── user.go ├── usercontext.go ├── users │ ├── server.go │ └── twitchuser.go ├── userstore.go ├── web │ ├── controller │ │ ├── admin │ │ │ └── admin.go │ │ ├── api │ │ │ ├── README.md │ │ │ ├── api.go │ │ │ ├── auth │ │ │ │ ├── routes.go │ │ │ │ └── twitch │ │ │ │ │ ├── bot.go │ │ │ │ │ ├── routes.go │ │ │ │ │ ├── shared.go │ │ │ │ │ ├── streamer.go │ │ │ │ │ └── user.go │ │ │ ├── channel │ │ │ │ ├── banphrases │ │ │ │ │ ├── list.go │ │ │ │ │ └── routes.go │ │ │ │ ├── moderation │ │ │ │ │ ├── checkmessage.go │ │ │ │ │ ├── latest.go │ │ │ │ │ ├── routes.go │ │ │ │ │ └── user.go │ │ │ │ └── routes.go │ │ │ ├── report │ │ │ │ ├── history.go │ │ │ │ └── routes.go │ │ │ └── webhook │ │ │ │ ├── eventsub.go │ │ │ │ ├── github.go │ │ │ │ ├── github_test.go │ │ │ │ └── webhook.go │ │ ├── banphrases │ │ │ └── banphrases.go │ │ ├── channel │ │ │ └── channel.go │ │ ├── commands.go │ │ ├── controller.go │ │ ├── dashboard │ │ │ └── dashboard.go │ │ ├── home │ │ │ └── home.go │ │ ├── logout │ │ │ └── logout.go │ │ ├── profile.go │ │ ├── static │ │ │ └── static.go │ │ └── ws │ │ │ ├── hub.go │ │ │ ├── ws.go │ │ │ ├── wsconn.go │ │ │ ├── wsmessage.go │ │ │ └── wspayload.go │ ├── errors.go │ ├── errors_test.go │ ├── hook.go │ ├── hooktypes.go │ ├── payloads.go │ ├── router │ │ └── router.go │ ├── state │ │ ├── sessionstore.go │ │ └── state.go │ ├── views │ │ └── views.go │ └── web.go └── webutils │ └── webutils.go ├── resources └── testfiles │ ├── config1.json │ └── config2_invalidjson.json ├── staticcheck.conf ├── tools.go ├── utils ├── build.sh ├── createdb.sh ├── docker │ ├── build.sh │ └── run.sh ├── findlibcoreclr.sh ├── install.sh ├── mkmig.sh ├── test-all.sh └── upmig.sh └── web ├── .babelrc ├── .gitignore ├── .prettierignore ├── .prettierrc.toml ├── package-lock.json ├── package.json ├── src ├── index.jsx ├── js │ ├── Admin.jsx │ ├── Banphrases.jsx │ ├── Commands.jsx │ ├── Dashboard.jsx │ ├── LogInButton.jsx │ ├── Menu.jsx │ ├── ThemeContext.jsx │ ├── ThemeLoader.jsx │ ├── ThemeProvider.jsx │ ├── ThemeSwitcher.jsx │ ├── WebSocketHandler.js │ ├── auth.js │ └── cookie.js └── scss │ ├── app.scss │ ├── base │ ├── body.scss │ ├── buttons.scss │ └── links.scss │ ├── modules │ └── dashboard.scss │ ├── tools │ └── mixins.scss │ ├── variables │ ├── colors.scss │ └── spacing.scss │ └── vendor │ └── reset.scss ├── static └── themes │ ├── Dark │ └── bootstrap.min.css │ └── Light │ └── bootstrap.min.css ├── views ├── 403.html ├── admin.html ├── banphrases.html ├── base.html ├── commands.html ├── dashboard.html ├── home.html └── profile.html └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,jsx}] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | labels: 13 | - "dependencies" 14 | - package-ecosystem: "npm" 15 | directory: "/web" 16 | schedule: 17 | interval: "daily" 18 | labels: 19 | - "dependencies" 20 | versioning-strategy: increase 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "daily" 25 | labels: 26 | - "dependencies" 27 | - package-ecosystem: "gitsubmodule" 28 | directory: "/" 29 | schedule: 30 | interval: "daily" 31 | labels: 32 | - "dependencies" 33 | - "submodules" 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: Build bot 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | go: ["oldstable", "stable"] 18 | 19 | steps: 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go }} 24 | id: go 25 | 26 | - name: Check out code into the Go module directory 27 | uses: actions/checkout@v4 28 | 29 | - name: Get dependencies 30 | run: go get -v -t -d ./... 31 | 32 | - name: Build bot 33 | working-directory: ./cmd/bot 34 | run: go build -v 35 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | # 3pm every Sunday 16 | - cron: '0 16 * * 0' 17 | 18 | jobs: 19 | analyze: 20 | name: Analyze 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | # Override automatic language detection by changing the below list 27 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 28 | language: ['go', 'javascript'] 29 | # Learn more... 30 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | 36 | # Initializes the CodeQL tools for scanning. 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@v3 39 | with: 40 | languages: ${{ matrix.language }} 41 | # If you wish to specify custom queries, you can do so here or in a config file. 42 | # By default, queries listed here will override any specified in a config file. 43 | # Prefix the list here with "+" to use these queries and those in the config file. 44 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 45 | 46 | - name: Perform CodeQL Analysis 47 | uses: github/codeql-action/analyze@v3 48 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | go: ["oldstable", "stable"] 18 | 19 | steps: 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go }} 24 | id: go 25 | 26 | - name: Check out code into the Go module directory 27 | uses: actions/checkout@v4 28 | 29 | - name: Install linter 30 | run: go install honnef.co/go/tools/cmd/staticcheck 31 | 32 | - name: Lint 33 | run: staticcheck ./... 34 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build web 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: Build web 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node: ['18', '20', '22', '23'] 18 | 19 | steps: 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Go 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node }} 27 | 28 | - working-directory: ./web 29 | run: npm ci 30 | 31 | - working-directory: ./web 32 | run: npm run check_formatting 33 | 34 | - working-directory: ./web 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore binary file 2 | pajbot2 3 | *.exe 4 | *.exe~ 5 | /bot 6 | 7 | # Ignore vendor folder 8 | /vendor/ 9 | 10 | # Ignore config files 11 | config.json 12 | 13 | # Ignore web Files 14 | web/node_modules 15 | web/static/build 16 | 17 | # Various 18 | .DS_Store 19 | .vscode/ 20 | 21 | # Coverage files 22 | coverage.out 23 | coverage.html 24 | 25 | .gdb_history 26 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "3rdParty/MessageHeightTwitch"] 2 | path = 3rdParty/MessageHeightTwitch 3 | url = https://github.com/TETYYS/MessageHeightTwitch.git 4 | -------------------------------------------------------------------------------- /.make/README.md: -------------------------------------------------------------------------------- 1 | helper makefile files 2 | -------------------------------------------------------------------------------- /.make/test.mk: -------------------------------------------------------------------------------- 1 | test: 2 | @go test -v 3 | 4 | cover: 5 | @go test -coverprofile=coverage.out -covermode=count 6 | @go tool cover -html=coverage.out -o coverage.html 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.11.x 4 | - 1.12.x 5 | 6 | env: 7 | - GO111MODULE=on 8 | 9 | script: 10 | - pwd 11 | - go test -v ./pkg/... ./internal/... 12 | 13 | install: true 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unversioned 4 | 5 | ## v2.1.0 - 2024-03-06 6 | 7 | - Fixed a crash when nuking with an "invalid" regex. (#1109) 8 | - Bumped minimum Go version to 1.20. (#1083) 9 | - Allow pb2exec commands `ban`, `unban`, `timeout`, `untimeout` to be used with IDs (e.g. `!pb2exec .timeout id:22484632 5 reason`). (#1056) 10 | - Remove Twitter support. (#1033) 11 | Warning messages will be posted in the console if twitter tokens are configured. 12 | - Add GitHub push event webhook support. (#1042, #1043, #1064) 13 | Webhook format: `https://your-bot-domain.com/api/webhook/github/{channel_id}` 14 | Example config has been updated to show Auth -> Github -> Webhook -> Secret 15 | 16 | ## v2.0.0 - 2023-08-08 17 | 18 | - Bumped minimum Go version to 1.19. (#898) 19 | - The nuke module now has tests for parsing parameters. (#530) 20 | - The `Auth->Twitch->Webhook->Secret` config value is now REQUIRED. It's your own private secret you need to generate yourself, and it must be at least 10 characters and at most 100 characters long. 21 | - The nuke module will now recognize users with global permissions. (#268) 22 | - Message height limit no longer applies to Twitch Moderators (#89, #228) 23 | - The version of MessageHeightTwitch was updated, which requires version 3.0 of .NET Core. 24 | - Changed DB backend from MySQL to PostgreSQL. 25 | Setting up the bot from scratch? You don't need to do anything! 26 | Upgrading your already set up bot to this version? Follow the instructions in [this document](/resources/mysql-to-postgresql-transition/README.md) 27 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | - [nuuls](https://github.com/nuuls) 3 | - [pajlada](https://github.com/pajlada) 4 | - [gempir](https://github.com/gempir) 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:6.0.301-1-alpine3.16 AS build 2 | RUN apk add --no-cache go nodejs npm build-base 3 | ADD . /src 4 | RUN cd /src && ./utils/install.sh 5 | RUN cd /src && ./utils/build.sh -v -tags csharp 6 | 7 | FROM mcr.microsoft.com/dotnet/runtime:6.0.6-alpine3.16 8 | WORKDIR /app/cmd/bot 9 | RUN apk add --no-cache icu 10 | ENV LIBCOREFOLDER /usr/share/dotnet/shared/Microsoft.NETCore.App/6.0.6 11 | COPY --from=build /src/web/static /app/web/static 12 | COPY --from=build /src/web/views /app/web/views 13 | COPY --from=build /src/cmd/bot/bot /app/cmd/bot/bot 14 | COPY --from=build /src/migrations /app/migrations/ 15 | COPY --from=build /src/cmd/bot/*.dll /app/cmd/bot/ 16 | COPY --from=build /src/cmd/bot/charmap.bin.gz /app/cmd/bot/ 17 | RUN chmod 777 /app/cmd/bot/charmap.bin.gz 18 | CMD ["./bot"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Rasmus Karlsson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | swag: 2 | @swag init --generalInfo cmd/bot/main.go 3 | 4 | lint: 5 | @staticcheck ./... 6 | 7 | build: 8 | @go build ./cmd/bot/ 9 | 10 | build-csharp: 11 | @go build -tags csharp ./cmd/bot/ 12 | 13 | build-all: build build-csharp 14 | 15 | check: lint build-all 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pajbot 2 [![CircleCI](https://circleci.com/gh/pajbot/pajbot2.svg?style=svg)](https://circleci.com/gh/pajbot/pajbot2) 2 | 3 | A rewrite/restructuring of [pajbot 1](https://github.com/pajbot/pajbot) in Go. As with pajbot1, this is a way for me to familiarize myself with the language. 4 | 5 | ## Web guide 6 | 7 | - `cd web && npm install` 8 | - `npm run watch` to let webpack running and compile in background 9 | 10 | ## FAQ 11 | 12 | ### How do I add a bot? 13 | 14 | After making yourself an admin in the config file, open up the web interface. Log in, go to `/admin`, press the "Log in as bot", then after authenticating whatever user you want as a bot, restart the bot! 15 | -------------------------------------------------------------------------------- /cmd/bot/.gitignore: -------------------------------------------------------------------------------- 1 | /bot 2 | /migrations 3 | /*.dll 4 | /charmap.bin.gz 5 | /web 6 | -------------------------------------------------------------------------------- /cmd/bot/Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | @go build 3 | 4 | run: default 5 | ./bot 6 | -------------------------------------------------------------------------------- /cmd/bot/README.md: -------------------------------------------------------------------------------- 1 | # pajbot2 2 | 3 | This is the cmd to actually run the bot 4 | 5 | Just run `go build` in this folder to make the executable, then run it (./bot) 6 | -------------------------------------------------------------------------------- /cmd/bot/example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Admin": { 3 | "TwitchUserID": "12345678" 4 | }, 5 | "Web": { 6 | "Host": "127.0.0.1:45000", 7 | "Domain": "paj.pajbot.com", 8 | "Secure": true 9 | }, 10 | "PostgreSQL": { 11 | "__dsn_comment": "Refer to https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters for valid options", 12 | "DSN": "host=/var/run/postgresql dbname=pajbot2 sslmode=disable" 13 | }, 14 | "Auth": { 15 | "Twitch": { 16 | "Webhook": { 17 | "Secret": "topsecretstringbetween10and100characters" 18 | }, 19 | "Bot": { 20 | "ClientID": "123", 21 | "ClientSecret": "123" 22 | }, 23 | "User": { 24 | "ClientID": "123", 25 | "ClientSecret": "123" 26 | }, 27 | "Streamer": { 28 | "ClientID": "123", 29 | "ClientSecret": "123" 30 | } 31 | }, 32 | "Github": { 33 | "Webhook": { 34 | "Secret": "secret" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/bot/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /cmd/bot/minit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Initialize any modules that are in their own packages 4 | 5 | import ( 6 | _ "github.com/pajbot/pajbot2/pkg/modules/commands" // xd 7 | 8 | _ "github.com/pajbot/pajbot2/pkg/modules/tusecommands" // xd 9 | 10 | _ "github.com/pajbot/pajbot2/pkg/modules/punisher" // xd 11 | 12 | _ "github.com/pajbot/pajbot2/pkg/modules/system" 13 | 14 | _ "github.com/pajbot/pajbot2/pkg/modules/giveaway" 15 | 16 | _ "github.com/pajbot/pajbot2/pkg/modules/link_filter" 17 | 18 | _ "github.com/pajbot/pajbot2/pkg/modules/nuke" 19 | 20 | _ "github.com/pajbot/pajbot2/pkg/modules/welcome" 21 | 22 | _ "github.com/pajbot/pajbot2/pkg/modules/banned_names" 23 | 24 | _ "github.com/pajbot/pajbot2/pkg/modules/bad_character_filter" 25 | ) 26 | -------------------------------------------------------------------------------- /cmd/bot/minit_csharp.go: -------------------------------------------------------------------------------- 1 | // +build csharp 2 | 3 | package main 4 | 5 | // Initialize any modules that are in their own packages and require the csharp tag 6 | 7 | import ( 8 | _ "github.com/pajbot/pajbot2/pkg/modules/message_height_limit" 9 | ) 10 | -------------------------------------------------------------------------------- /cmd/bot/mkswag.sh: -------------------------------------------------------------------------------- 1 | cd ../../; make; cd cmd/swagger-website; go build && ./swagger-website 2 | -------------------------------------------------------------------------------- /cmd/bot/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . ../../utils/findlibcoreclr.sh 4 | 5 | LIBCOREFOLDER="$(get_libcoreclr_path)" ./bot 6 | -------------------------------------------------------------------------------- /cmd/bot/twitchusercontext.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | ) 9 | 10 | var _ pkg.UserContext = &UserContext{} 11 | 12 | type UserContext struct { 13 | mutex *sync.Mutex 14 | 15 | // key = channel ID 16 | context map[string]map[string][]string 17 | } 18 | 19 | func NewUserContext() *UserContext { 20 | c := &UserContext{ 21 | mutex: &sync.Mutex{}, 22 | context: make(map[string]map[string][]string), 23 | } 24 | 25 | return c 26 | } 27 | 28 | func (c *UserContext) GetContext(channelID, userID string) []string { 29 | c.mutex.Lock() 30 | defer c.mutex.Unlock() 31 | 32 | if users, ok := c.context[channelID]; ok { 33 | if userContext, ok := users[userID]; ok { 34 | return userContext 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func (c *UserContext) AddContext(channelID, userID, message string) { 42 | if channelID == "" { 43 | fmt.Println("Channel ID is empty") 44 | return 45 | } 46 | if userID == "" { 47 | fmt.Println("User ID is empty") 48 | return 49 | } 50 | 51 | c.mutex.Lock() 52 | defer c.mutex.Unlock() 53 | 54 | _, ok := c.context[channelID] 55 | if !ok { 56 | c.context[channelID] = make(map[string][]string) 57 | } 58 | 59 | c.context[channelID][userID] = append(c.context[channelID][userID], message) 60 | newLen := len(c.context[channelID][userID]) - 5 61 | if newLen > 5 { 62 | c.context[channelID][userID] = c.context[channelID][userID][newLen:] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cmd/swagger-website/.gitignore: -------------------------------------------------------------------------------- 1 | /swagger-website 2 | -------------------------------------------------------------------------------- /cmd/swagger-website/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | echoSwagger "github.com/swaggo/echo-swagger" 6 | 7 | _ "github.com/pajbot/pajbot2/docs" // docs is generated by Swag CLI, you have to import it. 8 | ) 9 | 10 | func main() { 11 | e := echo.New() 12 | 13 | e.GET("/*", echoSwagger.WrapHandler) 14 | 15 | e.Logger.Fatal(e.Start(":1323")) 16 | } 17 | -------------------------------------------------------------------------------- /docs/ENV.md: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | 3 | ## PAJBOT2_WEB_PATH 4 | Points to the path where the web static and views folders are located. 5 | Default value: `../../web` 6 | -------------------------------------------------------------------------------- /docs/swagger/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: '{{.BasePath}}' 2 | definitions: 3 | moderation.CheckMessageSuccessResponse: 4 | properties: 5 | banned: 6 | type: boolean 7 | filter_data: 8 | items: 9 | $ref: '#/definitions/moderation.filterData' 10 | type: array 11 | input_message: 12 | type: string 13 | type: object 14 | moderation.filterData: 15 | properties: 16 | mute_type: 17 | type: integer 18 | reason: 19 | type: string 20 | type: object 21 | utils.WebAPIError: 22 | properties: 23 | code: 24 | type: integer 25 | error: 26 | type: string 27 | type: object 28 | host: localhost:2355 29 | info: 30 | contact: 31 | email: rasmus.karlsson@pajlada.com 32 | name: pajlada 33 | url: https://pajlada.se 34 | description: API for pajbot2 35 | license: 36 | name: MIT 37 | url: https://github.com/pajbot/pajbot2/blob/master/LICENSE 38 | title: pajbot2 API 39 | version: "1.0" 40 | paths: 41 | /api/channel/{channelID}/moderation/check_message: 42 | get: 43 | parameters: 44 | - description: ID of channel to run the test in 45 | in: path 46 | name: channelID 47 | required: true 48 | type: string 49 | - description: message to test against the bots filters 50 | in: query 51 | name: message 52 | required: true 53 | type: string 54 | produces: 55 | - application/json 56 | responses: 57 | "200": 58 | description: OK 59 | schema: 60 | $ref: '#/definitions/moderation.CheckMessageSuccessResponse' 61 | type: object 62 | "404": 63 | description: Not Found 64 | schema: 65 | $ref: '#/definitions/utils.WebAPIError' 66 | type: object 67 | summary: Check a message in a bots filter 68 | swagger: "2.0" 69 | -------------------------------------------------------------------------------- /internal/commands/disconnect/disconnect.go: -------------------------------------------------------------------------------- 1 | package disconnect 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | ) 6 | 7 | type Command struct { 8 | Bot pkg.BotChannel 9 | } 10 | 11 | func (c *Command) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 12 | if !event.User.HasPermission(event.Channel, pkg.PermissionAdmin) { 13 | return nil 14 | } 15 | 16 | c.Bot.Bot().Disconnect() 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/commands/getuserid/getuserid.go: -------------------------------------------------------------------------------- 1 | package getuserid 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | "github.com/pajbot/utils" 9 | ) 10 | 11 | type Command struct { 12 | } 13 | 14 | func (c Command) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 15 | usernames := utils.FilterUsernames(parts[1:]) 16 | 17 | if len(usernames) == 0 { 18 | return twitchactions.Mention(event.User, "usage: !userid USERNAME (i.e. !userid pajlada)") 19 | } 20 | 21 | userIDs := event.UserStore.GetIDs(usernames) 22 | var results []string 23 | for username, userID := range userIDs { 24 | results = append(results, username+"="+userID) 25 | } 26 | 27 | if len(results) == 0 { 28 | return twitchactions.Mention(event.User, "no valid usernames were given") 29 | } 30 | 31 | return twitchactions.Mention(event.User, strings.Join(results, ", ")) 32 | } 33 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "os" 4 | 5 | var ( 6 | WebStaticPath = stringEnv("PAJBOT2_WEB_PATH", "../../web/") 7 | ) 8 | 9 | func stringEnv(key, defaultValue string) string { 10 | value, exists := os.LookupEnv(key) 11 | if !exists { 12 | return defaultValue 13 | } 14 | return value 15 | } 16 | -------------------------------------------------------------------------------- /internal/twitchuser/twitchuser.go: -------------------------------------------------------------------------------- 1 | package twitchuser 2 | 3 | type TwitchUser struct { 4 | id string 5 | name string 6 | } 7 | 8 | func New(id, name string) *TwitchUser { 9 | return &TwitchUser{ 10 | id: id, 11 | name: name, 12 | } 13 | } 14 | 15 | func (u *TwitchUser) ID() string { 16 | return u.id 17 | } 18 | 19 | func (u *TwitchUser) Name() string { 20 | return u.name 21 | } 22 | -------------------------------------------------------------------------------- /internal/utils/db.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "database/sql" 4 | 5 | // WithTransaction taken from https://stackoverflow.com/questions/16184238/database-sql-tx-detecting-commit-or-rollback 6 | func WithTransaction(db *sql.DB, txFunc func(*sql.Tx) error) error { 7 | tx, err := db.Begin() 8 | if err != nil { 9 | return err 10 | } 11 | defer func() { 12 | if p := recover(); p != nil { 13 | tx.Rollback() 14 | panic(p) // re-throw panic after Rollback 15 | } else if err != nil { 16 | tx.Rollback() // err is non-nil; don't change it 17 | } else { 18 | err = tx.Commit() // err is nil; if Commit returns error update err 19 | } 20 | }() 21 | err = txFunc(tx) 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /migrations/.gitignore: -------------------------------------------------------------------------------- 1 | *.down.sql 2 | -------------------------------------------------------------------------------- /migrations/000001_create_channel_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `pb_channel` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `channel` VARCHAR(64) NOT NULL COMMENT 'i.e. forsenlol', 4 | `nickname` VARCHAR(64) NULL DEFAULT NULL COMMENT 'i.e. Forsen', 5 | `twitch_channel_id` BIGINT(20) NULL DEFAULT NULL COMMENT 'i.e. 12345678', 6 | `twitch_access_token` VARCHAR(64) NULL DEFAULT NULL, 7 | `twitch_refresh_token` VARCHAR(64) NULL DEFAULT NULL, 8 | PRIMARY KEY (`id`), 9 | UNIQUE INDEX `channel` (`channel`) 10 | ) 11 | COLLATE='utf8mb4_general_ci' 12 | ENGINE=InnoDB 13 | ; 14 | -------------------------------------------------------------------------------- /migrations/000002_create_command_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `pb_command` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `channel_id` INT(10) UNSIGNED NOT NULL, 4 | `triggers` VARCHAR(512) NOT NULL COMMENT 'Each trigger is divided by a pipe character "|". No !\'s allowed in command names. Example: testman|testman1|anotheralias', 5 | `response` VARCHAR(1024) NOT NULL, 6 | `response_type` ENUM('say','whisper','reply') NOT NULL DEFAULT 'say', 7 | `level` INT(11) NOT NULL DEFAULT '100' COMMENT 'User level required to use the command', 8 | `cooldown_all` INT(11) NOT NULL DEFAULT '4', 9 | `cooldown_user` INT(11) NOT NULL DEFAULT '10', 10 | `enabled` ENUM('yes','no','online_only','offline_only') NOT NULL DEFAULT 'yes', 11 | `cost_points` INT(10) UNSIGNED NOT NULL DEFAULT '0', 12 | `filters` SET('banphrases','linkchecker') NOT NULL DEFAULT '', 13 | PRIMARY KEY (`id`), 14 | INDEX `channel_id` (`channel_id`), 15 | CONSTRAINT `FK_pb_command_pb_channel` FOREIGN KEY (`channel_id`) REFERENCES `pb_channel` (`id`) 16 | ) 17 | COLLATE='utf8mb4_general_ci' 18 | ENGINE=InnoDB 19 | ; 20 | -------------------------------------------------------------------------------- /migrations/000003_change_column_name.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_channel` CHANGE `channel` `name` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'i.e. forsenlol'; 2 | -------------------------------------------------------------------------------- /migrations/000004_add_channel_enabled_flag.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_channel` ADD `enabled` BOOLEAN NOT NULL DEFAULT TRUE AFTER `nickname`; 2 | -------------------------------------------------------------------------------- /migrations/000005_create_user_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `pb_user` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `name` VARCHAR(64) NOT NULL COMMENT 'i.e. forsenlol', 4 | `twitch_access_token` VARCHAR(64) NULL DEFAULT NULL COMMENT 'User level access-token', 5 | `twitch_refresh_token` VARCHAR(64) NULL DEFAULT NULL COMMENT 'User level refresh-token', 6 | PRIMARY KEY (`id`) 7 | ) 8 | COMMENT='Users that log in via the web interface' 9 | COLLATE='utf8mb4_general_ci' 10 | ENGINE=InnoDB 11 | ; 12 | -------------------------------------------------------------------------------- /migrations/000006_create_bot_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `pb_bot` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `name` VARCHAR(64) NOT NULL COMMENT 'i.e. snusbot', 4 | `twitch_access_token` VARCHAR(64) NULL DEFAULT NULL COMMENT 'Bot level access-token', 5 | `twitch_refresh_token` VARCHAR(64) NULL DEFAULT NULL COMMENT 'Bot level refresh-token', 6 | PRIMARY KEY (`id`) 7 | ) 8 | COMMENT='Store available bot accouns, requires an access token with chat_login scope' 9 | COLLATE='utf8mb4_general_ci' 10 | ENGINE=InnoDB 11 | ; 12 | -------------------------------------------------------------------------------- /migrations/000007_add_bot_id_to_channel.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_channel` ADD `bot_id` INT(11) UNSIGNED NOT NULL AFTER `nickname`; 2 | -------------------------------------------------------------------------------- /migrations/000008_increase_refreshtoken_length.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_bot` MODIFY twitch_refresh_token VARCHAR(256); 2 | -------------------------------------------------------------------------------- /migrations/000009_modify_user_refresh_token.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_user` MODIFY twitch_refresh_token VARCHAR(256); 2 | -------------------------------------------------------------------------------- /migrations/000010_drop_channel_access_token.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_channel` DROP COLUMN twitch_access_token, DROP COLUMN twitch_refresh_token; 2 | -------------------------------------------------------------------------------- /migrations/000011_alter_user_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_user` 2 | ALTER `twitch_access_token` DROP DEFAULT, 3 | ALTER `twitch_refresh_token` DROP DEFAULT; 4 | -------------------------------------------------------------------------------- /migrations/000012_channel_drop_channel_id.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_channel` 2 | DROP COLUMN `twitch_channel_id`; 3 | -------------------------------------------------------------------------------- /migrations/000013_alter_user_table2.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pb_user` 2 | ADD COLUMN `type` ENUM('bot','user') NOT NULL DEFAULT 'user' AFTER `name`, 3 | CHANGE COLUMN `twitch_access_token` `twitch_access_token` VARCHAR(64) NOT NULL COMMENT 'User level access-token' AFTER `type`, 4 | CHANGE COLUMN `twitch_refresh_token` `twitch_refresh_token` VARCHAR(256) NOT NULL AFTER `twitch_access_token`, 5 | ADD COLUMN `twitch_room_id` BIGINT NOT NULL AFTER `twitch_refresh_token`, 6 | ADD INDEX `INDEX_BY_USER_TYPE` (`type`); 7 | -------------------------------------------------------------------------------- /migrations/000014_add_permissions_table.up.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE pb_user; 2 | -------------------------------------------------------------------------------- /migrations/000015_create_twitch_users_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `twitch_users` ( 2 | `twitch_user_id` VARCHAR(64) NOT NULL, 3 | `name` VARCHAR(64) NOT NULL COMMENT 'i.e. testaccount_420', 4 | `display_name` VARCHAR(64) NULL COMMENT 'i.e. TestAccount_420', 5 | PRIMARY KEY (`twitch_user_id`), 6 | INDEX `name` (`name`) 7 | ) 8 | COLLATE='utf8mb4_general_ci' 9 | ENGINE=InnoDB 10 | ; 11 | -------------------------------------------------------------------------------- /migrations/000016_create_twitch_user_permissions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `twitch_user_permissions` ( 2 | `twitch_user_id` VARCHAR(64) NOT NULL, 3 | `permission` VARCHAR(64) NOT NULL, 4 | 5 | PRIMARY KEY (`twitch_user_id`), 6 | 7 | UNIQUE INDEX `user_permission` (`twitch_user_id`, `permission`) 8 | ) 9 | COLLATE='utf8mb4_general_ci' 10 | ENGINE=InnoDB 11 | ; 12 | -------------------------------------------------------------------------------- /migrations/000017_change_permissions.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `twitch_user_permissions` CHANGE COLUMN permission permissions BIT(64) NOT NULL DEFAULT 0b0; 2 | -------------------------------------------------------------------------------- /migrations/000018_twitch_user_channel_permissions.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `twitch_user_channel_permissions` ( 2 | `twitch_user_id` VARCHAR(64) NOT NULL, 3 | `channel_id` VARCHAR(64) NOT NULL, 4 | `permissions` BIT(64) NOT NULL DEFAULT 0b0, 5 | 6 | PRIMARY KEY (`twitch_user_id`), 7 | 8 | UNIQUE INDEX `user_channel_permission` (`twitch_user_id`, `channel_id`) 9 | ) 10 | COLLATE='utf8mb4_general_ci' 11 | ENGINE=InnoDB 12 | ; 13 | -------------------------------------------------------------------------------- /migrations/000019_make_reports_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `Report` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `channel_id` VARCHAR(64) NOT NULL, 4 | `channel_name` VARCHAR(64) NOT NULL, 5 | `channel_type` VARCHAR(64) NOT NULL, 6 | `reporter_id` VARCHAR(64) NOT NULL, 7 | `reporter_name` VARCHAR(64) NOT NULL, 8 | `target_id` VARCHAR(64) NOT NULL, 9 | `target_name` VARCHAR(64) NOT NULL, 10 | `reason` TEXT, 11 | `logs` TEXT, 12 | 13 | PRIMARY KEY (`id`) 14 | ) 15 | COLLATE='utf8mb4_general_ci' 16 | ENGINE=InnoDB 17 | ; 18 | -------------------------------------------------------------------------------- /migrations/000020_create_user_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `User` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `twitch_username` VARCHAR(64) NOT NULL, 4 | `twitch_userid` VARCHAR(64) NOT NULL, 5 | `twitch_nonce` VARCHAR(64) NOT NULL, 6 | 7 | PRIMARY KEY (`id`), 8 | UNIQUE INDEX `ui_twitch_userid` (`twitch_userid`) 9 | ) 10 | COLLATE='utf8mb4_general_ci' 11 | ENGINE=InnoDB 12 | ; 13 | -------------------------------------------------------------------------------- /migrations/000021_remove_user_permission_primary_key.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE twitch_user_channel_permissions DROP PRIMARY KEY; 2 | -------------------------------------------------------------------------------- /migrations/000022_report_add_timestamp.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE Report ADD COLUMN time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 2 | -------------------------------------------------------------------------------- /migrations/000023_rename_tables.up.sql: -------------------------------------------------------------------------------- 1 | RENAME TABLE pb_bot TO Bot, twitch_user_channel_permissions TO TwitchUserChannelPermission, twitch_user_permissions TO TwitchUserGlobalPermission; 2 | -------------------------------------------------------------------------------- /migrations/000024_remove_unused_table.up.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE twitch_users, pb_command; 2 | -------------------------------------------------------------------------------- /migrations/000025_create_bot_channel_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `BotChannel` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `bot_id` INT(11) UNSIGNED NOT NULL, 4 | `twitch_channel_id` VARCHAR(64) NOT NULL COMMENT 'i.e. 11148817', 5 | PRIMARY KEY (`id`), 6 | FOREIGN KEY (bot_id) 7 | REFERENCES Bot(id) 8 | ON DELETE CASCADE, 9 | UNIQUE INDEX `bot_channel` (bot_id, twitch_channel_id) 10 | ) 11 | COLLATE='utf8mb4_general_ci' 12 | ENGINE=InnoDB 13 | ; 14 | -------------------------------------------------------------------------------- /migrations/000026_create_bot_channel_module_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `BotChannelModule` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `bot_channel_id` INT(11) UNSIGNED NOT NULL, 4 | `module_id` VARCHAR(128) NOT NULL COMMENT 'i.e. nuke', 5 | `enabled` BOOLEAN NULL COMMENT 'if null, it uses the modules default enabled value', 6 | `settings` BLOB NULL COMMENT 'json blob with settings', 7 | 8 | PRIMARY KEY(`id`), 9 | 10 | FOREIGN KEY (bot_channel_id) 11 | REFERENCES BotChannel(id) 12 | ON DELETE CASCADE, 13 | 14 | UNIQUE INDEX `bot_channel_module` (bot_channel_id, module_id) 15 | ) 16 | COLLATE='utf8mb4_general_ci' 17 | ENGINE=InnoDB 18 | ; 19 | -------------------------------------------------------------------------------- /migrations/000027_make_moderation_action_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `ModerationAction` ( 2 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 3 | `ChannelID` varchar(64) NOT NULL COMMENT 'Twitch Channel owners user ID', 4 | `UserID` varchar(64) NOT NULL COMMENT 'Source user ID', 5 | `TargetID` varchar(64) NOT NULL COMMENT 'Target user ID (the user who has banned/unbanned/timed out)', 6 | `Action` smallint(2) NOT NULL COMMENT 'Action in int format, enums declared outside of SQL', 7 | `Duration` int(11) DEFAULT NULL COMMENT 'Duration of action (only used for timeouts atm)', 8 | `Reason` text COMMENT 'Reason for ban. Auto filled in from twich chat, but can be modified in web gui', 9 | `Timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp of when the timeout occured', 10 | `Context` text, 11 | PRIMARY KEY (`id`), 12 | KEY `ChannelUserTarget_INDEX` (`ChannelID`,`UserID`,`TargetID`), 13 | KEY `ChannelTargetAction_INDEX` (`ChannelID`,`TargetID`,`Action`) 14 | ) 15 | COLLATE='utf8mb4_general_ci' 16 | ENGINE=InnoDB 17 | ; 18 | -------------------------------------------------------------------------------- /migrations/000028_create_user_sessions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `UserSession` ( 2 | `id` varchar(64) NOT NULL, 3 | `user_id` INT(11) UNSIGNED NOT NULL, 4 | `expiry_date` TIMESTAMP NOT NULL DEFAULT 0, 5 | PRIMARY KEY (`id`), 6 | FOREIGN KEY (user_id) 7 | REFERENCES User(id) 8 | ON DELETE CASCADE 9 | ) 10 | COLLATE='utf8mb4_general_ci' 11 | ENGINE=InnoDB 12 | ; 13 | -------------------------------------------------------------------------------- /migrations/000029_remove_user_twitch_nonce_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `User` DROP COLUMN twitch_nonce; 2 | -------------------------------------------------------------------------------- /migrations/000030_create_report_history_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ReportHistory` ( 2 | `id` INT(11) UNSIGNED NOT NULL COMMENT 'report id, same as in Report', 3 | `channel_id` VARCHAR(64) NOT NULL COMMENT 'twitch ID of channel user was reported in', 4 | `channel_name` VARCHAR(64) NOT NULL COMMENT 'twitch username of channel the user was reported in', 5 | `channel_type` VARCHAR(64) NOT NULL, 6 | `reporter_id` VARCHAR(64) NOT NULL COMMENT 'twitch user ID of reporter', 7 | `reporter_name` VARCHAR(64) NOT NULL COMMENT 'twitch user name of reporter', 8 | `target_id` VARCHAR(64) NOT NULL COMMENT 'twitch user ID of person being reported', 9 | `target_name` VARCHAR(64) NOT NULL COMMENT 'twitch user name of person being reported', 10 | `reason` TEXT, 11 | `logs` TEXT, 12 | `time` timestamp NOT NULL DEFAULT 0 COMMENT 'time report was added', 13 | 14 | `handler_id` VARCHAR(64) NOT NULL COMMENT 'twitch user ID of person who handled the report', 15 | `handler_name` VARCHAR(64) NOT NULL COMMENT 'twitch user name of person who handled the report', 16 | 17 | `action` TINYINT UNSIGNED NOT NULL COMMENT 'number constant for what action was taken for the report. 1 = ban, 2 = timeout, 3 = dismiss', 18 | `action_duration` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'number of seconds for the action. only relevant for timeouts', 19 | 20 | `time_handled` timestamp NOT NULL DEFAULT 0, 21 | 22 | PRIMARY KEY (`id`) 23 | ) 24 | COLLATE='utf8mb4_general_ci' 25 | ENGINE=InnoDB 26 | ; 27 | -------------------------------------------------------------------------------- /migrations/000031_create_warning_scale_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `WarningScale` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | 4 | PRIMARY KEY (`id`) 5 | ) 6 | COMMENT='Store data about warning scales' 7 | COLLATE='utf8mb4_general_ci' 8 | ENGINE=InnoDB 9 | ; 10 | -------------------------------------------------------------------------------- /migrations/000032_create_banphrase_group_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `BanphraseGroup` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `enabled` TINYINT(1) NOT NULL DEFAULT 1, 4 | `name` VARCHAR(64) NOT NULL, 5 | `description` TEXT NULL COMMENT 'Optional description of the banphrase group, i.e. racism or banned emote', 6 | `length` INT(11) UNSIGNED NOT NULL DEFAULT 60 COMMENT '0 = permaban, >0 = timeout for X seconds', 7 | `warning_id` INT(11) UNSIGNED NULL DEFAULT NULL COMMENT 'ID to a warning "scale"', 8 | `case_sensitive` TINYINT(1) NOT NULL DEFAULT 0, 9 | `type` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = contains, more IDs can be found in the go code lol xd', 10 | `sub_immunity` TINYINT(1) NOT NULL DEFAULT 0, 11 | `remove_accents` TINYINT(1) NOT NULL DEFAULT 0, 12 | 13 | PRIMARY KEY (`id`), 14 | FOREIGN KEY (warning_id) 15 | REFERENCES WarningScale(id) 16 | ON DELETE SET NULL, 17 | UNIQUE INDEX `group_name` (name) 18 | ) 19 | COMMENT='Store banphrase groups. this will make it easier to manage multiple banphrases at the same time' 20 | COLLATE='utf8mb4_general_ci' 21 | ENGINE=InnoDB 22 | ; 23 | -------------------------------------------------------------------------------- /migrations/000033_create_banphrase_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `Banphrase` ( 2 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `group_id` INT(11) UNSIGNED NULL DEFAULT NULL, 4 | `enabled` TINYINT(1) NULL DEFAULT 1 COMMENT 'NULL = Inherit from group', 5 | `description` TEXT NULL COMMENT 'Optional description of the banphrase, i.e. racism or banned emote', 6 | `phrase` TEXT NOT NULL COMMENT 'The banned phrase itself. This can be a regular expression, it all depends on the "operator" of the banphrase', 7 | `length` INT(11) UNSIGNED NULL DEFAULT 60 COMMENT 'NULL = Inherit from group, 0 = permaban, >0 = timeout for X seconds', 8 | `warning_id` INT(11) UNSIGNED NULL COMMENT 'NULL = Inherit from group, anything else is an ID to a warning "scale"', 9 | `case_sensitive` TINYINT(1) NULL COMMENT 'NULL = Inherit from group', 10 | `type` INT(11) NULL DEFAULT 0 COMMENT 'NULL = Inherit from group, 0 = contains, more IDs can be found in the go code lol xd', 11 | `sub_immunity` TINYINT(1) NULL DEFAULT 0 COMMENT 'NULL = Inherit from group', 12 | `remove_accents` TINYINT(1) NULL DEFAULT 0 COMMENT 'NULL = Inherit from group', 13 | 14 | PRIMARY KEY (`id`), 15 | FOREIGN KEY (warning_id) 16 | REFERENCES WarningScale(id) 17 | ON DELETE SET NULL, 18 | FOREIGN KEY (group_id) 19 | REFERENCES BanphraseGroup(id) 20 | ON DELETE SET NULL 21 | ) 22 | COMMENT='Store banned phrases' 23 | COLLATE='utf8mb4_general_ci' 24 | ENGINE=InnoDB 25 | ; 26 | -------------------------------------------------------------------------------- /migrations/20190118000448_add_expiry_column_to_bot_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `Bot` 2 | ADD COLUMN `twitch_userid` VARCHAR(64) NOT NULL AFTER `id`, 3 | ADD COLUMN `twitch_access_token_expiry` DATETIME NOT NULL; 4 | -------------------------------------------------------------------------------- /migrations/20190118204509_add_unique_index_to_bot_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX `itwitchuid` ON Bot(twitch_userid); 2 | -------------------------------------------------------------------------------- /migrations/20190118221100_rename_name_column_in_bot_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `Bot` 2 | CHANGE name twitch_username varchar(64) NOT NULL; 3 | -------------------------------------------------------------------------------- /migrations/20190414182945_make_report_history_id_autoincrement.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ReportHistory MODIFY COLUMN id INT UNSIGNED NOT NULL AUTO_INCREMENT; 2 | -------------------------------------------------------------------------------- /migrations/psql/1567332318-command-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE command_action_type AS ENUM('text_response', 'module_action'); 2 | 3 | CREATE TYPE module_action_id AS ENUM('modules.nuke.nuke', 'modules.message_height_limit.heighttest'); 4 | COMMENT ON TYPE module_action_id IS 'Available raw actions that can be bound as the action of module commands'; 5 | 6 | CREATE TABLE command ( 7 | id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, 8 | bot_channel_id INT NOT NULL REFERENCES bot_channel(id), 9 | cost INT NOT NULL DEFAULT 0, 10 | cd_user INTERVAL NOT NULL DEFAULT interval '15 seconds', 11 | cd_all INTERVAL NOT NULL DEFAULT interval '5 seconds', 12 | enabled BOOLEAN NOT NULL DEFAULT TRUE, 13 | action_type command_action_type NOT NULL, -- this is the descriminator for the polymorphism (type = ENUM('text_response', 'module_action')) 14 | response TEXT, -- for text_response 15 | action_id module_action_id -- for module_action 16 | -- could add CONSTRAINT to check response not null when action_type = 'text_response' for example 17 | ); 18 | COMMENT ON TABLE command IS 'Available command on a given bot, in a given channel'; 19 | 20 | CREATE TABLE command_trigger ( 21 | command_id INT REFERENCES command(id), 22 | trigger TEXT NOT NULL UNIQUE, -- notice the extra unique constraint 23 | PRIMARY KEY (command_id, trigger) 24 | ); 25 | COMMENT ON TABLE command_trigger IS 'Aliases/Triggers for a given command'; 26 | 27 | CREATE TYPE permission AS ENUM( 28 | 'twitch_moderator', 29 | 'create_command', 30 | 'edit_command', 31 | 'delete_command', 32 | 'add_banphrase' -- etc. 33 | ); 34 | COMMENT ON TYPE permission IS 'Available permissions that can be granted, revoked and required'; 35 | 36 | CREATE TABLE command_execute_permission_requirement ( 37 | command_id INT REFERENCES command(id), 38 | permission_id permission NOT NULL, 39 | PRIMARY KEY (command_id, permission_id) 40 | ); 41 | COMMENT ON TABLE command_execute_permission_requirement IS 'Permissions required to execute a command'; 42 | 43 | -------------------------------------------------------------------------------- /migrations/psql/newmig.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | script_path="$(dirname "$0")" 6 | description="$1" 7 | 8 | if [ -z "$description" ]; then 9 | >&2 echo "usage: $0 description-of-migration" 10 | exit 1 11 | fi 12 | 13 | touch "$script_path/$(date --utc '+%s')-$description.sql" 14 | -------------------------------------------------------------------------------- /pkg/account.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type Account interface { 4 | // full id of user (i.e. 11148817) 5 | ID() string 6 | 7 | // full lowercase name (i.e. pajlada) 8 | Name() string 9 | } 10 | -------------------------------------------------------------------------------- /pkg/action.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type MuteType uint 8 | 9 | const ( 10 | MuteTypeTemporary MuteType = iota 11 | MuteTypePermanent 12 | ) 13 | 14 | // MuteAction defines an action that will mute/timeout/ban or otherwise stop a user from participating in chat, either temporarily or permanently 15 | type MuteAction interface { 16 | User() User 17 | SetReason(reason string) 18 | Reason() string 19 | 20 | Type() MuteType 21 | 22 | Duration() time.Duration 23 | } 24 | 25 | // UnmuteAction defines an action that will unmute/untimeout or unban a user 26 | type UnmuteAction interface { 27 | User() User 28 | 29 | Type() MuteType 30 | } 31 | 32 | // DeleteAction defines an action that will delete a message 33 | type DeleteAction interface { 34 | Message() string 35 | } 36 | 37 | // MessageAction defines a message that will be publicly displayed 38 | type MessageAction interface { 39 | // TODO: Add reply message action 40 | SetAction(v bool) 41 | Evaluate() string 42 | } 43 | 44 | // WhisperAction defines a message that will be privately sent to a user 45 | type WhisperAction interface { 46 | User() User 47 | Content() string 48 | } 49 | 50 | // Actions is a list of actions that wants to be run 51 | // An implementation of this can decide to filter out all mutes except for the "most grave one" 52 | type Actions interface { 53 | Timeout(user User, duration time.Duration) MuteAction 54 | 55 | Ban(user User) MuteAction 56 | 57 | Unban(user User) UnmuteAction 58 | 59 | Say(content string) MessageAction 60 | 61 | Delete(message string) DeleteAction 62 | 63 | Mention(user User, content string) MessageAction 64 | 65 | Whisper(user User, content string) WhisperAction 66 | 67 | Mutes() []MuteAction 68 | Unmutes() []UnmuteAction 69 | Deletes() []DeleteAction 70 | Messages() []MessageAction 71 | Whispers() []WhisperAction 72 | 73 | StopPropagation() bool 74 | 75 | // DoOnSuccess(func()) 76 | 77 | // Do(func()) 78 | } 79 | -------------------------------------------------------------------------------- /pkg/apirequest/apirequest.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | // HTTPRequest requests the given url 9 | func HTTPRequest(url string) ([]byte, error) { 10 | req, err := http.Get(url) 11 | if err != nil { 12 | return nil, err 13 | } 14 | bs, err := io.ReadAll(req.Body) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return bs, nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/apirequest/bttv.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | 3 | import "github.com/pajlada/gobttv" 4 | 5 | // BTTV is the BTTV api endpoint master 6 | var BTTV = gobttv.New() 7 | -------------------------------------------------------------------------------- /pkg/apirequest/common.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | -------------------------------------------------------------------------------- /pkg/apirequest/custom.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | -------------------------------------------------------------------------------- /pkg/apirequest/ffz.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | 3 | import "github.com/pajlada/goffz" 4 | 5 | // FFZ is the FFZ api endpoint master 6 | var FFZ = goffz.New() 7 | -------------------------------------------------------------------------------- /pkg/apirequest/twitch.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg/common/config" 5 | ) 6 | 7 | func InitTwitch(cfg *config.Config) (err error) { 8 | err = initWrapper(cfg) 9 | 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /pkg/apirequest/twitchratelimit.go: -------------------------------------------------------------------------------- 1 | package apirequest 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type TwitchRateLimit struct { 12 | mutex *sync.RWMutex 13 | 14 | // The rate at which points are added to your bucket. This is the average number of requests per minute you can make over an extended period of time. 15 | Limit int 16 | 17 | // The number of points you have left to use. 18 | Remaining int 19 | 20 | // A timestamp of when your bucket is reset to full. 21 | Reset time.Time 22 | } 23 | 24 | func NewTwitchRateLimit() TwitchRateLimit { 25 | return TwitchRateLimit{ 26 | mutex: &sync.RWMutex{}, 27 | } 28 | } 29 | 30 | func (l *TwitchRateLimit) Update(r *http.Response) { 31 | limit := r.Header.Get("Ratelimit-Limit") 32 | remaining := r.Header.Get("Ratelimit-Remaining") 33 | reset := r.Header.Get("Ratelimit-Reset") 34 | 35 | if limit == "" || remaining == "" || reset == "" { 36 | return 37 | } 38 | 39 | nLimit, err := strconv.Atoi(limit) 40 | if err != nil { 41 | fmt.Println("Error parsing limit from", limit) 42 | } 43 | nRemaining, err := strconv.Atoi(remaining) 44 | if err != nil { 45 | fmt.Println("Error parsing remaining from", remaining) 46 | } 47 | 48 | l.mutex.Lock() 49 | defer l.mutex.Unlock() 50 | 51 | l.Limit = nLimit 52 | l.Remaining = nRemaining 53 | } 54 | 55 | func (l *TwitchRateLimit) String() string { 56 | return fmt.Sprintf("[RateLimit Limit=%d Remaining=%d Reset=%s]", l.Limit, l.Remaining, l.Reset) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/application.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | // Application is an instance of pajbot2 8 | // It's responsible for initializing all bot accounts (`Bot` class) 9 | type Application interface { 10 | UserStore() UserStore 11 | ChannelStore() ChannelStore 12 | UserContext() UserContext 13 | StreamStore() StreamStore 14 | SQL() *sql.DB 15 | PubSub() PubSub 16 | TwitchBots() BotStore 17 | QuitChannel() chan string 18 | TwitchAuths() TwitchAuths 19 | MIMO() MIMO 20 | } 21 | -------------------------------------------------------------------------------- /pkg/auth.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "golang.org/x/oauth2" 4 | 5 | type TwitchAuths interface { 6 | Bot() *oauth2.Config 7 | Streamer() *oauth2.Config 8 | User() *oauth2.Config 9 | } 10 | -------------------------------------------------------------------------------- /pkg/banphrase.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "time" 4 | 5 | type Banphrase interface { 6 | Triggers(text string) bool 7 | IsCaseSensitive() bool 8 | 9 | // IsAdvanced decides whether or not the banphrase should be run on all variations or only the first one 10 | IsAdvanced() bool 11 | 12 | GetName() string 13 | GetID() int 14 | GetDuration() time.Duration 15 | } 16 | -------------------------------------------------------------------------------- /pkg/bot.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "golang.org/x/oauth2" 4 | 5 | type Sender interface { 6 | TwitchAccount() TwitchAccount 7 | GetTokenSource() oauth2.TokenSource 8 | 9 | Connected() bool 10 | 11 | Say(Channel, string) 12 | Mention(Channel, User, string) 13 | Whisper(User, string) 14 | Whisperf(User, string, ...interface{}) 15 | 16 | // Timeout times the user out a single time immediately 17 | Timeout(Channel, User, int, string) 18 | 19 | Ban(Channel, User, string) 20 | 21 | GetPoints(Channel, string) uint64 22 | 23 | // give or remove points from user in channel 24 | BulkEdit(string, []string, int32) 25 | 26 | AddPoints(Channel, string, uint64) (bool, uint64) 27 | RemovePoints(Channel, string, uint64) (bool, uint64) 28 | ForceRemovePoints(Channel, string, uint64) uint64 29 | 30 | PointRank(Channel, string) uint64 31 | 32 | // ChannelIDs returns a slice of the channels this bot is connected to 33 | ChannelIDs() []string 34 | 35 | InChannel(string) bool 36 | InChannelName(string) bool 37 | GetUserStore() UserStore 38 | GetUserContext() UserContext 39 | 40 | GetBotChannel(channelName string) BotChannel 41 | GetBotChannelByID(channelID string) BotChannel 42 | 43 | MakeUser(string) User 44 | MakeChannel(string) Channel 45 | 46 | // Permanently join channel with the given channel ID 47 | JoinChannel(channelID string) error 48 | 49 | // Permanently leave channel with the given channel ID 50 | LeaveChannel(channelID string) error 51 | 52 | // Connect to the OnNewChannelJoined callback 53 | OnNewChannelJoined(cb func(channel Channel)) 54 | 55 | Quit(message string) 56 | 57 | Application() Application 58 | 59 | // DEV 60 | Disconnect() 61 | } 62 | -------------------------------------------------------------------------------- /pkg/botchannel.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "github.com/pajbot/pajbot2/pkg/eventemitter" 4 | 5 | type MessageSender interface { 6 | Say(string) 7 | Mention(User, string) 8 | 9 | // Moderation 10 | Timeout(User, int, string) 11 | Ban(User, string) 12 | } 13 | 14 | type BotChannel interface { 15 | // Implement Channel interface 16 | GetName() string 17 | GetID() string 18 | 19 | MessageSender 20 | 21 | DatabaseID() int64 22 | Channel() Channel 23 | ChannelID() string 24 | ChannelName() string 25 | 26 | EnableModule(string) error 27 | DisableModule(string) error 28 | GetModule(string) (Module, error) 29 | 30 | // Implement ChannelWithStream interface 31 | Stream() Stream 32 | 33 | Events() *eventemitter.EventEmitter 34 | 35 | HandleMessage(user User, message Message) error 36 | HandleEventSubNotification(notification TwitchEventSubNotification) error 37 | OnModules(cb func(module Module) Actions, stop bool) []Actions 38 | 39 | SetSubscribers(state bool) error 40 | SetUniqueChat(state bool) error 41 | SetEmoteOnly(state bool) error 42 | SetSlowMode(state bool, durationS int) error 43 | SetFollowerMode(state bool, durationM int) error 44 | SetNonModChatDelay(state bool, durationS int) error 45 | 46 | Bot() Sender 47 | } 48 | -------------------------------------------------------------------------------- /pkg/botstore.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type BotStore interface { 4 | Add(Sender) 5 | 6 | GetBotFromName(string) Sender 7 | GetBotFromID(string) Sender 8 | GetBotFromChannel(string) Sender 9 | 10 | Iterate() BotStoreIterator 11 | } 12 | 13 | type BotStoreIterator interface { 14 | Next() bool 15 | Value() Sender 16 | } 17 | -------------------------------------------------------------------------------- /pkg/botstore/botstore.go: -------------------------------------------------------------------------------- 1 | package botstore 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | var _ pkg.BotStore = &BotStore{} 10 | 11 | type BotStore struct { 12 | store []pkg.Sender 13 | } 14 | 15 | func New() *BotStore { 16 | return &BotStore{} 17 | } 18 | 19 | func (s *BotStore) Add(bot pkg.Sender) { 20 | s.store = append(s.store, bot) 21 | } 22 | 23 | func (s *BotStore) GetBotFromName(name string) pkg.Sender { 24 | for _, b := range s.store { 25 | if b.TwitchAccount().Name() == strings.ToLower(name) { 26 | return b 27 | } 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (s *BotStore) GetBotFromID(id string) pkg.Sender { 34 | for _, b := range s.store { 35 | if b.TwitchAccount().ID() == id { 36 | return b 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (s *BotStore) GetBotFromChannel(channelID string) pkg.Sender { 44 | for _, b := range s.store { 45 | botExists := b.InChannel(channelID) 46 | if botExists { 47 | return b 48 | } 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (s *BotStore) Iterate() pkg.BotStoreIterator { 55 | return &BotStoreIterator{ 56 | data: s.store, 57 | index: -1, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/botstore/botstoreiterator.go: -------------------------------------------------------------------------------- 1 | package botstore 2 | 3 | import "github.com/pajbot/pajbot2/pkg" 4 | 5 | var _ pkg.BotStoreIterator = &BotStoreIterator{} 6 | 7 | type BotStoreIterator struct { 8 | data []pkg.Sender 9 | 10 | index int 11 | } 12 | 13 | func (i *BotStoreIterator) Next() bool { 14 | i.index++ 15 | 16 | return i.index < len(i.data) 17 | } 18 | 19 | func (i *BotStoreIterator) Value() pkg.Sender { 20 | return i.data[i.index] 21 | } 22 | -------------------------------------------------------------------------------- /pkg/channel.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // Channel is the most barebones way of accessing a Twitch channel 4 | // For a Channel to 'live' we must be able to access its Name (Twitch User Login) and ID (Twitch User ID) 5 | type Channel interface { 6 | GetName() string 7 | GetID() string 8 | } 9 | 10 | type ChannelWithStream interface { 11 | Channel 12 | 13 | Stream() Stream 14 | } 15 | 16 | type ChannelStore interface { 17 | TwitchChannel(channelID string) Channel 18 | RegisterTwitchChannel(channel Channel) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/channels/store.go: -------------------------------------------------------------------------------- 1 | package channels 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | var _ pkg.ChannelStore = &Store{} 10 | 11 | type Store struct { 12 | data map[string]pkg.Channel 13 | dataMutex sync.Mutex 14 | } 15 | 16 | func (s *Store) TwitchChannel(channelID string) (channel pkg.Channel) { 17 | s.dataMutex.Lock() 18 | defer s.dataMutex.Unlock() 19 | 20 | channel = s.data[channelID] 21 | 22 | return 23 | } 24 | 25 | func (s *Store) RegisterTwitchChannel(channel pkg.Channel) { 26 | s.dataMutex.Lock() 27 | defer s.dataMutex.Unlock() 28 | 29 | s.data[channel.GetID()] = channel 30 | } 31 | 32 | func NewStore() *Store { 33 | return &Store{ 34 | data: make(map[string]pkg.Channel), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/channels/twitchchannel.go: -------------------------------------------------------------------------------- 1 | package channels 2 | 3 | type TwitchChannel struct { 4 | Channel string 5 | ID string 6 | } 7 | 8 | func (c TwitchChannel) GetName() string { 9 | return c.Channel 10 | } 11 | 12 | func (c TwitchChannel) GetID() string { 13 | return c.ID 14 | } 15 | -------------------------------------------------------------------------------- /pkg/commandlist/commandlist.go: -------------------------------------------------------------------------------- 1 | package commandlist 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | var ( 10 | commandsMutex sync.Mutex 11 | commands []pkg.CommandInfo 12 | ) 13 | 14 | func Register(commandInfo pkg.CommandInfo) { 15 | commandsMutex.Lock() 16 | commands = append(commands, commandInfo) 17 | commandsMutex.Unlock() 18 | } 19 | 20 | func Commands() []pkg.CommandInfo { 21 | commandsMutex.Lock() 22 | defer commandsMutex.Unlock() 23 | return commands 24 | } 25 | 26 | func init() { 27 | 28 | } 29 | -------------------------------------------------------------------------------- /pkg/commands.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type CommandInfo struct { 4 | Name string 5 | Description string 6 | 7 | Maker func() CustomCommand2 `json:"-"` 8 | } 9 | 10 | type SimpleCommand interface { 11 | Trigger([]string, MessageEvent) Actions 12 | } 13 | 14 | type CustomCommand2 interface { 15 | SimpleCommand 16 | 17 | HasCooldown(User) bool 18 | AddCooldown(User) 19 | } 20 | 21 | type CommandMatcher interface { 22 | Register(aliases []string, command interface{}) interface{} 23 | Deregister(command interface{}) 24 | DeregisterAliases(aliases []string) 25 | Match(text string) (interface{}, []string) 26 | } 27 | 28 | type CommandsManager interface { 29 | CommandMatcher 30 | OnMessage(event MessageEvent) Actions 31 | 32 | FindByCommandID(id int64) interface{} 33 | 34 | Register2(id int64, aliases []string, command interface{}) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/commands/base.go: -------------------------------------------------------------------------------- 1 | package commands 2 | -------------------------------------------------------------------------------- /pkg/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/pajbot/commandmatcher" 5 | "github.com/pajbot/pajbot2/pkg" 6 | ) 7 | 8 | type Commands struct { 9 | *commandmatcher.CommandMatcher 10 | 11 | internalCommands map[int64]interface{} 12 | } 13 | 14 | func NewCommands() *Commands { 15 | c := &Commands{ 16 | CommandMatcher: commandmatcher.New(), 17 | 18 | internalCommands: map[int64]interface{}{}, 19 | } 20 | 21 | return c 22 | } 23 | 24 | func (c *Commands) Register2(id int64, triggers []string, cmd interface{}) { 25 | c.CommandMatcher.Register(triggers, cmd) 26 | c.internalCommands[id] = cmd 27 | } 28 | 29 | func (c *Commands) FindByCommandID(id int64) interface{} { 30 | return c.internalCommands[id] 31 | } 32 | 33 | func (c *Commands) OnMessage(event pkg.MessageEvent) pkg.Actions { 34 | message := event.Message 35 | user := event.User 36 | 37 | match, parts := c.Match(message.GetText()) 38 | if match != nil { 39 | switch command := match.(type) { 40 | case pkg.CustomCommand2: 41 | if command.HasCooldown(user) { 42 | return nil 43 | } 44 | command.AddCooldown(user) 45 | return command.Trigger(parts, event) 46 | 47 | case pkg.SimpleCommand: 48 | return command.Trigger(parts, event) 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/commands/join.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pajbot/pajbot2/internal/commands/base" 8 | "github.com/pajbot/pajbot2/pkg" 9 | "github.com/pajbot/pajbot2/pkg/commandlist" 10 | "github.com/pajbot/pajbot2/pkg/twitchactions" 11 | ) 12 | 13 | func init() { 14 | commandlist.Register(pkg.CommandInfo{ 15 | Name: "Join", 16 | Description: "xd join lol", 17 | // FIXME 18 | // Maker: NewJoin, 19 | }) 20 | } 21 | 22 | type Join struct { 23 | base.Command 24 | 25 | bot pkg.BotChannel 26 | } 27 | 28 | func NewJoin(bot pkg.BotChannel) pkg.CustomCommand2 { 29 | return &Join{ 30 | Command: base.New(), 31 | 32 | bot: bot, 33 | } 34 | } 35 | 36 | func (c Join) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 37 | user := event.User 38 | if !user.HasGlobalPermission(pkg.PermissionAdmin) { 39 | const errorMessage = "you do not have permission to use this command. Admin permission is required" 40 | return twitchactions.Mention(user, errorMessage) 41 | } 42 | 43 | if len(parts) < 2 { 44 | return nil 45 | } 46 | 47 | channelName := parts[1] 48 | 49 | if strings.EqualFold(channelName, c.bot.Bot().TwitchAccount().Name()) { 50 | const errorMessage = "I cannot join my own channel" 51 | return twitchactions.Mention(user, errorMessage) 52 | } 53 | 54 | channelID := c.bot.Bot().GetUserStore().GetID(channelName) 55 | if channelID == "" { 56 | const errorMessage = "no channel with that name exists" 57 | return twitchactions.Mention(user, errorMessage) 58 | } 59 | 60 | err := c.bot.Bot().JoinChannel(channelID) 61 | if err != nil { 62 | return twitchactions.Mention(user, err.Error()) 63 | } 64 | 65 | return twitchactions.Mention(user, fmt.Sprintf("joined channel %s(%s)", channelName, channelID)) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/commands/leave.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pajbot/pajbot2/internal/commands/base" 8 | "github.com/pajbot/pajbot2/pkg" 9 | "github.com/pajbot/pajbot2/pkg/commandlist" 10 | "github.com/pajbot/pajbot2/pkg/twitchactions" 11 | ) 12 | 13 | func init() { 14 | commandlist.Register(pkg.CommandInfo{ 15 | Name: "Leave", 16 | Description: "xd leave lol", 17 | // Maker: NewLeave, 18 | }) 19 | } 20 | 21 | type Leave struct { 22 | base.Command 23 | 24 | bot pkg.BotChannel 25 | } 26 | 27 | func NewLeave(bot pkg.BotChannel) pkg.CustomCommand2 { 28 | return &Leave{ 29 | Command: base.New(), 30 | 31 | bot: bot, 32 | } 33 | } 34 | 35 | func (c Leave) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 36 | user := event.User 37 | 38 | if !user.HasGlobalPermission(pkg.PermissionAdmin) { 39 | return twitchactions.Mention(user, "you do not have permission to use this command. Admin permission is required") 40 | } 41 | 42 | if len(parts) < 2 { 43 | return nil 44 | } 45 | 46 | channelName := parts[1] 47 | 48 | if strings.EqualFold(channelName, c.bot.Bot().TwitchAccount().Name()) { 49 | return twitchactions.Mention(user, "I cannot leave my own channel") 50 | } 51 | 52 | channelID := c.bot.Bot().GetUserStore().GetID(channelName) 53 | if channelID == "" { 54 | return twitchactions.Mention(user, "no channel with that name exists") 55 | } 56 | 57 | err := c.bot.Bot().LeaveChannel(channelID) 58 | if err != nil { 59 | return twitchactions.Mention(user, "Error leaving channel: "+err.Error()) 60 | } 61 | 62 | return twitchactions.Mention(user, fmt.Sprintf("left channel %s(%s)", channelName, channelID)) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/commands/module.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pajbot/pajbot2/internal/commands/base" 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/twitchactions" 9 | ) 10 | 11 | type moduleCommand struct { 12 | base.Command 13 | 14 | subCommands *subCommands 15 | defaultSubCommand string 16 | } 17 | 18 | func NewModule(bot pkg.BotChannel) pkg.CustomCommand2 { 19 | u := &moduleCommand{ 20 | Command: base.New(), 21 | subCommands: newSubCommands(), 22 | defaultSubCommand: "list", 23 | } 24 | 25 | u.UserCooldown = 0 26 | u.GlobalCooldown = 0 27 | 28 | u.subCommands.add("list", &subCommand{ 29 | permission: pkg.PermissionAdmin, 30 | cb: func(parts []string, event pkg.MessageEvent) pkg.Actions { 31 | return twitchactions.Mention(event.User, "TODO: list modules") 32 | }, 33 | }) 34 | 35 | u.subCommands.add("enable", &subCommand{ 36 | permission: pkg.PermissionAdmin, 37 | cb: func(parts []string, event pkg.MessageEvent) pkg.Actions { 38 | if len(parts) < 3 { 39 | return twitchactions.Mention(event.User, "usage: !module enable MODULE_ID") 40 | } 41 | 42 | moduleID := parts[2] 43 | 44 | err := bot.EnableModule(moduleID) 45 | if err != nil { 46 | return twitchactions.Mention(event.User, err.Error()) 47 | } 48 | 49 | return twitchactions.Mentionf(event.User, "Enabled module %s", moduleID) 50 | }, 51 | }) 52 | 53 | u.subCommands.addSC("disable", &subCommand{ 54 | permission: pkg.PermissionAdmin, 55 | cb: func(parts []string, event pkg.MessageEvent) pkg.Actions { 56 | if len(parts) < 3 { 57 | return twitchactions.Mention(event.User, "usage: !module disable MODULE_ID") 58 | } 59 | 60 | moduleID := parts[2] 61 | 62 | err := bot.DisableModule(moduleID) 63 | if err != nil { 64 | return twitchactions.Mention(event.User, err.Error()) 65 | } 66 | 67 | return twitchactions.Mentionf(event.User, "Disabled module %s", moduleID) 68 | }, 69 | }) 70 | 71 | return u 72 | } 73 | 74 | func (c *moduleCommand) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 75 | subCommandName := c.defaultSubCommand 76 | if len(parts) >= 2 { 77 | subCommandName = strings.ToLower(parts[1]) 78 | } 79 | 80 | if subCommand, ok := c.subCommands.find(subCommandName); ok { 81 | return subCommand.run(parts, event) 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/commands/pajbot1_command.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/pajbot/pajbot2/pkg" 9 | ) 10 | 11 | const commandPrefix = "!" 12 | 13 | // Pajbot1Command is a command loaded from the old pajbot1 database 14 | type Pajbot1Command struct { 15 | Level int 16 | Action string 17 | Triggers []string 18 | Enabled bool 19 | PointCost int 20 | 21 | GlobalCooldown int 22 | UserCooldown int 23 | 24 | CanExecuteWithWhisper bool 25 | SubOnly bool 26 | ModOnly bool 27 | 28 | TokenCost int 29 | 30 | // If a command has possible userdata, it means we should run it through our banphrase filter before printing it 31 | HasUserdata bool 32 | } 33 | 34 | type pajbot1CommandAction struct { 35 | Message string `json:"message"` 36 | Type string `json:"type"` 37 | } 38 | 39 | func (c *Pajbot1Command) LoadScan(rows *sql.Rows) error { 40 | var actionString []byte 41 | var commandString string 42 | const queryF = `SELECT level, action, command, delay_all, delay_user, enabled, cost, can_execute_with_whisper, sub_only, mod_only, tokens_cost FROM tb_command` 43 | err := rows.Scan(&c.Level, &actionString, &commandString, &c.GlobalCooldown, &c.UserCooldown, &c.Enabled, &c.PointCost, &c.CanExecuteWithWhisper, &c.SubOnly, &c.ModOnly, &c.TokenCost) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | var action pajbot1CommandAction 49 | 50 | err = json.Unmarshal(actionString, &action) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | c.Triggers = strings.Split(commandString, "|") 56 | 57 | for key, t := range c.Triggers { 58 | c.Triggers[key] = commandPrefix + t 59 | } 60 | 61 | c.Action = action.Message 62 | 63 | return nil 64 | } 65 | 66 | func (c *Pajbot1Command) IsTriggered(parts []string) bool { 67 | for _, trigger := range c.Triggers { 68 | if strings.ToLower(parts[0]) == trigger { 69 | return true 70 | } 71 | } 72 | 73 | return false 74 | } 75 | 76 | func (c *Pajbot1Command) Trigger(bot pkg.BotChannel, user pkg.User, parts []string) error { 77 | bot.Say(c.Action) 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/commands/ping.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/pajbot/pajbot2/internal/commands/base" 8 | "github.com/pajbot/pajbot2/pkg" 9 | "github.com/pajbot/pajbot2/pkg/commandlist" 10 | "github.com/pajbot/pajbot2/pkg/common" 11 | "github.com/pajbot/pajbot2/pkg/twitchactions" 12 | "github.com/pajbot/utils" 13 | ) 14 | 15 | func init() { 16 | commandlist.Register(pkg.CommandInfo{ 17 | Name: "Ping", 18 | Description: "xd ping lol", 19 | Maker: NewPing, 20 | }) 21 | } 22 | 23 | type Ping struct { 24 | base.Command 25 | } 26 | 27 | func NewPing() pkg.CustomCommand2 { 28 | return &Ping{ 29 | Command: base.New(), 30 | } 31 | } 32 | 33 | func (c Ping) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 34 | msg := fmt.Sprintf("pb2 has been running for %s (%s %s", utils.TimeSince(startTime), common.Version(), runtime.Version()) 35 | if common.BuildTime != "" { 36 | msg += " built " + common.BuildTime 37 | } 38 | msg += ")" 39 | 40 | return twitchactions.Mention(event.User, msg) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/commands/quit.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/internal/commands/base" 5 | "github.com/pajbot/pajbot2/pkg" 6 | "github.com/pajbot/pajbot2/pkg/commandlist" 7 | ) 8 | 9 | func init() { 10 | commandlist.Register(pkg.CommandInfo{ 11 | Name: "Quit", 12 | Description: "quit the bot", 13 | // FIXME 14 | // Maker: NewQuit, 15 | }) 16 | } 17 | 18 | type Quit struct { 19 | base.Command 20 | 21 | bot pkg.BotChannel 22 | } 23 | 24 | func NewQuit(bot pkg.BotChannel) pkg.CustomCommand2 { 25 | c := &Quit{ 26 | Command: base.New(), 27 | 28 | bot: bot, 29 | } 30 | 31 | return c 32 | } 33 | 34 | func (c *Quit) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 35 | user := event.User 36 | 37 | // FIXME: Channel should be part of the message event 38 | if !user.HasPermission(c.bot.Channel(), pkg.PermissionAdmin) { 39 | return nil 40 | } 41 | 42 | // FIXME: this should be an "on done action" 43 | c.bot.Bot().Quit("hehe") 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/commands/rank.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | ) 6 | 7 | type Rank struct { 8 | } 9 | 10 | func (c Rank) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 11 | // FIXME: Re-implement (POINTS SYSTEM) 12 | // var potentialTarget string 13 | // targetID := event.User.GetID() 14 | 15 | // if len(parts) >= 2 { 16 | // potentialTarget = utils.FilterUsername(parts[1]) 17 | // if potentialTarget != "" { 18 | // potentialTargetID := event.UserStore.GetID(potentialTarget) 19 | // if potentialTargetID != "" { 20 | // targetID = potentialTargetID 21 | // } else { 22 | // potentialTarget = "" 23 | // } 24 | // } 25 | // } 26 | 27 | // rank := botChannel.Bot().PointRank(event.Channel, targetID) 28 | // if potentialTarget == "" { 29 | // return twitchactions.Mention(event.User, "you are rank "+strconv.FormatUint(rank, 10)+" in points") 30 | // } 31 | 32 | // return twitchactions.Mention(event.User, potentialTarget+" is rank "+strconv.FormatUint(rank, 10)+" in points") 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/commands/subcommands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/twitchactions" 6 | ) 7 | 8 | type subCommands struct { 9 | m map[string]*subCommand 10 | } 11 | 12 | func newSubCommands() *subCommands { 13 | return &subCommands{ 14 | m: make(map[string]*subCommand), 15 | } 16 | } 17 | 18 | func (c *subCommands) add(name string, sc *subCommand) { 19 | c.m[name] = sc 20 | } 21 | 22 | func (c *subCommands) find(name string) (*subCommand, bool) { 23 | a, b := c.m[name] 24 | return a, b 25 | } 26 | 27 | func (c *subCommands) addSC(name string, sc *subCommand) { 28 | c.add(name, sc) 29 | c.add(name+"s", sc) 30 | } 31 | 32 | type subCommandFunc func(parts []string, event pkg.MessageEvent) pkg.Actions 33 | 34 | type subCommand struct { 35 | permission pkg.Permission 36 | cb subCommandFunc 37 | } 38 | 39 | func (c *subCommand) run(parts []string, event pkg.MessageEvent) pkg.Actions { 40 | if c.permission != pkg.PermissionNone { 41 | if !event.User.HasPermission(event.Channel, c.permission) { 42 | return twitchactions.Mention(event.User, "you do not have permission to use this command") 43 | } 44 | } 45 | 46 | return c.cb(parts, event) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/commandsubstitution/Makefile: -------------------------------------------------------------------------------- 1 | include ../../.make/test.mk 2 | -------------------------------------------------------------------------------- /pkg/commandsubstitution/helpers_test.go: -------------------------------------------------------------------------------- 1 | package commandsubstitution 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func assertStringsEqual(t *testing.T, expected, actual string) { 8 | if expected != actual { 9 | t.Errorf("failed asserting that \"%s\" is expected \"%s\"", actual, expected) 10 | } 11 | } 12 | 13 | func assertIntsEqual(t *testing.T, expected, actual int) { 14 | if expected != actual { 15 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 16 | } 17 | } 18 | 19 | func assertInt32sEqual(t *testing.T, expected, actual int32) { 20 | if expected != actual { 21 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 22 | } 23 | } 24 | 25 | func assertTrue(t *testing.T, actual bool, errorMessage string) { 26 | if !actual { 27 | t.Error(errorMessage) 28 | } 29 | } 30 | 31 | func assertFalse(t *testing.T, actual bool, errorMessage string) { 32 | if actual { 33 | t.Error(errorMessage) 34 | } 35 | } 36 | 37 | func assertStringSlicesEqual(t *testing.T, expected, actual []string) { 38 | if actual == nil { 39 | t.Errorf("actual slice was nil") 40 | return 41 | } 42 | 43 | if len(actual) != len(expected) { 44 | t.Errorf("actual slice was not the same length as expected slice") 45 | } 46 | 47 | for i, v := range actual { 48 | if v != expected[i] { 49 | t.Errorf("actual slice value \"%s\" was not equal to expected value \"%s\" at index \"%d\"", v, expected[i], i) 50 | } 51 | } 52 | } 53 | 54 | func assertErrorsEqual(t *testing.T, expected, actual error) { 55 | if expected != actual { 56 | t.Errorf("failed asserting that error \"%s\" is expected \"%s\"", actual, expected) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // this should make things easier with redis 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | var ( 11 | // BuildTime is the time when the binary was built 12 | // filled in with ./build.sh (ldflags) 13 | BuildTime string 14 | 15 | BuildRelease string 16 | 17 | BuildHash string 18 | 19 | BuildBranch string 20 | ) 21 | 22 | func Version() string { 23 | if BuildRelease == "git" { 24 | return fmt.Sprintf("%s@%s", BuildHash, BuildBranch) 25 | } 26 | 27 | return BuildRelease 28 | } 29 | 30 | // GlobalUser will only be used by boss to check if user is admin 31 | // and to decide what channel to send the message to if its a whisper 32 | type GlobalUser struct { 33 | LastActive time.Time 34 | Channel string 35 | Level int 36 | } 37 | 38 | // MsgType specifies the message's type, for example PRIVMSG or WHISPER 39 | type MsgType uint32 40 | 41 | // Various message types which describe what sort of message they are 42 | const ( 43 | MsgPrivmsg MsgType = iota + 1 44 | MsgWhisper 45 | MsgSub 46 | MsgThrowAway 47 | MsgUnknown 48 | MsgUserNotice 49 | MsgReSub 50 | MsgNotice 51 | MsgRoomState 52 | MsgSubsOn 53 | MsgSubsOff 54 | MsgSlowOn 55 | MsgSlowOff 56 | MsgR9kOn 57 | MsgR9kOff 58 | MsgHostOn 59 | MsgHostOff 60 | MsgTimeoutSuccess 61 | MsgReconnect 62 | ) 63 | -------------------------------------------------------------------------------- /pkg/common/common_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // Not sure what we can test with this really 4 | -------------------------------------------------------------------------------- /pkg/common/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLoadConfig(t *testing.T) { 8 | /* 9 | var configTests = []struct { 10 | inputPath string 11 | expectedC *Config 12 | expectedE bool 13 | }{ 14 | { 15 | inputPath: "../../resources/testfiles/config1.json", 16 | expectedC: &Config{ 17 | BrokerHost: helper.NewStringPtr("localhost:7353"), 18 | BrokerPass: helper.NewStringPtr("test"), 19 | RedisHost: "localhost:6379", 20 | SQLDSN: "pajbot2:password@tcp(localhost:3306)/pajbot2_test", 21 | RedisPassword: "", 22 | RedisDatabase: -1, 23 | TLSKey: "", 24 | TLSCert: "", 25 | ToWeb: (chan map[string]interface{})(nil), 26 | FromWeb: (chan map[string]interface{})(nil)}, 27 | expectedE: false, 28 | }, 29 | { 30 | inputPath: "../../resources/testfiles/nonexistingconfigfile.json", 31 | expectedC: nil, 32 | expectedE: true, 33 | }, 34 | { 35 | inputPath: "../../resources/testfiles/config2_invalidjson.json", 36 | expectedC: nil, 37 | expectedE: true, 38 | }, 39 | } 40 | 41 | for _, tt := range configTests { 42 | res, err := LoadConfig(tt.inputPath) 43 | 44 | if tt.expectedE { 45 | assert.NotNil(t, err) 46 | } else { 47 | assert.Nil(t, err) 48 | } 49 | 50 | assert.Equal(t, tt.expectedC, res) 51 | } 52 | */ 53 | } 54 | -------------------------------------------------------------------------------- /pkg/common/dbuser.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // DBUser xD 4 | type DBUser struct { 5 | // ID in the database 6 | ID int 7 | 8 | // Name of the user, i.e. snusbot 9 | Name string 10 | 11 | // Type of the user 12 | Type string 13 | 14 | TwitchCredentials TwitchClientCredentials 15 | } 16 | 17 | const userQ = "SELECT id, name, twitch_access_token, twitch_refresh_token FROM pb_user" 18 | -------------------------------------------------------------------------------- /pkg/common/emoji.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/urakozz/go-emoji" 5 | ) 6 | 7 | var parser = emoji.NewEmojiParser() 8 | 9 | // ParseEmojis prases emojis from the message Text 10 | /* 11 | func ParseEmojis(msg *Msg) { 12 | emoteCount := make(map[string]*Emote) 13 | _ = parser.ReplaceAllStringFunc(msg.Text, func(s string) string { 14 | byteArray := []byte(s) 15 | if emote, ok := emoteCount[s]; ok { 16 | emote.Count++ 17 | } else { 18 | emoteCount[s] = &Emote{ 19 | ID: fmt.Sprintf("%x", bytes.Runes(byteArray)[0]), 20 | Type: "emoji", 21 | SizeX: 28, 22 | SizeY: 28, 23 | IsGif: false, 24 | Count: 1, 25 | } 26 | } 27 | return "" 28 | }) 29 | 30 | for _, emote := range emoteCount { 31 | msg.Emotes = append(msg.Emotes, *emote) 32 | } 33 | } 34 | */ 35 | -------------------------------------------------------------------------------- /pkg/common/emote.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | // Emote xD 6 | type Emote struct { 7 | Name string `json:"name"` 8 | ID string `json:"id"` 9 | // Possible types: bttv, twitch, ffz 10 | Type string `json:"type"` 11 | 12 | // Size in pixels 13 | SizeX int `json:"size_x"` 14 | SizeY int `json:"size_y"` 15 | 16 | IsGif bool `json:"is_gif"` 17 | Count int `json:"count"` 18 | 19 | MaxScale int `json:"max_scale"` 20 | } 21 | 22 | // ExtensionEmotes is an object which contains emotes that are shared between all channels 23 | type ExtensionEmotes struct { 24 | // Global BTTV Emotes 25 | Bttv map[string]Emote 26 | BttvLastUpdate time.Time 27 | 28 | // Global FrankerFaceZ Emotes 29 | FrankerFaceZ map[string]Emote 30 | FrankerFaceZLastUpdate time.Time 31 | } 32 | 33 | // EmoteByName implements sort.Interface by emote name 34 | type EmoteByName []Emote 35 | 36 | // Len implements sort.Interface 37 | func (a EmoteByName) Len() int { 38 | return len(a) 39 | } 40 | 41 | // Swap implements sort.Interface 42 | func (a EmoteByName) Swap(i, j int) { 43 | a[i], a[j] = a[j], a[i] 44 | } 45 | 46 | // Less implements sort.Interface 47 | func (a EmoteByName) Less(i, j int) bool { 48 | return a[i].Name < a[j].Name 49 | } 50 | 51 | func (e Emote) GetID() string { 52 | return e.ID 53 | } 54 | 55 | func (e Emote) GetName() string { 56 | return e.Name 57 | } 58 | 59 | func (e Emote) GetType() string { 60 | return e.Type 61 | } 62 | 63 | func (e Emote) GetCount() int { 64 | return e.Count 65 | } 66 | -------------------------------------------------------------------------------- /pkg/common/sql.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "github.com/go-sql-driver/mysql" 4 | 5 | func IsDuplicateKey(err error) bool { 6 | me, ok := err.(*mysql.MySQLError) 7 | if !ok { 8 | return false 9 | } 10 | 11 | return me.Number == 1062 12 | } 13 | -------------------------------------------------------------------------------- /pkg/common/twitchcredentials.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // TwitchClientCredentials xD 4 | type TwitchClientCredentials struct { 5 | AccessToken string 6 | 7 | RefreshToken string 8 | } 9 | -------------------------------------------------------------------------------- /pkg/const.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const VerboseBenchmark = false 4 | const VerboseMessages = false 5 | -------------------------------------------------------------------------------- /pkg/emote.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type Emote interface { 4 | // i.e. "8vn23893nvcakuj23" for a bttv emote, or "85489481" for a twitch emote 5 | GetID() string 6 | 7 | // i.e. "NaM" or "forsenE" 8 | GetName() string 9 | 10 | // "twitch" or "bttv" 11 | GetType() string 12 | 13 | GetCount() int 14 | } 15 | 16 | type EmoteReader interface { 17 | Next() bool 18 | Get() Emote 19 | } 20 | -------------------------------------------------------------------------------- /pkg/emotes.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | -------------------------------------------------------------------------------- /pkg/eventemitter/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @go test -v 3 | 4 | cover: 5 | @go test -coverprofile=coverage.out -covermode=count 6 | @go tool cover -html=coverage.out -o coverage.html 7 | -------------------------------------------------------------------------------- /pkg/eventemitter/eventemitter.go: -------------------------------------------------------------------------------- 1 | package eventemitter 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | // type EventEmitter interface { 9 | // Listen(event string, cb interface{}, priority int) (*Listener, error) 10 | // Emit(event string, arguments map[string]interface{}) 11 | // } 12 | 13 | var ( 14 | ErrBadCallback = errors.New("bad callback passed through to Listen") 15 | ) 16 | 17 | type Listener struct { 18 | Disconnected bool 19 | 20 | cb interface{} 21 | priority int 22 | } 23 | 24 | type EventEmitter struct { 25 | listenersMutex sync.Mutex 26 | listeners map[string][]*Listener 27 | } 28 | 29 | func New() *EventEmitter { 30 | return &EventEmitter{ 31 | listenersMutex: sync.Mutex{}, 32 | listeners: make(map[string][]*Listener), 33 | } 34 | } 35 | 36 | func (e *EventEmitter) Listen(event string, cb interface{}, priority int) (*Listener, error) { 37 | switch cb.(type) { 38 | case func(map[string]interface{}) error: 39 | case func() error: 40 | default: 41 | return nil, ErrBadCallback 42 | } 43 | e.listenersMutex.Lock() 44 | defer e.listenersMutex.Unlock() 45 | 46 | l := &Listener{ 47 | cb: cb, 48 | priority: priority, 49 | } 50 | 51 | // TODO: sort by priority 52 | e.listeners[event] = append(e.listeners[event], l) 53 | 54 | return l, nil 55 | } 56 | 57 | func (e *EventEmitter) Emit(event string, arguments map[string]interface{}) (n int, err error) { 58 | e.listenersMutex.Lock() 59 | defer e.listenersMutex.Unlock() 60 | 61 | listeners, ok := e.listeners[event] 62 | if !ok { 63 | return 64 | } 65 | 66 | for _, listener := range listeners { 67 | if listener.Disconnected { 68 | // TODO: Remove from listeners 69 | continue 70 | } 71 | 72 | switch cb := listener.cb.(type) { 73 | case func(map[string]interface{}) error: 74 | err = cb(arguments) 75 | if err != nil { 76 | return 77 | } 78 | n++ 79 | case func() error: 80 | err = cb() 81 | if err != nil { 82 | return 83 | } 84 | n++ 85 | } 86 | } 87 | 88 | return 89 | } 90 | -------------------------------------------------------------------------------- /pkg/eventemitter/helpers_test.go: -------------------------------------------------------------------------------- 1 | package eventemitter 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func assertStringsEqual(t *testing.T, expected, actual string) { 8 | if expected != actual { 9 | t.Errorf("failed asserting that \"%s\" is expected \"%s\"", actual, expected) 10 | } 11 | } 12 | 13 | func assertIntsEqual(t *testing.T, expected, actual int) { 14 | if expected != actual { 15 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 16 | } 17 | } 18 | 19 | func assertInt32sEqual(t *testing.T, expected, actual int32) { 20 | if expected != actual { 21 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 22 | } 23 | } 24 | 25 | func assertTrue(t *testing.T, actual bool, errorMessage string) { 26 | if !actual { 27 | t.Error(errorMessage) 28 | } 29 | } 30 | 31 | func assertFalse(t *testing.T, actual bool, errorMessage string) { 32 | if actual { 33 | t.Error(errorMessage) 34 | } 35 | } 36 | 37 | func assertStringSlicesEqual(t *testing.T, expected, actual []string) { 38 | if actual == nil { 39 | t.Errorf("actual slice was nil") 40 | return 41 | } 42 | 43 | if len(actual) != len(expected) { 44 | t.Errorf("actual slice was not the same length as expected slice") 45 | } 46 | 47 | for i, v := range actual { 48 | if v != expected[i] { 49 | t.Errorf("actual slice value \"%s\" was not equal to expected value \"%s\" at index \"%d\"", v, expected[i], i) 50 | } 51 | } 52 | } 53 | 54 | func assertErrorsEqual(t *testing.T, expected, actual error) { 55 | if expected != actual { 56 | t.Errorf("failed asserting that error \"%s\" is expected \"%s\"", actual, expected) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/events.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type BaseEvent struct { 4 | UserStore UserStore 5 | } 6 | 7 | type MessageEvent struct { 8 | BaseEvent 9 | 10 | User User 11 | Message Message 12 | Channel ChannelWithStream 13 | } 14 | 15 | type EventSubNotificationEvent struct { 16 | BaseEvent 17 | 18 | Notification TwitchEventSubNotification 19 | } 20 | -------------------------------------------------------------------------------- /pkg/eventsub.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/nicklaw5/helix/v2" 7 | ) 8 | 9 | type TwitchEventSubNotification struct { 10 | Subscription helix.EventSubSubscription `json:"subscription"` 11 | Challenge string `json:"challenge"` 12 | Event json.RawMessage `json:"event"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/message.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type Message interface { 4 | GetText() string 5 | SetText(string) 6 | 7 | GetTwitchReader() EmoteReader 8 | 9 | GetBTTVReader() EmoteReader 10 | AddBTTVEmote(Emote) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/mimo.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // MIMO is a Many In Many Out interface 4 | // Implementation for this exists in pkg/mimo/ 5 | type MIMO interface { 6 | Subscriber(channelNames ...string) chan interface{} 7 | Publisher(channelName string) chan interface{} 8 | } 9 | -------------------------------------------------------------------------------- /pkg/mimo/mimo.go: -------------------------------------------------------------------------------- 1 | package mimo 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type MIMO struct { 9 | subscribersMutex sync.RWMutex 10 | subscribers map[string][]chan interface{} 11 | } 12 | 13 | func New() *MIMO { 14 | return &MIMO{ 15 | subscribers: make(map[string][]chan interface{}), 16 | } 17 | } 18 | 19 | func (m *MIMO) Subscriber(channelNames ...string) (out chan interface{}) { 20 | out = make(chan interface{}, 1) 21 | m.subscribersMutex.Lock() 22 | for _, channelName := range channelNames { 23 | m.subscribers[channelName] = append(m.subscribers[channelName], out) 24 | } 25 | m.subscribersMutex.Unlock() 26 | return 27 | } 28 | 29 | func (m *MIMO) Publisher(channelName string) (in chan interface{}) { 30 | in = make(chan interface{}) 31 | 32 | go func() { 33 | for msg := range in { 34 | m.subscribersMutex.RLock() 35 | subscribers, ok := m.subscribers[channelName] 36 | m.subscribersMutex.RUnlock() 37 | 38 | if ok { 39 | for _, subscriber := range subscribers { 40 | select { 41 | case subscriber <- msg: 42 | default: 43 | fmt.Println("ERROR SENDING DATA TO SUBSCRIBE. MARK FOR DELETION") 44 | } 45 | } 46 | } 47 | } 48 | }() 49 | 50 | return in 51 | } 52 | -------------------------------------------------------------------------------- /pkg/module.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type ModuleFactory func() ModuleSpec 4 | 5 | // A BaseModule is local to a bots channel 6 | // i.e. bot "pajbot" joins channels "pajlada" and "forsen" 7 | // Module list looks like this: 8 | // "pajbot": 9 | // - "pajlada": 10 | // - "MyTestModule" 11 | // - "MyTestModule2" 12 | // - "forsen": 13 | // - "MyTestModule" 14 | type BaseModule interface { 15 | LoadSettings([]byte) error 16 | Parameters() map[string]ModuleParameter 17 | ID() string 18 | Type() ModuleType 19 | Priority() int 20 | } 21 | 22 | type Module interface { 23 | BaseModule 24 | 25 | // Called when the module is disabled. The module can do any cleanup it needs to do here 26 | Disable() error 27 | 28 | // Returns the bot channel that the module has saved 29 | BotChannel() BotChannel 30 | 31 | OnWhisper(event MessageEvent) Actions 32 | OnMessage(event MessageEvent) Actions 33 | OnEventSubNotification(event EventSubNotificationEvent) Actions 34 | } 35 | 36 | type ModuleType uint 37 | 38 | // The order of these values matter. Higher value means higher priority in the "OnModules" function 39 | const ( 40 | ModuleTypeUnsorted ModuleType = iota 41 | ModuleTypeFilter 42 | ) 43 | 44 | type ModuleSpec interface { 45 | ID() string 46 | Name() string 47 | Type() ModuleType 48 | EnabledByDefault() bool 49 | Parameters() map[string]ModuleParameterSpec 50 | 51 | Create(bot BotChannel) Module 52 | 53 | Priority() int 54 | } 55 | 56 | type ModuleParameterSpec func() ModuleParameter 57 | 58 | type ModuleParameter interface { 59 | Description() string 60 | DefaultValue() interface{} 61 | Parse(string) error 62 | SetInterface(interface{}) 63 | Get() interface{} 64 | Link(interface{}) 65 | HasValue() bool 66 | HasBeenSet() bool 67 | } 68 | -------------------------------------------------------------------------------- /pkg/modules/bad_character_filter/m.go: -------------------------------------------------------------------------------- 1 | package bad_character_filter 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/modules" 8 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 9 | "github.com/pajbot/pajbot2/pkg/twitchactions" 10 | ) 11 | 12 | func init() { 13 | modules.Register("bad_character_filter", func() pkg.ModuleSpec { 14 | return modules.NewSpec("bad_character_filter", "Bad character filter", false, newBadCharacterFilter) 15 | }) 16 | } 17 | 18 | type badCharacterFilter struct { 19 | mbase.Base 20 | 21 | badCharacters []rune 22 | } 23 | 24 | func newBadCharacterFilter(b *mbase.Base) pkg.Module { 25 | return &badCharacterFilter{ 26 | Base: *b, 27 | 28 | badCharacters: []rune{'\x01'}, 29 | } 30 | } 31 | 32 | func (m *badCharacterFilter) OnMessage(event pkg.MessageEvent) pkg.Actions { 33 | message := event.Message 34 | 35 | for _, r := range message.GetText() { 36 | for _, badCharacter := range m.badCharacters { 37 | if r == badCharacter { 38 | actions := &twitchactions.Actions{} 39 | actions.Timeout(event.User, 300*time.Second).SetReason("Your message contains a banned character") 40 | return actions 41 | } 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/modules/banned_names/m.go: -------------------------------------------------------------------------------- 1 | package banned_names 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/modules" 8 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 9 | "github.com/pajbot/pajbot2/pkg/twitchactions" 10 | ) 11 | 12 | func init() { 13 | modules.Register("banned_names", func() pkg.ModuleSpec { 14 | // TODO: Make configurable 15 | badUsernames := []*regexp.Regexp{ 16 | regexp.MustCompile(`tos_is_trash\d+`), 17 | regexp.MustCompile(`trash_is_the_tos\d+`), 18 | regexp.MustCompile(`terms_of_service_uncool\d+`), 19 | regexp.MustCompile(`tos_i_love_mods_no_toxic\d+`), 20 | regexp.MustCompile(`^kemper.+`), 21 | regexp.MustCompile(`^pudele\d+`), 22 | regexp.MustCompile(`^ninjal0ver\d+`), 23 | regexp.MustCompile(`^trihard_account_\d+`), 24 | regexp.MustCompile(`^h[il1]erot[il1]tan.+`), 25 | } 26 | 27 | return modules.NewSpec("banned_names", "Banned names", false, func(b *mbase.Base) pkg.Module { 28 | return newBannedNames(b, badUsernames) 29 | }) 30 | }) 31 | } 32 | 33 | type bannedNames struct { 34 | mbase.Base 35 | 36 | badUsernames []*regexp.Regexp 37 | } 38 | 39 | func newBannedNames(b *mbase.Base, badUsernames []*regexp.Regexp) pkg.Module { 40 | return &bannedNames{ 41 | Base: *b, 42 | 43 | badUsernames: badUsernames, 44 | } 45 | } 46 | 47 | func (m bannedNames) OnMessage(event pkg.MessageEvent) pkg.Actions { 48 | user := event.User 49 | 50 | usernameBytes := []byte(user.GetName()) 51 | for _, badUsername := range m.badUsernames { 52 | if badUsername.Match(usernameBytes) { 53 | actions := &twitchactions.Actions{} 54 | actions.Ban(user).SetReason("Ban evasion") 55 | return actions 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/modules/basic_commands.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/commands" 6 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 7 | ) 8 | 9 | func init() { 10 | Register("basic_commands", func() pkg.ModuleSpec { 11 | return &Spec{ 12 | id: "basic_commands", 13 | name: "Basic commands", 14 | maker: newBasicCommandsModule, 15 | enabledByDefault: true, 16 | } 17 | }) 18 | } 19 | 20 | type basicCommandsModule struct { 21 | mbase.Base 22 | 23 | commands pkg.CommandsManager 24 | } 25 | 26 | func newBasicCommandsModule(b *mbase.Base) pkg.Module { 27 | m := &basicCommandsModule{ 28 | Base: *b, 29 | 30 | commands: commands.NewCommands(), 31 | } 32 | 33 | // FIXME 34 | m.Initialize() 35 | 36 | return m 37 | } 38 | 39 | func (m *basicCommandsModule) Initialize() { 40 | m.commands.Register([]string{"!pb2ping"}, commands.NewPing()) 41 | m.commands.Register([]string{"!pb2join"}, commands.NewJoin(m.BotChannel())) 42 | m.commands.Register([]string{"!pb2leave"}, commands.NewLeave(m.BotChannel())) 43 | m.commands.Register([]string{"!pb2module"}, commands.NewModule(m.BotChannel())) 44 | m.commands.Register([]string{"!pb2quit"}, commands.NewQuit(m.BotChannel())) 45 | 46 | m.commands.Register([]string{"!user"}, commands.NewUser(m.BotChannel())) 47 | } 48 | 49 | func (m *basicCommandsModule) OnMessage(event pkg.MessageEvent) pkg.Actions { 50 | return m.commands.OnMessage(event) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/modules/bttv_emote_parser.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/common" 9 | "github.com/pajbot/pajbot2/pkg/emotes" 10 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 11 | ) 12 | 13 | func init() { 14 | Register("bttv_emote_parser", func() pkg.ModuleSpec { 15 | return &Spec{ 16 | id: "bttv_emote_parser", 17 | name: "BTTV emote parser", 18 | 19 | enabledByDefault: true, 20 | 21 | priority: -50000, 22 | 23 | maker: newbttvEmoteParser, 24 | } 25 | }) 26 | } 27 | 28 | type bttvEmoteParser struct { 29 | mbase.Base 30 | 31 | globalEmotes *map[string]common.Emote 32 | } 33 | 34 | func newbttvEmoteParser(b *mbase.Base) pkg.Module { 35 | return &bttvEmoteParser{ 36 | Base: *b, 37 | 38 | globalEmotes: &emotes.GlobalEmotes.Bttv, 39 | } 40 | } 41 | 42 | func (m *bttvEmoteParser) OnMessage(event pkg.MessageEvent) pkg.Actions { 43 | message := event.Message 44 | 45 | parts := strings.FieldsFunc(message.GetText(), func(r rune) bool { 46 | // TODO(pajlada): This needs better testing 47 | return r > 0xFF || unicode.IsSpace(r) || r == '!' || r == '.' || r == '$' || r == '^' || r == '#' || r == '*' || r == '@' || r == ')' || r == '%' || r == '&' || r > 0x7a || r < 0x30 || (r > 0x39 && r < 0x41) || (r > 0x5a && r < 0x61) 48 | }) 49 | emoteCount := make(map[string]*common.Emote) 50 | for _, word := range parts { 51 | if emote, ok := emoteCount[word]; ok { 52 | emote.Count++ 53 | } else if emote, ok := (*m.globalEmotes)[word]; ok { 54 | emoteCount[word] = &emote 55 | } 56 | } 57 | 58 | for _, emote := range emoteCount { 59 | message.AddBTTVEmote(emote) 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/modules/commands/add.go: -------------------------------------------------------------------------------- 1 | package mcommands 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | ) 9 | 10 | type addCmd struct { 11 | m *CommandsModule 12 | } 13 | 14 | func newAddCmd(m *CommandsModule) *addCmd { 15 | return &addCmd{ 16 | m: m, 17 | } 18 | } 19 | 20 | func (c *addCmd) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 21 | if !event.User.IsModerator() { 22 | return twitchactions.Mention(event.User, "no permission") 23 | } 24 | 25 | parts = parts[1:] 26 | 27 | if len(parts) < 2 { 28 | return twitchactions.Mention(event.User, "bad usage") 29 | } 30 | 31 | trigger := parts[0] 32 | response := strings.Join(parts[1:], " ") 33 | 34 | err := c.m.addToDB(trigger, response) 35 | if err != nil { 36 | return twitchactions.Mentionf(event.User, "add error: %s", err) 37 | } 38 | 39 | return twitchactions.Mentionf(event.User, "added command: %s", trigger) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/modules/commands/add_trigger.go: -------------------------------------------------------------------------------- 1 | package mcommands 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | ) 9 | 10 | type cmdAddTrigger struct { 11 | m *CommandsModule 12 | } 13 | 14 | func newCmdAddTrigger(m *CommandsModule) *cmdAddTrigger { 15 | return &cmdAddTrigger{ 16 | m: m, 17 | } 18 | } 19 | 20 | func (c *cmdAddTrigger) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 21 | if !event.User.IsModerator() { 22 | return twitchactions.Mention(event.User, "no permission") 23 | } 24 | 25 | parts = parts[1:] 26 | 27 | if len(parts) < 2 { 28 | return twitchactions.Mention(event.User, "bad usage") 29 | } 30 | 31 | commandID, err := strconv.ParseInt(parts[0], 10, 64) 32 | if err != nil { 33 | return twitchactions.Mentionf(event.User, "add trigger error: %s", err) 34 | } 35 | newTrigger := parts[1] 36 | 37 | err = c.m.addTrigger(commandID, newTrigger) 38 | if err != nil { 39 | return twitchactions.Mentionf(event.User, "add error: %s", err) 40 | } 41 | 42 | return twitchactions.Mentionf(event.User, "added command trigger: %s", newTrigger) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/modules/commands/remove_trigger.go: -------------------------------------------------------------------------------- 1 | package mcommands 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | ) 9 | 10 | type cmdRemoveTrigger struct { 11 | m *CommandsModule 12 | } 13 | 14 | func newCmdRemoveTrigger(m *CommandsModule) *cmdRemoveTrigger { 15 | return &cmdRemoveTrigger{ 16 | m: m, 17 | } 18 | } 19 | 20 | func (c *cmdRemoveTrigger) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 21 | if !event.User.IsModerator() { 22 | return twitchactions.Mention(event.User, "no permission") 23 | } 24 | 25 | parts = parts[1:] 26 | 27 | if len(parts) < 2 { 28 | return twitchactions.Mention(event.User, "bad usage") 29 | } 30 | 31 | commandID, err := strconv.ParseInt(parts[0], 10, 64) 32 | if err != nil { 33 | return twitchactions.Mentionf(event.User, "remove trigger error: %s", err) 34 | } 35 | newTrigger := parts[1] 36 | 37 | err = c.m.removeTrigger(commandID, newTrigger) 38 | if err != nil { 39 | return twitchactions.Mentionf(event.User, "remove error: %s", err) 40 | } 41 | 42 | return twitchactions.Mentionf(event.User, "remove command trigger: %s", newTrigger) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/modules/commands/text.go: -------------------------------------------------------------------------------- 1 | package mcommands 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/twitchactions" 6 | ) 7 | 8 | type textResponseCmd struct { 9 | response string 10 | } 11 | 12 | func newTextResponseCommand(response string) *textResponseCmd { 13 | return &textResponseCmd{ 14 | response: response, 15 | } 16 | } 17 | 18 | func (c *textResponseCmd) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 19 | return twitchactions.Say(c.response) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/modules/custom_commands.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/commands" 6 | ) 7 | 8 | type CustomCommands struct { 9 | botChannel pkg.BotChannel 10 | 11 | server *server 12 | 13 | commands pkg.CommandsManager 14 | } 15 | 16 | func NewCustomCommands() *CustomCommands { 17 | return &CustomCommands{ 18 | server: &_server, 19 | 20 | commands: commands.NewCommands(), 21 | } 22 | } 23 | 24 | // func (m *CustomCommands) RegisterCommand(aliases []string, command pkg.CustomCommand) { 25 | // for _, alias := range aliases { 26 | // m.commands[alias] = command 27 | // } 28 | // } 29 | 30 | // FIXME 31 | // func (m *CustomCommands) OnWhisper(bot pkg.Sender, source pkg.User, message pkg.Message) error { 32 | // return nil 33 | // } 34 | // 35 | // func (m *CustomCommands) OnMessage(bot pkg.Sender, source pkg.Channel, user pkg.User, message pkg.Message, action pkg.Action) error { 36 | // parts := strings.Split(message.GetText(), " ") 37 | // if len(parts) == 0 { 38 | // return nil 39 | // } 40 | // 41 | // if command, ok := m.commands[strings.ToLower(parts[0])]; ok { 42 | // command.Trigger(m.botChannel, parts, source, user, message, action) 43 | // } 44 | // 45 | // return nil 46 | // } 47 | -------------------------------------------------------------------------------- /pkg/modules/datastructures/transparentlist.go: -------------------------------------------------------------------------------- 1 | package datastructures 2 | 3 | import ( 4 | "errors" 5 | 6 | goahocorasick "github.com/anknown/ahocorasick" 7 | ) 8 | 9 | type transparentListRange struct { 10 | Pos int 11 | SkipLength int 12 | } 13 | 14 | type TransparentListSkipRange struct { 15 | skips []transparentListRange 16 | } 17 | 18 | func (t *TransparentListSkipRange) addSkip(r transparentListRange) { 19 | t.skips = append(t.skips, r) 20 | } 21 | 22 | func (t *TransparentListSkipRange) ShouldSkip(index int) int { 23 | if len(t.skips) == 0 { 24 | return 0 25 | } 26 | 27 | skip := t.skips[0] 28 | 29 | if skip.Pos == index { 30 | ret := skip.SkipLength 31 | 32 | t.skips = t.skips[1:] 33 | 34 | return ret 35 | } 36 | 37 | return 0 38 | } 39 | 40 | type TransparentList struct { 41 | m *goahocorasick.Machine 42 | 43 | dict [][]rune 44 | } 45 | 46 | func NewTransparentList() *TransparentList { 47 | t := &TransparentList{} 48 | 49 | t.m = new(goahocorasick.Machine) 50 | 51 | return t 52 | } 53 | 54 | func (t *TransparentList) Add(s string) { 55 | t.dict = append(t.dict, []rune(s)) 56 | } 57 | 58 | func (t *TransparentList) Build() error { 59 | if t.m == nil { 60 | return errors.New("transparent list not initialized properly") 61 | } 62 | 63 | return t.m.Build(t.dict) 64 | } 65 | 66 | func (t *TransparentList) Find(text []rune) (ret TransparentListSkipRange) { 67 | terms := t.m.MultiPatternSearch(text, false) 68 | 69 | for _, t := range terms { 70 | ret.addSkip(transparentListRange{ 71 | Pos: t.Pos, 72 | SkipLength: len(t.Word), 73 | }) 74 | } 75 | 76 | return 77 | } 78 | -------------------------------------------------------------------------------- /pkg/modules/giveaway/giveaway_config.go: -------------------------------------------------------------------------------- 1 | package giveaway 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | ) 9 | 10 | type giveawayCmdConfig struct { 11 | m *giveaway 12 | } 13 | 14 | var ( 15 | giveawayValidKeys = map[string]string{ 16 | "emoteid": "EmoteID", 17 | "emotename": "EmoteName", 18 | } 19 | ) 20 | 21 | func (c *giveawayCmdConfig) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 22 | if !event.User.IsModerator() { 23 | return nil 24 | } 25 | 26 | if len(parts) <= 1 { 27 | return nil 28 | } 29 | 30 | key := strings.ToLower(parts[1]) 31 | var ok bool 32 | key, ok = giveawayValidKeys[key] 33 | if !ok { 34 | return twitchactions.Mentionf(event.User, "%s is not a valid giveaway parameter key", key) 35 | } 36 | 37 | if len(parts) >= 3 { 38 | value := parts[2] 39 | return c.m.SetParameterResponse(key, value, event) 40 | } 41 | 42 | switch key { 43 | case "EmoteID": 44 | return twitchactions.Mentionf(event.User, "emote ID is %s", c.m.emoteID) 45 | case "EmoteName": 46 | return twitchactions.Mentionf(event.User, "emote name is %s", c.m.emoteName) 47 | } 48 | 49 | return twitchactions.Mentionf(event.User, "error: unhandled key '%s' in giveaway_config.go", key) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/modules/giveaway/giveaway_draw.go: -------------------------------------------------------------------------------- 1 | package giveaway 2 | 3 | import ( 4 | "math/rand" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/twitchactions" 8 | ) 9 | 10 | type giveawayCmdDraw struct { 11 | m *giveaway 12 | } 13 | 14 | func (c *giveawayCmdDraw) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 15 | if !event.User.IsModerator() { 16 | // User does not have permission to draw a winner 17 | return nil 18 | } 19 | 20 | if len(c.m.entrants) == 0 { 21 | return twitchactions.Say("No one has joined the giveaway") 22 | } 23 | 24 | winnerIndex := rand.Intn(len(c.m.entrants)) 25 | winnerUsername := c.m.entrants[winnerIndex] 26 | 27 | c.m.entrants = append(c.m.entrants[:winnerIndex], c.m.entrants[winnerIndex+1:]...) 28 | 29 | return twitchactions.Say(winnerUsername + " just won the sub emote giveaway PogChamp") 30 | } 31 | -------------------------------------------------------------------------------- /pkg/modules/giveaway/giveaway_start.go: -------------------------------------------------------------------------------- 1 | package giveaway 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/twitchactions" 6 | ) 7 | 8 | type giveawayCmdStart struct { 9 | m *giveaway 10 | } 11 | 12 | func (c *giveawayCmdStart) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 13 | if !event.User.IsModerator() { 14 | // User does not have permission to start a giveaway 15 | return nil 16 | } 17 | 18 | if c.m.emoteID == "" { 19 | return twitchactions.Mention(event.User, "No emote ID set. Use '!25config emoteid 98374583' to configure this module") 20 | } 21 | 22 | if c.m.emoteName == "" { 23 | return twitchactions.Mention(event.User, "No emote name set. Use '!25config emotename NaM' to configure this module") 24 | } 25 | 26 | if c.m.stopped() { 27 | c.m.start() 28 | return twitchactions.Sayf("Started giveaway, type %s to join the giveaway", c.m.emoteName) 29 | } 30 | 31 | return twitchactions.Say("Giveaway already started") 32 | } 33 | -------------------------------------------------------------------------------- /pkg/modules/giveaway/giveaway_stop.go: -------------------------------------------------------------------------------- 1 | package giveaway 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/twitchactions" 6 | ) 7 | 8 | type giveawayCmdStop struct { 9 | m *giveaway 10 | } 11 | 12 | func (c *giveawayCmdStop) Trigger(parts []string, event pkg.MessageEvent) pkg.Actions { 13 | if !event.User.IsModerator() { 14 | // User does not have permission to stop a giveaway 15 | return nil 16 | } 17 | 18 | if c.m.started() { 19 | c.m.stop() 20 | return twitchactions.Say("Stopped accepting people into the giveaway") 21 | } 22 | 23 | return twitchactions.Say("Giveaway already stopped") 24 | } 25 | -------------------------------------------------------------------------------- /pkg/modules/goodbye.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 8 | ) 9 | 10 | func init() { 11 | Register("goodbye", func() pkg.ModuleSpec { 12 | return &Spec{ 13 | id: "goodbye", 14 | name: "Goodbye", 15 | maker: newGoodbye, 16 | 17 | enabledByDefault: false, 18 | } 19 | }) 20 | } 21 | 22 | type goodbye struct { 23 | mbase.Base 24 | } 25 | 26 | func newGoodbye(b *mbase.Base) pkg.Module { 27 | m := &goodbye{ 28 | Base: *b, 29 | } 30 | 31 | err := m.Listen("on_quit", func() error { 32 | go m.BotChannel().Say("cya lol") 33 | return nil 34 | }, 100) 35 | if err != nil { 36 | log.Println("Error listening to on_quit event:", err) 37 | // FIXME 38 | // return err 39 | } 40 | 41 | return m 42 | } 43 | -------------------------------------------------------------------------------- /pkg/modules/link_filter/link_filter_test.go: -------------------------------------------------------------------------------- 1 | package link_filter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pajbot/pajbot2/pkg/modules" 7 | ) 8 | 9 | func TestLinkFilterUnmatches(t *testing.T) { 10 | spec, ok := modules.GetModuleSpec("link_filter") 11 | if !ok { 12 | t.Fatal("AAAAAA") 13 | } 14 | 15 | m := spec.Create(nil).(*LinkFilter) 16 | 17 | // these strings should not match as links 18 | tests := []string{ 19 | "test.Bb5", 20 | } 21 | 22 | for _, link := range tests { 23 | triggered := m.checkMessage(link) 24 | if triggered { 25 | t.Errorf("%s is seen as a link while it should not be", link) 26 | } 27 | } 28 | } 29 | 30 | func TestLinkFilterMatches(t *testing.T) { 31 | spec, ok := modules.GetModuleSpec("link_filter") 32 | if !ok { 33 | t.Fatal("AAAAAA") 34 | } 35 | 36 | m := spec.Create(nil).(*LinkFilter) 37 | 38 | // these strings should match as links 39 | tests := []string{ 40 | "google.com", 41 | "mylovely.horse", 42 | "clips.twitch.tv", 43 | "google.nl", 44 | "google.co.uk", 45 | "google.fi", 46 | "twitch.tv", 47 | "pajlada.se", 48 | "test.bb", 49 | "https://google.com", 50 | "https://twitter.com", 51 | } 52 | 53 | for _, link := range tests { 54 | triggered := m.checkMessage(link) 55 | if !triggered { 56 | t.Errorf("%s is not seen as a link while it should be", link) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/modules/link_filter/m.go: -------------------------------------------------------------------------------- 1 | package link_filter 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/modules" 9 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 10 | "github.com/pajbot/pajbot2/pkg/twitchactions" 11 | xurls "mvdan.cc/xurls/v2" 12 | ) 13 | 14 | func init() { 15 | modules.Register("link_filter", func() pkg.ModuleSpec { 16 | const regexpModifier = `(\b|$)` 17 | relaxedRegexpStr := xurls.Relaxed().String() 18 | strictRegexpStr := xurls.Strict().String() 19 | 20 | relaxedRegexp := regexp.MustCompile(relaxedRegexpStr + regexpModifier) 21 | relaxedRegexp.Longest() 22 | strictRegexp := regexp.MustCompile(strictRegexpStr + regexpModifier) 23 | strictRegexp.Longest() 24 | 25 | return modules.NewSpec("link_filter", "Link filter", false, func(b *mbase.Base) pkg.Module { 26 | return newLinkFilter(b, relaxedRegexp, strictRegexp) 27 | }) 28 | }) 29 | } 30 | 31 | type LinkFilter struct { 32 | mbase.Base 33 | 34 | relaxedRegexp *regexp.Regexp 35 | strictRegexp *regexp.Regexp 36 | } 37 | 38 | func newLinkFilter(b *mbase.Base, relaxedRegexp, strictRegexp *regexp.Regexp) pkg.Module { 39 | return &LinkFilter{ 40 | Base: *b, 41 | 42 | relaxedRegexp: relaxedRegexp, 43 | strictRegexp: strictRegexp, 44 | } 45 | } 46 | 47 | func (m *LinkFilter) checkMessage(text string) bool { 48 | links := m.relaxedRegexp.FindAllString(text, -1) 49 | return len(links) > 0 50 | } 51 | 52 | func (m LinkFilter) OnMessage(event pkg.MessageEvent) pkg.Actions { 53 | if event.User.IsModerator() { 54 | return nil 55 | } 56 | 57 | if event.User.IsVIP() { 58 | return nil 59 | } 60 | 61 | if m.checkMessage(event.Message.GetText()) { 62 | actions := &twitchactions.Actions{} 63 | actions.Timeout(event.User, 180*time.Second).SetReason("No links allowed") 64 | return actions 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/modules/message_length_limit.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "time" 5 | "unicode/utf8" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 9 | "github.com/pajbot/pajbot2/pkg/twitchactions" 10 | ) 11 | 12 | func init() { 13 | Register("message_length_limit", func() pkg.ModuleSpec { 14 | return &Spec{ 15 | id: "message_length_limit", 16 | name: "Message length limit", 17 | maker: newMessageLengthLimit, 18 | } 19 | }) 20 | } 21 | 22 | var _ pkg.Module = &MessageLengthLimit{} 23 | 24 | type MessageLengthLimit struct { 25 | mbase.Base 26 | } 27 | 28 | func newMessageLengthLimit(b *mbase.Base) pkg.Module { 29 | return &MessageLengthLimit{ 30 | Base: *b, 31 | } 32 | } 33 | 34 | func (m MessageLengthLimit) OnMessage(event pkg.MessageEvent) pkg.Actions { 35 | message := event.Message 36 | 37 | messageLength := utf8.RuneCountInString(message.GetText()) 38 | if messageLength > 140 { 39 | if messageLength > 420 { 40 | return twitchactions.DoTimeout(event.User, 600*time.Second, "Your message is way too long") 41 | } 42 | 43 | return twitchactions.DoTimeout(event.User, 300*time.Second, "Your message is too long, shorten it") 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/modules/nuke/parse.go: -------------------------------------------------------------------------------- 1 | package nuke 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type NukeParameterParser struct { 10 | } 11 | 12 | func (p *NukeParameterParser) ParseNukeParameters(parts []string) (*NukeParameters, error) { 13 | if len(parts) < 3 { 14 | return nil, ErrUsage 15 | } 16 | 17 | phrase := strings.Join(parts[1:len(parts)-2], " ") 18 | var regexPhrase *regexp.Regexp 19 | if strings.HasPrefix(phrase, "/") && strings.HasSuffix(phrase, "/") && len(phrase) >= 2 { 20 | // parse as regex 21 | asd := phrase[1 : len(phrase)-1] 22 | regex, err := regexp.Compile(asd) 23 | if err == nil { 24 | regexPhrase = regex 25 | } 26 | } 27 | 28 | scrollbackLength, err := time.ParseDuration(parts[len(parts)-2]) 29 | if err != nil { 30 | return nil, ErrUsage 31 | } 32 | if scrollbackLength < 0 { 33 | return nil, ErrUsage 34 | } 35 | timeoutDuration, err := time.ParseDuration(parts[len(parts)-1]) 36 | if err != nil { 37 | return nil, ErrUsage 38 | } 39 | if timeoutDuration < 0 { 40 | return nil, ErrUsage 41 | } 42 | 43 | return &NukeParameters{ 44 | Phrase: phrase, 45 | RegexPhrase: regexPhrase, 46 | ScrollbackLength: scrollbackLength, 47 | TimeoutDuration: timeoutDuration, 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/modules/other_commands.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/internal/commands/disconnect" 5 | "github.com/pajbot/pajbot2/internal/commands/getuserid" 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/commands" 8 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 9 | ) 10 | 11 | func init() { 12 | Register("other_commands", func() pkg.ModuleSpec { 13 | return &Spec{ 14 | id: "other_commands", 15 | name: "Other commands", 16 | maker: newOtherCommandsModule, 17 | enabledByDefault: false, 18 | } 19 | }) 20 | } 21 | 22 | type otherCommandsModule struct { 23 | mbase.Base 24 | 25 | commands pkg.CommandsManager 26 | } 27 | 28 | func newOtherCommandsModule(b *mbase.Base) pkg.Module { 29 | m := &otherCommandsModule{ 30 | Base: *b, 31 | 32 | commands: commands.NewCommands(), 33 | } 34 | 35 | // FIXME 36 | m.Initialize() 37 | 38 | return m 39 | } 40 | 41 | func (m *otherCommandsModule) Initialize() { 42 | m.commands.Register([]string{"!userid"}, &getuserid.Command{}) 43 | m.commands.Register([]string{"!username"}, &commands.GetUserName{}) 44 | m.commands.Register([]string{"!pb2points"}, &commands.GetPoints{}) 45 | m.commands.Register([]string{"!pb2roulette"}, &commands.Roulette{}) 46 | m.commands.Register([]string{"!pb2givepoints"}, &commands.GivePoints{}) 47 | m.commands.Register([]string{"!pb2disconnect"}, &disconnect.Command{Bot: m.BotChannel()}) 48 | // m.commands.Register([]string{"!pb2addpoints"}, &commands.AddPoints{}) 49 | // m.commands.Register([]string{"!pb2removepoints"}, &commands.RemovePoints{}) 50 | m.commands.Register([]string{"!roffle", "!join"}, commands.NewRaffle()) 51 | m.commands.Register([]string{"!pb2rank"}, &commands.Rank{}) 52 | m.commands.Register([]string{"!pb2simplify"}, &commands.Simplify{}) 53 | // m.commands.Register([]string{"!timemeout"}, &commands.TimeMeOut{}) 54 | m.commands.Register([]string{"!pb2test"}, &commands.Test{}) 55 | m.commands.Register([]string{"!pb2islive"}, commands.IsLive{}) 56 | } 57 | 58 | func (m *otherCommandsModule) OnMessage(event pkg.MessageEvent) pkg.Actions { 59 | return m.commands.OnMessage(event) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/modules/pajbot1_commands.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/commands" 9 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 10 | ) 11 | 12 | func init() { 13 | Register("pajbot1_commands", func() pkg.ModuleSpec { 14 | return &Spec{ 15 | id: "pajbot1_commands", 16 | name: "pajbot1 commands", 17 | maker: newPajbot1Commands, 18 | } 19 | }) 20 | } 21 | 22 | type Pajbot1Commands struct { 23 | mbase.Base 24 | 25 | commands []*commands.Pajbot1Command 26 | } 27 | 28 | func newPajbot1Commands(b *mbase.Base) pkg.Module { 29 | m := &Pajbot1Commands{ 30 | Base: *b, 31 | } 32 | 33 | m.loadPajbot1Commands() 34 | 35 | return m 36 | } 37 | 38 | func (m *Pajbot1Commands) loadPajbot1Commands() error { 39 | const queryF = `SELECT level, action, command, delay_all, delay_user, enabled, cost, can_execute_with_whisper, sub_only, mod_only, tokens_cost FROM tb_command` 40 | 41 | session := m.OldSession 42 | 43 | rows, err := session.Query(queryF) // GOOD 44 | if err != nil { 45 | return err 46 | } 47 | 48 | defer rows.Close() 49 | 50 | for rows.Next() { 51 | var command commands.Pajbot1Command 52 | err = command.LoadScan(rows) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if !command.Enabled { 58 | continue 59 | } 60 | 61 | if command.PointCost > 0 || command.TokenCost > 0 { 62 | continue 63 | } 64 | 65 | m.commands = append(m.commands, &command) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (m Pajbot1Commands) OnMessage(event pkg.MessageEvent) pkg.Actions { 72 | user := event.User 73 | message := event.Message 74 | 75 | parts := strings.Split(message.GetText(), " ") 76 | if len(parts) == 0 { 77 | return nil 78 | } 79 | 80 | for _, command := range m.commands { 81 | if command.IsTriggered(parts) { 82 | err := command.Trigger(m.BotChannel(), user, parts) 83 | if err != nil { 84 | return nil 85 | } 86 | log.Println("Triggered command!") 87 | log.Println(command.Action) 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/modules/param.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | var nullBuffer = []byte("null") 4 | 5 | type baseParameter struct { 6 | description string 7 | 8 | hasBeenSet bool 9 | } 10 | 11 | func (b baseParameter) HasBeenSet() bool { 12 | return b.hasBeenSet 13 | } 14 | 15 | func (b baseParameter) Description() string { 16 | return b.description 17 | } 18 | 19 | type ParameterSpec struct { 20 | Description string 21 | DefaultValue interface{} 22 | } 23 | -------------------------------------------------------------------------------- /pkg/modules/punisher/timeout.go: -------------------------------------------------------------------------------- 1 | package punisher 2 | 3 | import "time" 4 | 5 | type timeout struct { 6 | received time.Time 7 | end time.Time 8 | 9 | duration int 10 | } 11 | 12 | func (t *timeout) IsSmaller(o *timeout, leniency int) bool { 13 | if o.duration == t.duration { 14 | // We are equal, so we are not smaller XD 15 | return false 16 | } 17 | 18 | if o.duration == 0 { 19 | // Other timeout is a permaban, so we are smaller 20 | return true 21 | } 22 | 23 | return o.duration-t.duration > leniency 24 | } 25 | 26 | func (t *timeout) Seconds() float64 { 27 | return time.Until(t.end).Seconds() 28 | } 29 | -------------------------------------------------------------------------------- /pkg/modules/server.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/common/config" 8 | "github.com/pajbot/pajbot2/pkg/report" 9 | ) 10 | 11 | type server struct { 12 | sql *sql.DB 13 | oldSession *sql.DB 14 | pubSub pkg.PubSub 15 | reportHolder *report.Holder 16 | } 17 | 18 | var _server server 19 | 20 | func InitServer(app pkg.Application, pajbot1Config *config.Pajbot1Config, reportHolder *report.Holder) error { 21 | var err error 22 | 23 | _server.sql = app.SQL() 24 | _server.oldSession, err = sql.Open("mysql", pajbot1Config.SQL.DSN) 25 | if err != nil { 26 | return err 27 | } 28 | _server.pubSub = app.PubSub() 29 | _server.reportHolder = reportHolder 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/modules/spec.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 6 | ) 7 | 8 | type moduleParameterSpec struct { 9 | description string 10 | parameterType string 11 | defaultValue interface{} 12 | } 13 | 14 | type moduleMaker func(b *mbase.Base) pkg.Module 15 | 16 | type Spec struct { 17 | // i.e. "report". This is used in external calls enabling or disabling the module 18 | // the ID is also what's used when storing settings in the database 19 | id string 20 | 21 | // i.e. "Report" 22 | name string 23 | 24 | moduleType pkg.ModuleType 25 | 26 | enabledByDefault bool 27 | 28 | priority int 29 | 30 | parameters map[string]pkg.ModuleParameterSpec 31 | 32 | maker moduleMaker 33 | } 34 | 35 | func NewSpec(id, name string, enabledByDefault bool, maker moduleMaker) *Spec { 36 | return &Spec{ 37 | id: id, 38 | name: name, 39 | enabledByDefault: enabledByDefault, 40 | maker: maker, 41 | } 42 | } 43 | 44 | func (s *Spec) ID() string { 45 | return s.id 46 | } 47 | 48 | func (s *Spec) Name() string { 49 | return s.name 50 | } 51 | 52 | func (s *Spec) Type() pkg.ModuleType { 53 | return s.moduleType 54 | } 55 | 56 | func (s *Spec) SetType(moduleType pkg.ModuleType) { 57 | s.moduleType = moduleType 58 | } 59 | 60 | func (s *Spec) EnabledByDefault() bool { 61 | return s.enabledByDefault 62 | } 63 | 64 | func (s *Spec) Create(bot pkg.BotChannel) pkg.Module { 65 | b := mbase.New(s, bot, _server.sql, _server.oldSession, _server.pubSub, _server.reportHolder) 66 | m := s.maker(&b) 67 | 68 | return m 69 | } 70 | 71 | func (s *Spec) Priority() int { 72 | return s.priority 73 | } 74 | 75 | func (s *Spec) Parameters() map[string]pkg.ModuleParameterSpec { 76 | return s.parameters 77 | } 78 | 79 | func (s *Spec) SetParameters(parameters map[string]pkg.ModuleParameterSpec) { 80 | s.parameters = parameters 81 | } 82 | 83 | var _ pkg.ModuleSpec = &Spec{} 84 | -------------------------------------------------------------------------------- /pkg/modules/system/m.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/nicklaw5/helix/v2" 9 | "github.com/pajbot/pajbot2/pkg" 10 | "github.com/pajbot/pajbot2/pkg/modules" 11 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 12 | "github.com/pajbot/pajbot2/pkg/twitchactions" 13 | ) 14 | 15 | const id = "system" 16 | const name = "System" 17 | 18 | func init() { 19 | modules.Register(id, func() pkg.ModuleSpec { 20 | return modules.NewSpec(id, name, true, newModule) 21 | }) 22 | } 23 | 24 | type module struct { 25 | mbase.Base 26 | } 27 | 28 | func newModule(b *mbase.Base) pkg.Module { 29 | m := &module{ 30 | Base: *b, 31 | } 32 | 33 | return m 34 | } 35 | 36 | func (m *module) OnEventSubNotification(event pkg.EventSubNotificationEvent) pkg.Actions { 37 | switch event.Notification.Subscription.Type { 38 | case helix.EventSubTypeChannelFollow: 39 | var followEvent helix.EventSubChannelFollowEvent 40 | err := json.NewDecoder(bytes.NewReader(event.Notification.Event)).Decode(&followEvent) 41 | if err != nil { 42 | fmt.Println(err) 43 | return nil 44 | } 45 | return twitchactions.Sayf("%s just followed the stream xD", followEvent.UserLogin) 46 | default: 47 | fmt.Println("Got unhandled eventsub notification:", event) 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/modules/test_module.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 6 | ) 7 | 8 | func init() { 9 | Register("test", func() pkg.ModuleSpec { 10 | return &Spec{ 11 | id: "test", 12 | name: "Test", 13 | enabledByDefault: false, 14 | 15 | maker: newTest, 16 | } 17 | }) 18 | } 19 | 20 | type test struct { 21 | mbase.Base 22 | } 23 | 24 | func newTest(b *mbase.Base) pkg.Module { 25 | return &test{ 26 | Base: *b, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/modules/tusecommands/m.go: -------------------------------------------------------------------------------- 1 | package tusecommands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/modules" 9 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 10 | mcommands "github.com/pajbot/pajbot2/pkg/modules/commands" 11 | ) 12 | 13 | const id = "tusecommands" 14 | const name = "Test module - Use commands module" 15 | 16 | func init() { 17 | modules.Register(id, func() pkg.ModuleSpec { 18 | return modules.NewSpec(id, name, false, newModule) 19 | }) 20 | } 21 | 22 | type module struct { 23 | mbase.Base 24 | } 25 | 26 | func (m *module) registerCommand() error { 27 | iCommandsModule, err := m.BotChannel().GetModule("commands") 28 | if err != nil { 29 | return err 30 | } 31 | 32 | commandsModule, ok := iCommandsModule.(*mcommands.CommandsModule) 33 | if !ok { 34 | return errors.New("this module is not a commands module wtf") 35 | } 36 | 37 | fmt.Println("got commands module:", commandsModule) 38 | 39 | return nil 40 | } 41 | 42 | func newModule(b *mbase.Base) pkg.Module { 43 | m := &module{ 44 | Base: *b, 45 | } 46 | 47 | return m 48 | } 49 | -------------------------------------------------------------------------------- /pkg/modules/welcome/m.go: -------------------------------------------------------------------------------- 1 | package welcome 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/modules" 8 | mbase "github.com/pajbot/pajbot2/pkg/modules/base" 9 | ) 10 | 11 | func init() { 12 | modules.Register("welcome", func() pkg.ModuleSpec { 13 | return modules.NewSpec("welcome", "Welcome", false, newWelcome) 14 | }) 15 | } 16 | 17 | type welcome struct { 18 | mbase.Base 19 | } 20 | 21 | func newWelcome(b *mbase.Base) pkg.Module { 22 | m := &welcome{ 23 | Base: *b, 24 | } 25 | 26 | // FIXME 27 | m.Initialize() 28 | 29 | return m 30 | } 31 | 32 | func (m *welcome) Initialize() { 33 | err := m.Listen("on_join", func() error { 34 | go m.BotChannel().Say("pb2 joined") 35 | return nil 36 | }, 100) 37 | if err != nil { 38 | // FIXME 39 | log.Println("ERROR LISTENING TO ON JOIN XD") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/pajbot/pajbot.go: -------------------------------------------------------------------------------- 1 | package pajbot 2 | 3 | // Bot is one instance of the pajbot bot. 4 | // One instance of the pajbot bot can run multiple bots and join multiple chats. 5 | type Bot struct { 6 | } 7 | 8 | func New() Bot { 9 | return Bot{} 10 | } 11 | -------------------------------------------------------------------------------- /pkg/permissions.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type Permission uint64 4 | 5 | const ( 6 | PermissionNone Permission = 0 7 | PermissionReport Permission = 1 << 0 8 | PermissionRaffle Permission = 1 << 1 9 | PermissionAdmin Permission = 1 << 2 10 | PermissionModeration Permission = 1 << 3 11 | PermissionReportAPI Permission = 1 << 4 12 | 13 | PermissionImmuneToMessageLimits = 1 << 5 14 | ) 15 | 16 | // GetPermissionBit converts a string (i.e. "admin") to the binary value it represents. 17 | // 0b100 in this example 18 | func GetPermissionBit(s string) Permission { 19 | if s == "report" { 20 | return PermissionReport 21 | } 22 | if s == "raffle" { 23 | return PermissionRaffle 24 | } 25 | if s == "admin" { 26 | return PermissionAdmin 27 | } 28 | if s == "moderation" { 29 | return PermissionModeration 30 | } 31 | if s == "reportapi" { 32 | return PermissionReportAPI 33 | } 34 | if s == "immunetomessagelimits" { 35 | return PermissionImmuneToMessageLimits 36 | } 37 | 38 | return PermissionNone 39 | } 40 | 41 | // GetPermissionBits converts a list of strings (i.e. ["admin", "raffle"]) to the binary value they represent. 42 | // 0b110 in this example 43 | func GetPermissionBits(permissionNames []string) (permissions Permission) { 44 | for _, permissionName := range permissionNames { 45 | permission := GetPermissionBit(permissionName) 46 | permissions |= permission 47 | } 48 | 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /pkg/pubsub.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "encoding/json" 4 | 5 | type PubSub interface { 6 | Subscribe(source PubSubSource, topic string, parameters json.RawMessage) 7 | Publish(source PubSubSource, topic string, data interface{}) 8 | 9 | HandleSubscribe(connection PubSubSubscriptionHandler, topic string) 10 | HandleJSON(source PubSubSource, bytes []byte) error 11 | } 12 | 13 | // PubSubConnection is an interface where a JSON message can be written to 14 | type PubSubConnection interface { 15 | MessageReceived(source PubSubSource, topic string, bytes []byte) error 16 | } 17 | 18 | type PubSubSubscriptionHandler interface { 19 | ConnectionSubscribed(source PubSubSource, topic string, parameters json.RawMessage) (bool, error) 20 | } 21 | 22 | // PubSubSource is an interface that is responsible for a message being written into pubsub 23 | // This will be responsible for checking authorization 24 | type PubSubSource interface { 25 | IsApplication() bool 26 | Connection() PubSubConnection 27 | AuthenticatedUser() User 28 | } 29 | 30 | type PubSubBan struct { 31 | Channel string 32 | Target string 33 | Reason string 34 | } 35 | 36 | type PubSubTimeout struct { 37 | Channel string 38 | Target string 39 | Reason string 40 | Duration uint32 41 | } 42 | 43 | type PubSubUntimeout struct { 44 | Channel string 45 | Target string 46 | } 47 | 48 | type PubSubUser struct { 49 | ID string 50 | Name string 51 | } 52 | 53 | type PubSubBanEvent struct { 54 | Channel PubSubUser 55 | Target PubSubUser 56 | Source PubSubUser 57 | Reason string 58 | } 59 | 60 | type PubSubTimeoutEvent struct { 61 | Channel PubSubUser 62 | Target PubSubUser 63 | Source PubSubUser 64 | Duration int 65 | Reason string 66 | } 67 | -------------------------------------------------------------------------------- /pkg/pubsub/listener.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | ) 9 | 10 | type Listener struct { 11 | connection pkg.PubSubConnection 12 | subscriptionType SubscriptionType 13 | } 14 | 15 | func (l *Listener) Publish(source pkg.PubSubSource, topic string, data interface{}) error { 16 | bytes, err := json.Marshal(data) 17 | if err != nil { 18 | fmt.Printf("Unable to unmarshal %#v\n", data) 19 | // we don't treat this as an actual error 20 | return nil 21 | } 22 | 23 | err = l.connection.MessageReceived(source, topic, bytes) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/reportaction.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const ( 4 | ReportActionUnknown = 0 5 | ReportActionBan = 1 6 | ReportActionTimeout = 2 7 | ReportActionDismiss = 3 8 | ReportActionUndo = 4 9 | ) 10 | 11 | func GetReportActionByName(actionName string) uint8 { 12 | switch actionName { 13 | case "ban": 14 | return ReportActionBan 15 | case "timeout": 16 | return ReportActionTimeout 17 | case "dismiss": 18 | return ReportActionDismiss 19 | case "undo": 20 | return ReportActionUndo 21 | default: 22 | return ReportActionUnknown 23 | } 24 | } 25 | 26 | func GetReportActionName(action uint8) string { 27 | switch action { 28 | case ReportActionBan: 29 | return "ban" 30 | case ReportActionTimeout: 31 | return "timeout" 32 | case ReportActionDismiss: 33 | return "dismiss" 34 | case ReportActionUndo: 35 | return "undo" 36 | default: 37 | return "unknown" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/stream.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nicklaw5/helix/v2" 7 | ) 8 | 9 | type StreamStatus interface { 10 | Live() bool 11 | StartedAt() time.Time 12 | } 13 | 14 | type Stream interface { 15 | Status() StreamStatus 16 | 17 | // Update forwards the given helix data to its internal status 18 | Update(*helix.Stream) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/streamstore.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type StreamStore interface { 4 | // GetStream ensures that the given Account is being followed & polled for stream status updates 5 | // If the account is already being followed, the Stream that was already stored is returned. 6 | GetStream(Account) Stream 7 | } 8 | -------------------------------------------------------------------------------- /pkg/twitch/account.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | type SimpleAccount struct { 4 | id string 5 | name string 6 | } 7 | 8 | func (a *SimpleAccount) ID() string { 9 | return a.id 10 | } 11 | 12 | func (a *SimpleAccount) Name() string { 13 | return a.name 14 | } 15 | 16 | type TwitchAccount struct { 17 | UserID string 18 | UserName string 19 | } 20 | 21 | func (a *TwitchAccount) ID() string { 22 | return a.UserID 23 | } 24 | 25 | func (a *TwitchAccount) Name() string { 26 | return a.UserName 27 | } 28 | -------------------------------------------------------------------------------- /pkg/twitch/action.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | type Action struct { 4 | } 5 | -------------------------------------------------------------------------------- /pkg/twitch/user.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | func NewUser(id, name string) *User { 10 | return &User{ 11 | id: id, 12 | name: name, 13 | } 14 | } 15 | 16 | type User struct { 17 | id string 18 | name string 19 | } 20 | 21 | func (u *User) ID() string { 22 | return u.id 23 | } 24 | 25 | func (u *User) Name() string { 26 | return u.name 27 | } 28 | 29 | func (u *User) GetID() string { 30 | return u.id 31 | } 32 | 33 | func (u *User) GetName() string { 34 | return u.name 35 | } 36 | 37 | func (u *User) SetName(v string) { 38 | u.name = v 39 | } 40 | 41 | func (u *User) fillIn(userStore pkg.UserStore) error { 42 | if u.id == "" && u.name != "" { 43 | // Has name but not ID 44 | u.id = userStore.GetID(u.name) 45 | if u.id == "" { 46 | return errors.New("unable to get ID") 47 | } 48 | 49 | return nil 50 | } 51 | 52 | if u.name == "" && u.id != "" { 53 | // Has ID but not name 54 | u.name = userStore.GetName(u.id) 55 | if u.name == "" { 56 | return errors.New("unable to get Name") 57 | } 58 | 59 | return nil 60 | } 61 | 62 | if u.name != "" && u.id != "" { 63 | // User already has name and ID, no need to fetch anything 64 | return nil 65 | } 66 | 67 | return errors.New("User missing both ID and Name") 68 | } 69 | 70 | func (u *User) Valid() bool { 71 | return u.name != "" && u.id != "" 72 | } 73 | -------------------------------------------------------------------------------- /pkg/twitchaccount.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type TwitchAccount interface { 4 | ID() string 5 | Name() string 6 | } 7 | -------------------------------------------------------------------------------- /pkg/twitchactions/ban.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | var _ pkg.MuteAction = &Ban{} 10 | 11 | type Ban struct { 12 | mute 13 | 14 | duration time.Duration 15 | } 16 | 17 | func (b Ban) Type() pkg.MuteType { 18 | return pkg.MuteTypePermanent 19 | } 20 | 21 | func (b Ban) Duration() time.Duration { 22 | return time.Duration(0) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/twitchactions/delete.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | type deleteAction struct { 4 | message string 5 | } 6 | 7 | func (m *deleteAction) Message() string { 8 | return m.message 9 | } 10 | -------------------------------------------------------------------------------- /pkg/twitchactions/message.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import "github.com/pajbot/pajbot2/pkg" 4 | 5 | var _ pkg.MessageAction = &Message{} 6 | 7 | type Message struct { 8 | content string 9 | 10 | action bool 11 | } 12 | 13 | func (m *Message) SetAction(v bool) { 14 | m.action = v 15 | } 16 | 17 | func (m Message) Evaluate() string { 18 | if m.action { 19 | return ".me " + m.content 20 | } 21 | 22 | return m.content 23 | } 24 | -------------------------------------------------------------------------------- /pkg/twitchactions/mute.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import "github.com/pajbot/pajbot2/pkg" 4 | 5 | type mute struct { 6 | user pkg.User 7 | reason string 8 | } 9 | 10 | func (m *mute) User() pkg.User { 11 | return m.user 12 | } 13 | 14 | func (m *mute) SetReason(reason string) { 15 | m.reason = reason 16 | } 17 | 18 | func (m mute) Reason() string { 19 | return m.reason 20 | } 21 | -------------------------------------------------------------------------------- /pkg/twitchactions/timeout.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | ) 8 | 9 | var _ pkg.MuteAction = &Timeout{} 10 | 11 | type Timeout struct { 12 | mute 13 | 14 | duration time.Duration 15 | } 16 | 17 | func (t Timeout) Type() pkg.MuteType { 18 | return pkg.MuteTypeTemporary 19 | } 20 | 21 | func (t Timeout) Duration() time.Duration { 22 | return t.duration 23 | } 24 | -------------------------------------------------------------------------------- /pkg/twitchactions/twitchactions.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | ) 9 | 10 | var _ pkg.Actions = &Actions{} 11 | 12 | // Actions lists 13 | type Actions struct { 14 | baseActions 15 | } 16 | 17 | /// HELPER FUNCTIONS FOR SINGLE ACTIONS 18 | 19 | func Mention(user pkg.User, content string) *Actions { 20 | actions := &Actions{} 21 | actions.Mention(user, content) 22 | return actions 23 | } 24 | 25 | func Mentionf(user pkg.User, format string, a ...interface{}) *Actions { 26 | actions := &Actions{} 27 | actions.Mention(user, fmt.Sprintf(format, a...)) 28 | return actions 29 | } 30 | 31 | func Say(content string) *Actions { 32 | actions := &Actions{} 33 | actions.Say(content) 34 | return actions 35 | } 36 | 37 | func Sayf(format string, a ...interface{}) *Actions { 38 | actions := &Actions{} 39 | actions.Say(fmt.Sprintf(format, a...)) 40 | return actions 41 | } 42 | 43 | func DoWhisper(user pkg.User, content string) *Actions { 44 | actions := &Actions{} 45 | actions.Whisper(user, content) 46 | return actions 47 | } 48 | 49 | func DoWhisperf(user pkg.User, format string, a ...interface{}) *Actions { 50 | actions := &Actions{} 51 | actions.Whisper(user, fmt.Sprintf(format, a...)) 52 | return actions 53 | } 54 | 55 | func DoTimeout(user pkg.User, duration time.Duration, reason string) *Actions { 56 | actions := &Actions{} 57 | actions.Timeout(user, duration).SetReason(reason) 58 | return actions 59 | } 60 | -------------------------------------------------------------------------------- /pkg/twitchactions/unmute.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import "github.com/pajbot/pajbot2/pkg" 4 | 5 | type unmute struct { 6 | user pkg.User 7 | muteType pkg.MuteType 8 | } 9 | 10 | func (m *unmute) User() pkg.User { 11 | return m.user 12 | } 13 | 14 | func (m *unmute) Type() pkg.MuteType { 15 | return m.muteType 16 | } 17 | -------------------------------------------------------------------------------- /pkg/twitchactions/whisper.go: -------------------------------------------------------------------------------- 1 | package twitchactions 2 | 3 | import "github.com/pajbot/pajbot2/pkg" 4 | 5 | type Whisper struct { 6 | user pkg.User 7 | content string 8 | } 9 | 10 | func (w Whisper) User() pkg.User { 11 | return w.user 12 | } 13 | 14 | func (w Whisper) Content() string { 15 | return w.content 16 | } 17 | -------------------------------------------------------------------------------- /pkg/user.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type User interface { 4 | // Has channel or global permission 5 | HasPermission(Channel, Permission) bool 6 | 7 | // Has global permission 8 | HasGlobalPermission(Permission) bool 9 | 10 | // Has channel permission 11 | HasChannelPermission(Channel, Permission) bool 12 | 13 | GetName() string 14 | GetDisplayName() string 15 | GetID() string 16 | IsModerator() bool 17 | IsBroadcaster() bool 18 | IsVIP() bool 19 | IsSubscriber() bool 20 | GetBadges() map[string]int 21 | 22 | // Set the ID of this user 23 | // Will return an error if the ID of this user was already set 24 | SetID(string) error 25 | 26 | // Set the Name of this user 27 | // Will return an error if the Name of this user was already set 28 | SetName(string) error 29 | } 30 | -------------------------------------------------------------------------------- /pkg/usercontext.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type UserContext interface { 4 | GetContext(channelID, userID string) []string 5 | 6 | // The message must be preformatted. i.e. [2018-09-13 502:501:201] username: message 7 | AddContext(channelID, userID, message string) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/users/server.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | type server struct { 8 | sql *sql.DB 9 | } 10 | 11 | var _server server 12 | 13 | func InitServer(_sql *sql.DB) { 14 | _server.sql = _sql 15 | } 16 | -------------------------------------------------------------------------------- /pkg/userstore.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/pajbot/utils" 7 | ) 8 | 9 | type UserStoreRequest struct { 10 | ids map[string]bool 11 | names map[string]bool 12 | } 13 | 14 | func NewUserStoreRequest() *UserStoreRequest { 15 | return &UserStoreRequest{ 16 | ids: make(map[string]bool), 17 | names: make(map[string]bool), 18 | } 19 | } 20 | 21 | func (r *UserStoreRequest) AddID(id string) { 22 | r.ids[id] = true 23 | } 24 | 25 | func (r *UserStoreRequest) AddName(name string) { 26 | r.names[name] = true 27 | } 28 | 29 | // Execute returns two values: 30 | // First value: map where key is a user ID pointing at a user name 31 | // Second value: map where key is a user name pointing at a user ID 32 | func (r *UserStoreRequest) Execute(userStore UserStore) (names map[string]string, ids map[string]string) { 33 | var wg sync.WaitGroup 34 | if len(r.ids) > 0 { 35 | wg.Add(1) 36 | go func() { 37 | defer wg.Done() 38 | names = userStore.GetNames(utils.SBKey(r.ids)) 39 | }() 40 | } 41 | 42 | if len(r.names) > 0 { 43 | wg.Add(1) 44 | go func() { 45 | defer wg.Done() 46 | ids = userStore.GetIDs(utils.SBKey(r.names)) 47 | }() 48 | } 49 | 50 | wg.Wait() 51 | 52 | return 53 | } 54 | 55 | type UserStore interface { 56 | // Input: Lowercased twitch usernames 57 | // Returns: user IDs as strings in no specific order, and a bool indicating whether the user needs to exhaust the list first and wait 58 | GetIDs([]string) map[string]string 59 | 60 | GetID(string) string 61 | 62 | GetUserByLogin(string) (User, error) 63 | GetUserByID(string) (User, error) 64 | 65 | GetName(string) string 66 | 67 | // Input: list of twitch IDs 68 | // Returns: map of twitch IDs pointing at twitch usernames 69 | GetNames([]string) map[string]string 70 | 71 | Hydrate([]User) error 72 | } 73 | -------------------------------------------------------------------------------- /pkg/web/controller/admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/pajbot/pajbot2/pkg" 9 | "github.com/pajbot/pajbot2/pkg/users" 10 | "github.com/pajbot/pajbot2/pkg/web/router" 11 | "github.com/pajbot/pajbot2/pkg/web/state" 12 | "github.com/pajbot/pajbot2/pkg/web/views" 13 | ) 14 | 15 | func Load(a pkg.Application) { 16 | router.Get("/admin", handleRoot(a)) 17 | } 18 | 19 | func handleRoot(a pkg.Application) func(w http.ResponseWriter, r *http.Request) { 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | // Step 1: Ensure user is logged in 22 | c := state.Context(w, r) 23 | if c.Session == nil { 24 | views.Render403(w, r) 25 | return 26 | } 27 | 28 | // Step 2: Ensure logged in user has global admin permissions 29 | user := users.NewSimpleTwitchUser(c.Session.TwitchUserID, c.Session.TwitchUserName) 30 | 31 | if !user.HasGlobalPermission(pkg.PermissionAdmin) { 32 | views.Render403(w, r) 33 | return 34 | } 35 | 36 | type BotInfo struct { 37 | Name string 38 | Connected bool 39 | } 40 | 41 | type Extra struct { 42 | Bots []BotInfo 43 | } 44 | 45 | extra := &Extra{} 46 | 47 | for it := a.TwitchBots().Iterate(); it.Next(); { 48 | bot := it.Value() 49 | botInfo := BotInfo{ 50 | Name: bot.TwitchAccount().Name(), 51 | Connected: bot.Connected(), 52 | } 53 | extra.Bots = append(extra.Bots, botInfo) 54 | } 55 | 56 | // TODO: Also fetch all channel admin permissions, and send that along to. 57 | // That would tell the API what channels it can administrate 58 | 59 | extraBytes, _ := json.Marshal(extra) 60 | 61 | err := views.RenderExtra("admin", w, r, extraBytes) 62 | if err != nil { 63 | log.Println("Error rendering admin view:", err) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pkg/web/controller/api/README.md: -------------------------------------------------------------------------------- 1 | # pajbot2 api 2 | First-party API endpoints are registered under this module. These are API modules that are deemed vital for pajbot2. 3 | A short description of each endpoint can be found in each submodules `routes.go` file. 4 | 5 | ## I want to register my own API endpoints 6 | See instructions that I have not yet written but I would like to write. 7 | -------------------------------------------------------------------------------- /pkg/web/controller/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/common/config" 9 | "github.com/pajbot/pajbot2/pkg/web/controller/api/auth" 10 | "github.com/pajbot/pajbot2/pkg/web/controller/api/channel" 11 | "github.com/pajbot/pajbot2/pkg/web/controller/api/report" 12 | "github.com/pajbot/pajbot2/pkg/web/controller/api/webhook" 13 | "github.com/pajbot/pajbot2/pkg/web/router" 14 | ) 15 | 16 | func apiRoot(w http.ResponseWriter, r *http.Request) { 17 | fmt.Fprintf(w, "XD API ROOT") 18 | fmt.Fprintf(w, "\nTODO: Link to api docs") 19 | } 20 | 21 | func Load(a pkg.Application, cfg *config.Config) { 22 | m := router.Subrouter("/api") 23 | 24 | router.RGet(m, "/", apiRoot) 25 | m.HandleFunc("", apiRoot) 26 | 27 | auth.Load(m, a, cfg) 28 | 29 | channel.Load(m) 30 | 31 | report.Load(m) 32 | 33 | webhook.Load(m, cfg) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/web/controller/api/auth/routes.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/pajbot/pajbot2/pkg" 8 | "github.com/pajbot/pajbot2/pkg/common/config" 9 | "github.com/pajbot/pajbot2/pkg/web/controller/api/auth/twitch" 10 | ) 11 | 12 | // Load routes for /api/auth/ 13 | func Load(parent *mux.Router, a pkg.Application, cfg *config.Config) { 14 | m := parent.PathPrefix("/auth").Subrouter() 15 | 16 | // Subroute /api/auth/twitch/ 17 | err := twitch.Load(m, a) 18 | if err != nil { 19 | fmt.Println("Error loading /api/auth/twitch:", err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/web/controller/api/auth/twitch/bot.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/nicklaw5/helix/v2" 7 | "github.com/pajbot/pajbot2/pkg/web/state" 8 | "github.com/pajbot/utils" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | func onBotAuthenticated( 13 | w http.ResponseWriter, r *http.Request, 14 | self *helix.ValidateTokenResponse, oauth2Token *oauth2.Token, stateData *stateData) { 15 | const queryF = ` 16 | INSERT INTO bot (twitch_userid, twitch_username, twitch_access_token, twitch_refresh_token, twitch_access_token_expiry) 17 | VALUES ($1, $2, $3, $4, $5) ON CONFLICT (twitch_userid) 18 | DO 19 | UPDATE 20 | SET 21 | twitch_username = $2, 22 | twitch_access_token = $3, 23 | twitch_refresh_token = $4, 24 | twitch_access_token_expiry = $5` 25 | c := state.Context(w, r) 26 | _, err := c.SQL.Exec(queryF, self.Data.UserID, self.Data.Login, oauth2Token.AccessToken, oauth2Token.RefreshToken, oauth2Token.Expiry) // GOOD 27 | if err != nil { 28 | _ = utils.WebWriteError(w, 500, "Error inserting bot, admin should check console logs NaM") 29 | return 30 | } 31 | 32 | _, _ = w.Write([]byte("Bot added/updated! Restart the bot for the changes to take effect")) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/web/controller/api/auth/twitch/routes.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/pajbot/pajbot2/pkg" 8 | ) 9 | 10 | // Load routes for /api/auth/twitch/ 11 | func Load(parent *mux.Router, a pkg.Application) error { 12 | m := parent.PathPrefix("/twitch").Subrouter() 13 | auths := a.TwitchAuths() 14 | ctx := context.Background() 15 | 16 | // Initialize /api/auth/twitch/bot and /api/auth/twitch/bot/callback routes 17 | initializeOauthRoutes(ctx, m, auths.Bot(), "bot", onBotAuthenticated) 18 | 19 | // Initialize /api/auth/twitch/user and /api/auth/twitch/user/callback routes 20 | initializeOauthRoutes(ctx, m, auths.User(), "user", onUserAuthenticated) 21 | 22 | // Initialize /api/auth/twitch/streamer and /api/auth/twitch/streamer/callback routes 23 | initializeOauthRoutes(ctx, m, auths.Streamer(), "streamer", onStreamerAuthenticated) 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/web/controller/api/auth/twitch/streamer.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/nicklaw5/helix/v2" 7 | "golang.org/x/oauth2" 8 | ) 9 | 10 | func onStreamerAuthenticated( 11 | w http.ResponseWriter, r *http.Request, 12 | self *helix.ValidateTokenResponse, oauth2Token *oauth2.Token, stateData *stateData) { 13 | } 14 | -------------------------------------------------------------------------------- /pkg/web/controller/api/auth/twitch/user.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/nicklaw5/helix/v2" 8 | "github.com/pajbot/pajbot2/pkg/web/state" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | func onUserAuthenticated( 13 | w http.ResponseWriter, r *http.Request, 14 | self *helix.ValidateTokenResponse, oauth2Token *oauth2.Token, stateData *stateData) { 15 | c := state.Context(w, r) 16 | twitchUserName := self.Data.Login 17 | 18 | twitchUserID := c.TwitchUserStore.GetID(twitchUserName) 19 | 20 | if twitchUserID == "" { 21 | // TODO: Implement proper error handling 22 | fmt.Println("Unable to get user id for user", twitchUserName) 23 | return 24 | } 25 | 26 | const queryF = ` 27 | INSERT INTO "user" 28 | (twitch_username, twitch_userid) 29 | VALUES ($1, $2) 30 | ON CONFLICT (twitch_userid) DO UPDATE SET twitch_username=$1 31 | RETURNING id 32 | ` 33 | 34 | var lastInsertID int64 35 | row := c.SQL.QueryRow(queryF, twitchUserName, twitchUserID) // GOOD 36 | err := row.Scan(&lastInsertID) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | fmt.Println("Last insert ID:", lastInsertID) 42 | 43 | sessionID, err := c.CreateSession(lastInsertID) 44 | if err != nil { 45 | panic(err) 46 | } 47 | state.SetSessionCookies(w, sessionID, twitchUserName) 48 | 49 | // TODO: Secure the redirect 50 | if stateData.redirect != "" { 51 | http.Redirect(w, r, stateData.redirect, http.StatusFound) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/web/controller/api/channel/banphrases/list.go: -------------------------------------------------------------------------------- 1 | package banphrases 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/web/state" 8 | "github.com/pajbot/pajbot2/pkg/webutils" 9 | "github.com/pajbot/utils" 10 | ) 11 | 12 | type banphrase struct { 13 | ID string 14 | Enabled bool 15 | Description string 16 | Phrase string 17 | } 18 | 19 | type listResponse struct { 20 | Banphrases []banphrase 21 | ChannelID string 22 | } 23 | 24 | func handleList(w http.ResponseWriter, r *http.Request) { 25 | c := state.Context(w, r) 26 | 27 | if c.Channel == nil { 28 | utils.WebWriteError(w, 400, "Missing channel argument") 29 | return 30 | } 31 | 32 | if !webutils.RequirePermission(w, c, c.Channel, pkg.PermissionModeration) { 33 | return 34 | } 35 | 36 | var response listResponse 37 | 38 | response.ChannelID = c.Channel.GetID() 39 | 40 | const queryF = "SELECT id, enabled, description, phrase FROM banphrase" 41 | 42 | rows, err := c.SQL.Query(queryF) // GOOD 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | defer rows.Close() 48 | 49 | for rows.Next() { 50 | var bp banphrase 51 | if err := rows.Scan(&bp.ID, &bp.Enabled, &bp.Description, &bp.Phrase); err != nil { 52 | panic(err) 53 | } 54 | 55 | response.Banphrases = append(response.Banphrases, bp) 56 | } 57 | 58 | utils.WebWrite(w, response) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/web/controller/api/channel/banphrases/routes.go: -------------------------------------------------------------------------------- 1 | package banphrases 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/pajbot/pajbot2/pkg/web/router" 6 | ) 7 | 8 | // Load routes for /api/channel/:channel_id/banphrases/ 9 | func Load(parent *mux.Router) { 10 | m := parent.PathPrefix("/banphrases").Subrouter() 11 | 12 | router.RGet(m, `/list`, handleList) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/web/controller/api/channel/moderation/routes.go: -------------------------------------------------------------------------------- 1 | package moderation 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/pajbot/pajbot2/pkg/web/router" 6 | ) 7 | 8 | // Load routes for /api/channel/:channel_id/moderation/ 9 | func Load(parent *mux.Router) { 10 | m := parent.PathPrefix("/moderation").Subrouter() 11 | 12 | router.RGet(m, `/latest`, apiChannelModerationLatest) 13 | 14 | router.RGet(m, `/user`, apiUser).Queries("user_id", `{user_id:[0-9]+}`) 15 | router.RGet(m, `/user`, apiUser).Queries("user_name", `{user_name:\w+}`) 16 | router.RGet(m, `/user`, apiUserMissingVariables) 17 | router.RGet(m, `/check_message`, apiCheckMessage).Queries("message", `{message:.+}`) 18 | router.RGet(m, `/check_message`, apiCheckMessageMissingVariables) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/web/controller/api/channel/routes.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/pajbot/pajbot2/pkg/web/controller/api/channel/banphrases" 6 | "github.com/pajbot/pajbot2/pkg/web/controller/api/channel/moderation" 7 | ) 8 | 9 | // Load routes for /api/channel/:channel_id 10 | func Load(parent *mux.Router) { 11 | m := parent.PathPrefix(`/channel/{channel_id:\w+}`).Subrouter() 12 | 13 | // Load subroutes for /api/channel/:channel_id/moderation/ 14 | moderation.Load(m) 15 | 16 | // Load subroutes for /api/channel/:channel_id/banphrases/ 17 | banphrases.Load(m) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/web/controller/api/report/history.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/pajbot/pajbot2/pkg" 9 | "github.com/pajbot/pajbot2/pkg/report" 10 | "github.com/pajbot/pajbot2/pkg/users" 11 | "github.com/pajbot/pajbot2/pkg/web/state" 12 | "github.com/pajbot/utils" 13 | ) 14 | 15 | func apiHistory(w http.ResponseWriter, r *http.Request) { 16 | const queryF = ` 17 | SELECT 18 | id, 19 | channel_id, channel_name, channel_type, 20 | reporter_id, reporter_name, 21 | target_id, target_name, 22 | reason, logs, 23 | time, 24 | handler_id, handler_name, 25 | action, action_duration, 26 | time_handled 27 | FROM 28 | report_history 29 | ORDER BY time_handled DESC 30 | LIMIT 50; 31 | ` 32 | 33 | c := state.Context(w, r) 34 | 35 | if c.Session == nil { 36 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint") 37 | return 38 | } 39 | 40 | user := users.NewSimpleTwitchUser(c.Session.TwitchUserID, c.Session.TwitchUserName) 41 | if user == nil { 42 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint") 43 | return 44 | } 45 | 46 | if !user.HasGlobalPermission(pkg.PermissionReportAPI) { 47 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint!!!") 48 | return 49 | } 50 | 51 | rows, err := c.SQL.Query(queryF) // GOOD 52 | if err != nil { 53 | fmt.Println("error in mysql query apiUser:", err) 54 | utils.WebWriteError(w, 500, "Internal error") 55 | return 56 | } 57 | 58 | defer rows.Close() 59 | 60 | type xd struct { 61 | Reports []report.HistoricReport 62 | } 63 | 64 | var response xd 65 | 66 | for rows.Next() { 67 | var r report.HistoricReport 68 | var logsString string 69 | if err := rows.Scan(&r.ID, &r.Channel.ID, &r.Channel.Name, &r.Channel.Type, &r.Reporter.ID, &r.Reporter.Name, &r.Target.ID, &r.Target.Name, &r.Reason, &logsString, &r.Time, &r.Handler.ID, &r.Handler.Name, &r.Action, &r.ActionDuration, &r.TimeHandled); err != nil { 70 | fmt.Println("error when scanning row:", err) 71 | utils.WebWriteError(w, 500, "Internal error") 72 | return 73 | } 74 | r.Logs = strings.Split(logsString, "\n") 75 | 76 | response.Reports = append(response.Reports, r) 77 | } 78 | 79 | utils.WebWrite(w, response) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/web/controller/api/report/routes.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/pajbot/pajbot2/pkg/web/router" 6 | ) 7 | 8 | // Load routes for /api/report/ 9 | func Load(parent *mux.Router) { 10 | m := parent.PathPrefix("/report").Subrouter() 11 | 12 | router.RGet(m, `/history`, apiHistory) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/web/controller/api/webhook/eventsub.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/nicklaw5/helix/v2" 12 | "github.com/pajbot/pajbot2/pkg" 13 | "github.com/pajbot/pajbot2/pkg/common/config" 14 | "github.com/pajbot/pajbot2/pkg/web/state" 15 | ) 16 | 17 | func apiEventsub(cfg *config.TwitchWebhookConfig) func(w http.ResponseWriter, r *http.Request) { 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | c := state.Context(w, r) 20 | 21 | body, err := io.ReadAll(r.Body) 22 | if err != nil { 23 | log.Println(err) 24 | return 25 | } 26 | defer r.Body.Close() 27 | 28 | if !helix.VerifyEventSubNotification(cfg.Secret, r.Header, string(body)) { 29 | log.Println("No valid signature in subscription message") 30 | return 31 | } 32 | 33 | var notification pkg.TwitchEventSubNotification 34 | err = json.NewDecoder(bytes.NewReader(body)).Decode(¬ification) 35 | if err != nil { 36 | fmt.Println(err) 37 | return 38 | } 39 | 40 | if notification.Challenge != "" { 41 | w.Write([]byte(notification.Challenge)) 42 | return 43 | } 44 | 45 | var botChannel pkg.BotChannel 46 | var channelID string 47 | 48 | switch notification.Subscription.Type { 49 | case helix.EventSubTypeChannelFollow: 50 | fallthrough 51 | case helix.EventSubTypeStreamOnline: 52 | fallthrough 53 | case helix.EventSubTypeStreamOffline: 54 | channelID = notification.Subscription.Condition.BroadcasterUserID 55 | } 56 | 57 | if channelID != "" { 58 | for it := c.Application.TwitchBots().Iterate(); it.Next(); { 59 | bot := it.Value() 60 | if bot == nil { 61 | continue 62 | } 63 | 64 | botChannel = bot.GetBotChannelByID(channelID) 65 | if botChannel == nil { 66 | continue 67 | } 68 | 69 | break 70 | } 71 | } 72 | 73 | if botChannel == nil { 74 | fmt.Println("No bot channel active to handle this request", channelID, notification.Subscription.Type) 75 | fmt.Println(string(notification.Event)) 76 | // No bot channel active to handle this request 77 | return 78 | } 79 | 80 | err = botChannel.HandleEventSubNotification(notification) 81 | if err != nil { 82 | fmt.Println("Error handling eventsub notification:", err) 83 | } 84 | 85 | w.WriteHeader(http.StatusOK) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/web/controller/api/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/pajbot/pajbot2/pkg/common/config" 6 | "github.com/pajbot/pajbot2/pkg/web/router" 7 | ) 8 | 9 | func Load(parent *mux.Router, cfg *config.Config) { 10 | m := parent.PathPrefix("/webhook").Subrouter() 11 | 12 | // NEW AND FRESH AND COOL 13 | router.RPost(m, `/eventsub`, apiEventsub(&cfg.Auth.Twitch.Webhook)) 14 | 15 | router.RPost(m, `/github/{channelID:\w+}`, apiGithub(&cfg.Auth.Github.Webhook)) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/web/controller/banphrases/banphrases.go: -------------------------------------------------------------------------------- 1 | package banphrases 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/pajbot/pajbot2/pkg/web/router" 9 | "github.com/pajbot/pajbot2/pkg/web/views" 10 | ) 11 | 12 | func Load() { 13 | router.Get("/banphrases", handleBanphrases) 14 | } 15 | 16 | func handleBanphrases(w http.ResponseWriter, r *http.Request) { 17 | banphrases := []string{ 18 | "a", "b", "c", 19 | } 20 | extra, _ := json.Marshal(banphrases) 21 | 22 | err := views.RenderExtra("banphrases", w, r, extra) 23 | if err != nil { 24 | log.Println("Error rendering banphrases view:", err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/web/controller/channel/channel.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/pajbot/pajbot2/pkg" 10 | "github.com/pajbot/pajbot2/pkg/common/config" 11 | "github.com/pajbot/pajbot2/pkg/web/router" 12 | "github.com/pajbot/pajbot2/pkg/web/views" 13 | ) 14 | 15 | func Load(a pkg.Application, cfg *config.Config) { 16 | m := router.Subrouter("/c/{channel:[a-zA-Z0-9_]+}") 17 | 18 | router.RGet(m, "/dashboard", handleDashboard(a)) 19 | } 20 | 21 | func handleDashboard(a pkg.Application) func(w http.ResponseWriter, r *http.Request) { 22 | type BotInfo struct { 23 | Name string 24 | Connected bool 25 | } 26 | 27 | type ChannelInfo struct { 28 | ID string 29 | Name string 30 | } 31 | 32 | type Extra struct { 33 | Bots []BotInfo 34 | Channel ChannelInfo 35 | } 36 | 37 | return func(w http.ResponseWriter, r *http.Request) { 38 | extra := &Extra{} 39 | vars := mux.Vars(r) 40 | channel, ok := vars["channel"] 41 | if !ok { 42 | return 43 | } 44 | 45 | for it := a.TwitchBots().Iterate(); it.Next(); { 46 | bot := it.Value() 47 | 48 | bc := bot.GetBotChannel(channel) 49 | if bc == nil { 50 | continue 51 | } 52 | 53 | extra.Channel.ID = bc.Channel().GetID() 54 | extra.Channel.Name = bc.Channel().GetName() 55 | 56 | // fmt.Fprintf(w, "Bot: %s\n", bot.TwitchAccount().ID()) 57 | bi := BotInfo{ 58 | Name: bot.TwitchAccount().Name(), 59 | Connected: bot.Connected(), 60 | } 61 | extra.Bots = append(extra.Bots, bi) 62 | } 63 | 64 | extraBytes, _ := json.Marshal(extra) 65 | 66 | err := views.RenderExtra("dashboard", w, r, extraBytes) 67 | if err != nil { 68 | log.Println("Error rendering dashboard view:", err) 69 | return 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/web/controller/commands.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/pajbot/pajbot2/pkg/commandlist" 10 | "github.com/pajbot/pajbot2/pkg/web/views" 11 | ) 12 | 13 | func handleCommands(w http.ResponseWriter, r *http.Request) { 14 | commands := commandlist.Commands() 15 | bytes, err := json.Marshal(commands) 16 | if err != nil { 17 | fmt.Println("Error:", err) 18 | return 19 | } 20 | err = views.RenderExtra("commands", w, r, bytes) 21 | if err != nil { 22 | log.Println("Error rendering dashboard view:", err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/web/controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/pajbot/pajbot2/pkg" 5 | "github.com/pajbot/pajbot2/pkg/common/config" 6 | "github.com/pajbot/pajbot2/pkg/web/controller/admin" 7 | "github.com/pajbot/pajbot2/pkg/web/controller/api" 8 | "github.com/pajbot/pajbot2/pkg/web/controller/banphrases" 9 | "github.com/pajbot/pajbot2/pkg/web/controller/channel" 10 | "github.com/pajbot/pajbot2/pkg/web/controller/dashboard" 11 | "github.com/pajbot/pajbot2/pkg/web/controller/home" 12 | "github.com/pajbot/pajbot2/pkg/web/controller/logout" 13 | "github.com/pajbot/pajbot2/pkg/web/controller/static" 14 | "github.com/pajbot/pajbot2/pkg/web/controller/ws" 15 | "github.com/pajbot/pajbot2/pkg/web/router" 16 | ) 17 | 18 | func LoadRoutes(a pkg.Application, cfg *config.Config) { 19 | channel.Load(a, cfg) 20 | 21 | dashboard.Load() 22 | home.Load() 23 | api.Load(a, cfg) 24 | static.Load() 25 | ws.Load() 26 | 27 | // /logout 28 | logout.Load() 29 | 30 | // /profile 31 | router.Get("/profile", handleProfile) 32 | 33 | // /banphrases 34 | banphrases.Load() 35 | 36 | // /admin 37 | admin.Load(a) 38 | 39 | // /commands 40 | router.Get("/commands", handleCommands) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/web/controller/dashboard/dashboard.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/pajbot/pajbot2/pkg" 10 | "github.com/pajbot/pajbot2/pkg/web/router" 11 | "github.com/pajbot/pajbot2/pkg/web/state" 12 | "github.com/pajbot/pajbot2/pkg/web/views" 13 | ) 14 | 15 | func Load() { 16 | router.Get("/dashboard", Dashboard) 17 | } 18 | 19 | const dashboardPermissions = pkg.PermissionAdmin | pkg.PermissionReport | pkg.PermissionModeration | pkg.PermissionReportAPI 20 | 21 | func Dashboard(w http.ResponseWriter, r *http.Request) { 22 | c := state.Context(w, r) 23 | 24 | const queryF = ` 25 | SELECT 26 | twitch_user_channel_permission.channel_id, 27 | permissions 28 | FROM 29 | "user" 30 | LEFT JOIN 31 | twitch_user_channel_permission ON "user".twitch_userid="twitch_user_channel_permission".twitch_user_id 32 | WHERE twitch_userid=$1` 33 | 34 | type ChannelInfo struct { 35 | ID string 36 | Name string 37 | } 38 | 39 | type Extra struct { 40 | Channels []ChannelInfo 41 | } 42 | 43 | var extraBytes []byte 44 | 45 | if c.Session != nil { 46 | rows, err := c.SQL.Query(queryF, c.Session.TwitchUserID) 47 | if err != nil { 48 | // TODO: render error page somehow? 49 | fmt.Println("ERROR 1:", err) 50 | return 51 | } 52 | defer rows.Close() 53 | 54 | extra := &Extra{} 55 | 56 | for rows.Next() { 57 | var twitchChannelID string 58 | var permission pkg.Permission 59 | 60 | err := rows.Scan(&twitchChannelID, &permission) 61 | if err != nil { 62 | // TODO: render error page somehow? 63 | fmt.Println("ERROR 2:", err) 64 | return 65 | } 66 | 67 | if (permission & dashboardPermissions) != 0 { 68 | extra.Channels = append(extra.Channels, ChannelInfo{ 69 | ID: twitchChannelID, 70 | Name: c.TwitchUserStore.GetName(twitchChannelID), 71 | }) 72 | } 73 | extraBytes, _ = json.Marshal(extra) 74 | } 75 | } 76 | 77 | err := views.RenderExtra("dashboard", w, r, extraBytes) 78 | if err != nil { 79 | log.Println("Error rendering dashboard view:", err) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/web/controller/home/home.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/pajbot/pajbot2/pkg/web/router" 8 | "github.com/pajbot/pajbot2/pkg/web/views" 9 | ) 10 | 11 | func Load() { 12 | router.Get("/", Home) 13 | router.Get("/home", Home) 14 | } 15 | 16 | func Home(w http.ResponseWriter, r *http.Request) { 17 | err := views.Render("home", w, r) 18 | if err != nil { 19 | log.Println("Error rendering dashboard view:", err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/web/controller/logout/logout.go: -------------------------------------------------------------------------------- 1 | package logout 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/pajbot/pajbot2/pkg/web/router" 8 | "github.com/pajbot/pajbot2/pkg/web/state" 9 | ) 10 | 11 | func Load() { 12 | router.Get("/logout", handleLogout) 13 | } 14 | 15 | func handleLogout(w http.ResponseWriter, r *http.Request) { 16 | c := state.Context(w, r) 17 | if c.SessionID != nil { 18 | sessionID := *c.SessionID 19 | 20 | if state.IsValidSessionID(sessionID) { 21 | // Remove session from database 22 | const queryF = `DELETE FROM user_session WHERE id=$1` 23 | 24 | _, err := c.SQL.Exec(queryF, sessionID) // GOOD 25 | if err != nil { 26 | fmt.Println("Error deleting session ID") 27 | } 28 | } 29 | 30 | state.ClearSessionCookies(w) 31 | } 32 | 33 | // Redirect user 34 | redirectURL := r.FormValue("redirect") 35 | 36 | if redirectURL == "" { 37 | redirectURL = "/" 38 | } 39 | 40 | http.Redirect(w, r, redirectURL, http.StatusFound) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/web/controller/profile.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/pajbot/pajbot2/pkg/web/views" 8 | ) 9 | 10 | func handleProfile(w http.ResponseWriter, r *http.Request) { 11 | err := views.Render("profile", w, r) 12 | if err != nil { 13 | log.Println("Error rendering dashboard view:", err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/web/controller/static/static.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "net/http" 5 | "path/filepath" 6 | 7 | "github.com/pajbot/pajbot2/internal/config" 8 | "github.com/pajbot/pajbot2/pkg/web/router" 9 | ) 10 | 11 | func Load() { 12 | staticPath := filepath.Join(config.WebStaticPath, "static") 13 | 14 | // Serve files statically from ./web/static in /static 15 | router.PathPrefix("/static", http.StripPrefix("/static", http.FileServer(http.Dir(staticPath)))) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/web/controller/ws/hub.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import "fmt" 4 | 5 | // ConnectionHub xD 6 | type ConnectionHub struct { 7 | connections map[*WSConn]bool 8 | broadcast chan *WSMessage 9 | unregister chan *WSConn 10 | register chan *WSConn 11 | } 12 | 13 | // Hub xD 14 | var Hub = ConnectionHub{ 15 | connections: make(map[*WSConn]bool), 16 | broadcast: make(chan *WSMessage), 17 | unregister: make(chan *WSConn), 18 | register: make(chan *WSConn), 19 | } 20 | 21 | func (h *ConnectionHub) run() { 22 | for { 23 | select { 24 | case conn := <-h.register: 25 | fmt.Println("new connection") 26 | h.connections[conn] = true 27 | case conn := <-h.unregister: 28 | if _, ok := h.connections[conn]; ok { 29 | fmt.Println("disconnect") 30 | delete(h.connections, conn) 31 | conn.disconnect() 32 | } 33 | case wsMessage := <-h.broadcast: 34 | fmt.Println("broadcast") 35 | message := wsMessage.Payload.ToJSON() 36 | for conn := range h.connections { 37 | // Figure out if this connection should even be sent this message 38 | if wsMessage.LevelRequired > 0 && (conn.user == nil /* || conn.user.Level < wsMessage.LevelRequired*/) { 39 | // The user did not fulfill the message Level Requirement 40 | fmt.Printf("Not sending %#v to %#v\n", wsMessage, conn) 41 | continue 42 | } 43 | 44 | if wsMessage.MessageType != MessageTypeAll && conn.messageType != MessageTypeAll && wsMessage.MessageType != conn.messageType { 45 | // Invalid message type 46 | fmt.Printf("Not sending %#v to %#v cuz message types differ\n", wsMessage, conn) 47 | continue 48 | } 49 | select { 50 | case conn.send <- message: 51 | default: 52 | // Not sure what this is for 53 | close(conn.send) 54 | delete(h.connections, conn) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | // Broadcast some data to all connections 62 | func (h *ConnectionHub) Broadcast(data *WSMessage) { 63 | h.broadcast <- data 64 | } 65 | -------------------------------------------------------------------------------- /pkg/web/controller/ws/ws.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/gorilla/websocket" 9 | "github.com/pajbot/pajbot2/pkg/web/router" 10 | "github.com/pajbot/pajbot2/pkg/web/state" 11 | ) 12 | 13 | var upgrader = websocket.Upgrader{ 14 | ReadBufferSize: 1024, 15 | WriteBufferSize: 1024, 16 | CheckOrigin: func(r *http.Request) bool { 17 | return true 18 | }, 19 | } 20 | 21 | func Load() { 22 | go Hub.run() 23 | 24 | m := router.Subrouter("/ws") 25 | 26 | router.RHandleFunc(m, `/{type}`, wsHandler) 27 | } 28 | 29 | func wsHandler(w http.ResponseWriter, r *http.Request) { 30 | fmt.Println("ws handler") 31 | vars := mux.Vars(r) 32 | messageTypeString := vars["type"] 33 | messageType := MessageTypeNone 34 | switch messageTypeString { 35 | case "clr": 36 | messageType = MessageTypeCLR 37 | case "dashboard": 38 | messageType = MessageTypeDashboard 39 | } 40 | 41 | if messageType == MessageTypeNone { 42 | fmt.Println("ws handler error") 43 | http.Error(w, "Invalid url. Valid urls: /ws/clr and /ws/dashboard", http.StatusBadRequest) 44 | return 45 | } 46 | 47 | ws, err := upgrader.Upgrade(w, r, nil) 48 | if err != nil { 49 | http.Error(w, "Could not open websocket connection", http.StatusBadRequest) 50 | fmt.Printf("Upgrader error: %v\n", err) 51 | return 52 | } 53 | 54 | c := state.Context(w, r) 55 | 56 | conn := NewWSConn(ws, messageType, c) 57 | 58 | fmt.Println("aaaaaaaaa") 59 | 60 | // Create a custom connection 61 | Hub.register <- conn 62 | conn.onConnected() 63 | } 64 | -------------------------------------------------------------------------------- /pkg/web/controller/ws/wsmessage.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | // MessageType is used to help redirect a message to the proper connections 4 | type MessageType uint8 5 | 6 | // All available MessageTypes 7 | const ( 8 | MessageTypeAll MessageType = iota 9 | MessageTypeNone 10 | MessageTypeCLR 11 | MessageTypeDashboard 12 | ) 13 | 14 | // WSMessage xD 15 | type WSMessage struct { 16 | Channel string 17 | 18 | MessageType MessageType 19 | 20 | // LevelRequired <=0 means the message does not require authentication, otherwise 21 | // authentication is required and the users level must be equal to or above 22 | // the LevelRequired value 23 | LevelRequired int 24 | 25 | Payload *Payload 26 | } 27 | -------------------------------------------------------------------------------- /pkg/web/controller/ws/wspayload.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | ) 7 | 8 | // Payload xD 9 | type Payload struct { 10 | Event string `json:"event"` 11 | Data map[string]interface{} `json:"data"` 12 | } 13 | 14 | // ToJSON creates a json string from the payload 15 | func (p *Payload) ToJSON() (ret []byte) { 16 | ret, err := json.Marshal(p) 17 | if err != nil { 18 | log.Println("Error marshaling payload:", err) 19 | } 20 | return 21 | } 22 | 23 | func createPayload(data []byte) (*Payload, error) { 24 | p := &Payload{} 25 | err := json.Unmarshal(data, p) 26 | if err != nil { 27 | return nil, err 28 | } 29 | return p, nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/web/errors.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "regexp" 4 | 5 | const ( 6 | // ErrInvalidUserName is returned if an invalid username was posted to a router that expected a valid username 7 | ErrInvalidUserName = "invalid username" 8 | ) 9 | 10 | var ( 11 | singleUserName = regexp.MustCompile(`\w+`) 12 | userNameList = regexp.MustCompile(`[\w\,]+`) 13 | ) 14 | 15 | func isValidUserName(input string) bool { 16 | return singleUserName.FindString(input) == input 17 | } 18 | -------------------------------------------------------------------------------- /pkg/web/errors_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "testing" 4 | 5 | func TestIsValidUserName(t *testing.T) { 6 | good := []string{ 7 | "asd", 8 | "pajlada", 9 | "randers", 10 | "karl_kons", 11 | "testaccount_420", 12 | "bajlada", 13 | } 14 | bad := []string{ 15 | "lol hi", 16 | "!@$%!@$!@$!@$", 17 | " pajlada", 18 | } 19 | for _, name := range good { 20 | if !isValidUserName(name) { 21 | t.Fatalf("%s is not considered valid, while it should be", name) 22 | } 23 | } 24 | 25 | for _, name := range bad { 26 | if isValidUserName(name) { 27 | t.Fatalf("%s is considered valid, while it shouldn't be", name) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/web/hooktypes.go: -------------------------------------------------------------------------------- 1 | package web 2 | -------------------------------------------------------------------------------- /pkg/web/payloads.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | type user struct { 4 | Name string `json:"name"` 5 | DisplayName string `json:"display_name"` 6 | Points int64 `json:"points"` 7 | Level int64 `json:"level"` 8 | TotalMessageCount int64 `json:"total_message_count"` 9 | OnlineMessageCount int64 `json:"online_message_count"` 10 | OfflineMessageCount int64 `json:"offline_message_count"` 11 | LastSeen string `json:"last_seen"` 12 | LastActive string `json:"last_active"` 13 | } 14 | 15 | type customPayload struct { 16 | data map[string]interface{} 17 | } 18 | 19 | func (p *customPayload) Add(key string, value interface{}) { 20 | if p.data == nil { 21 | p.data = make(map[string]interface{}) 22 | } 23 | p.data[key] = value 24 | } 25 | -------------------------------------------------------------------------------- /pkg/web/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // r will most likely need to be mutex locked 11 | 12 | var ( 13 | r *mux.Router 14 | ) 15 | 16 | func init() { 17 | r = mux.NewRouter() 18 | } 19 | 20 | func Subrouter(path string) *mux.Router { 21 | return r.PathPrefix(path).Subrouter() 22 | } 23 | 24 | func RGet(R *mux.Router, path string, handler http.HandlerFunc) *mux.Route { 25 | return R.HandleFunc(path, handler).Methods("GET") 26 | } 27 | 28 | func RPost(R *mux.Router, path string, handler http.HandlerFunc) *mux.Route { 29 | return R.HandleFunc(path, handler).Methods("POST") 30 | } 31 | 32 | func Get(path string, handler http.HandlerFunc) { 33 | RGet(r, path, handler) 34 | } 35 | 36 | func PathPrefix(path string, handler http.Handler) { 37 | r.PathPrefix(path).Handler(handler) 38 | } 39 | 40 | func RHandleFunc(R *mux.Router, path string, handler http.HandlerFunc) { 41 | R.HandleFunc(path, handler) 42 | } 43 | 44 | func HandleFunc(path string, handler http.HandlerFunc) { 45 | RHandleFunc(r, path, handler) 46 | } 47 | 48 | func Instance() *mux.Router { 49 | return r 50 | } 51 | 52 | func Debug() { 53 | r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { 54 | t, err := route.GetPathTemplate() 55 | if err != nil { 56 | return err 57 | } 58 | fmt.Println(t) 59 | return nil 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/web/state/sessionstore.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const SessionIDCookie = "pb2sessionid" 11 | 12 | type SessionStore struct { 13 | } 14 | 15 | func getCookie(r *http.Request, cookieName string) *string { 16 | c, _ := r.Cookie(cookieName) 17 | if c == nil { 18 | return nil 19 | } 20 | 21 | return &c.Value 22 | } 23 | 24 | func IsValidSessionID(sessionID string) bool { 25 | // TODO: Make this more sophisticated 26 | return sessionID != "" 27 | } 28 | 29 | func SetSessionCookies(w http.ResponseWriter, sessionID string, twitchUserName string) { 30 | sessionIDCookie := &http.Cookie{ 31 | Name: SessionIDCookie, 32 | Value: sessionID, 33 | Path: "/", 34 | } 35 | http.SetCookie(w, sessionIDCookie) 36 | 37 | userNameCookie := &http.Cookie{ 38 | Name: "pb2username", 39 | Value: twitchUserName, 40 | Path: "/", 41 | } 42 | http.SetCookie(w, userNameCookie) 43 | } 44 | 45 | func ClearSessionCookies(w http.ResponseWriter) { 46 | sessionIDCookie := &http.Cookie{ 47 | Name: SessionIDCookie, 48 | Value: "", 49 | Path: "/", 50 | 51 | Expires: time.Unix(0, 0), 52 | } 53 | http.SetCookie(w, sessionIDCookie) 54 | 55 | userNameCookie := &http.Cookie{ 56 | Name: "pb2username", 57 | Value: "", 58 | Path: "/", 59 | 60 | Expires: time.Unix(0, 0), 61 | } 62 | http.SetCookie(w, userNameCookie) 63 | } 64 | 65 | func (s *SessionStore) Get(db *sql.DB, sessionID string) *Session { 66 | const queryF = `SELECT user_session.user_id, "user".twitch_userid, "user".twitch_username FROM user_session INNER JOIN "user" ON "user".id=user_session.user_id WHERE user_session.id=$1` 67 | 68 | var userID uint64 69 | var twitchUserID string 70 | var twitchUserName string 71 | 72 | err := db.QueryRow(queryF, sessionID).Scan(&userID, &twitchUserID, &twitchUserName) // GOOD 73 | switch { 74 | case err == sql.ErrNoRows: 75 | // invalid session ID 76 | return nil 77 | case err != nil: 78 | fmt.Println("SQL Error:", err) 79 | // some query or mysql error occurred 80 | return nil 81 | default: 82 | return &Session{ 83 | UserID: userID, 84 | TwitchUserID: twitchUserID, 85 | TwitchUserName: twitchUserName, 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/handlers" 9 | "github.com/pajbot/pajbot2/pkg/common/config" 10 | "github.com/pajbot/pajbot2/pkg/web/router" 11 | ) 12 | 13 | func Run(cfg *config.WebConfig) { 14 | fmt.Printf("Starting web on host %s\n", cfg.Host) 15 | corsObj := handlers.AllowedOrigins([]string{"*"}) 16 | err := http.ListenAndServe(cfg.Host, handlers.CORS(corsObj)(router.Instance())) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/webutils/webutils.go: -------------------------------------------------------------------------------- 1 | package webutils 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/pajbot/pajbot2/pkg" 7 | "github.com/pajbot/pajbot2/pkg/users" 8 | "github.com/pajbot/pajbot2/pkg/web/state" 9 | "github.com/pajbot/utils" 10 | ) 11 | 12 | func RequirePermission(w http.ResponseWriter, c state.State, channel pkg.Channel, permission pkg.Permission) bool { 13 | if c.Session == nil { 14 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint") 15 | return false 16 | } 17 | 18 | user := users.NewSimpleTwitchUser(c.Session.TwitchUserID, c.Session.TwitchUserName) 19 | if user == nil { 20 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint") 21 | return false 22 | } 23 | 24 | if channel != nil { 25 | if user.HasPermission(channel, permission) { 26 | return true 27 | } 28 | } else { 29 | if user.HasGlobalPermission(permission) { 30 | return true 31 | } 32 | } 33 | 34 | utils.WebWriteError(w, 400, "Not authorized to view this endpoint!!!") 35 | return false 36 | } 37 | -------------------------------------------------------------------------------- /resources/testfiles/config1.json: -------------------------------------------------------------------------------- 1 | { 2 | "pass": "oauth:xD", 3 | "nick": "twitch_username", 4 | "channels": [ 5 | "pajlada", 6 | "nuuls", 7 | "forsenlol" 8 | ], 9 | "redis_host": "localhost:6379", 10 | "sql_dsn": "pajbot2:password@tcp(localhost:3306)/pajbot2_test", 11 | "broker_host": "localhost:7353", 12 | "broker_pass": "test" 13 | } 14 | -------------------------------------------------------------------------------- /resources/testfiles/config2_invalidjson.json: -------------------------------------------------------------------------------- 1 | { 2 | "pass": "oauth:xD", 3 | "nick": "twitch_username", 4 | "channels": [ 5 | "pajlada", 6 | "nuuls", 7 | "forsenlol", 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = ["all", "-ST1000", "-ST1003", "-U1000"] 2 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "honnef.co/go/tools/cmd/staticcheck" 7 | ) 8 | -------------------------------------------------------------------------------- /utils/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | basedir="$(cd "$(dirname "$0")" >/dev/null 2>&1 && pwd)" 6 | 7 | if [ -z "$git_release" ]; then 8 | git_release=$(git describe --exact 2>/dev/null || echo "") 9 | fi 10 | if [ -z "$git_release" ]; then 11 | git_release="git" 12 | fi 13 | if [ -z "$git_hash" ]; then 14 | git_hash=$(git rev-parse --short HEAD) 15 | fi 16 | if [ -z "$git_branch" ]; then 17 | git_branch=$(git rev-parse --abbrev-ref HEAD) 18 | fi 19 | 20 | >&2 echo " * Building pajbot2 with the following flags: git_release=$git_release, git_hash=$git_hash, git_branch=$git_branch" 21 | 22 | cd "$basedir/../web" && npm i && npm run build 23 | 24 | go build -ldflags "\ 25 | -X \"main.buildTime=$(date +%Y-%m-%dT%H:%M:%S%:z)\" \ 26 | -X \"main.buildRelease=$git_release\" \ 27 | -X \"main.buildHash=$git_hash\" \ 28 | -X \"main.buildBranch=$git_branch\" \ 29 | " \ 30 | "$@" \ 31 | -o "$basedir/../cmd/bot/" \ 32 | "$basedir/../cmd/bot/" 33 | -------------------------------------------------------------------------------- /utils/createdb.sh: -------------------------------------------------------------------------------- 1 | echo "enter root password" 2 | echo "CREATE DATABASE pajbot2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;GRANT ALL PRIVILEGES ON pajbot2.* TO 'pajbot2'@'localhost' IDENTIFIED BY 'password';USE pajbot2;CREATE TABLE pb_command (triggers VARCHAR(512));" | mysql -uroot -p 3 | -------------------------------------------------------------------------------- /utils/docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ ! -d utils/docker ]; then 4 | echo "This script needs to be called from the root folder, i.e. ./utils/docker/build.sh" 5 | exit 1 6 | fi 7 | 8 | IMAGE_NAME=pajbot2:latest 9 | 10 | echo docker build --pull -t "$IMAGE_NAME" . 11 | -------------------------------------------------------------------------------- /utils/docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | PBUID=$(id -u pajbot) 3 | PBGID=$(id -g pajbot) 4 | 5 | if [[ -z "${PBUID}${PBGID}" ]]; then 6 | echo 'pajbot user not detected.' 7 | exit 1 8 | fi 9 | 10 | if [ ! -f /opt/pajbot/configs/pajbot2.json ]; then 11 | echo "No config file /opt/pajbot/configs/pajbot2.json found." 12 | exit 1 13 | fi 14 | 15 | echo docker run \ 16 | --name pajbot2 \ 17 | --network host \ 18 | --restart unless-stopped \ 19 | -d \ 20 | -v /opt/pajbot/configs/pajbot2.json:/app/cmd/bot/config.json \ 21 | -v /var/run/postgresql:/var/run/postgresql:ro \ 22 | -v /etc/localtime:/etc/localtime:ro \ 23 | -u "$PBUID":"$PBGID" \ 24 | pajbot2:latest 25 | -------------------------------------------------------------------------------- /utils/findlibcoreclr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | get_libcoreclr_path() { 4 | LIBCORECLR_PATH="$(dotnet --list-runtimes | grep Microsoft.NETCore.App | tail -1 | awk '{gsub(/\[|\]/, "", $3); print $3 "/" $2}')" 5 | if [ -z "$LIBCORECLR_PATH" ]; then 6 | echo "" 7 | else 8 | echo $LIBCORECLR_PATH 9 | fi 10 | } 11 | 12 | if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 13 | LIBCORECLR_PATH="$(get_libcoreclr_path)" 14 | if [ -z "$LIBCORECLR_PATH" ]; then 15 | echo "Unable to find path to libcoreclr. Ensure dotnet is installed" 16 | exit 1 17 | fi 18 | 19 | echo "Found libcoreclr.so at $LIBCORECLR_PATH" 20 | echo "To run the bot with csharp modules enabled, build the bot with '-tags csharp' and set the path with LIBCOREFOLDER=$LIBCORECLR_PATH" 21 | fi 22 | -------------------------------------------------------------------------------- /utils/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Compile C++ "coreruncommon" lib 6 | cd 3rdParty/MessageHeightTwitch/c-interop 7 | g++ -c coreruncommon.cpp --std=c++14 8 | ar rvs libcoreruncommon.a coreruncommon.o 9 | 10 | # Compile C# library 11 | cd ../ 12 | dotnet publish --configuration Release -o build/ 13 | 14 | cp charmap.bin.gz build/*.dll ../../cmd/bot/ 15 | -------------------------------------------------------------------------------- /utils/mkmig.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | migrate -database mysql://pajbot2:password@/pajbot2_test -verbose create -ext sql -seq -dir migrations $1 4 | -------------------------------------------------------------------------------- /utils/test-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | go test ./... 3 | -------------------------------------------------------------------------------- /utils/upmig.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | COMMAND=${1:-up} 4 | 5 | echo $COMMAND 6 | 7 | $GOPATH/bin/migrate -verbose -database mysql://root:penis123@/pajbot2_test -path ./migrations ${COMMAND} 8 | -------------------------------------------------------------------------------- /web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # The index.html file is generated by webpack 2 | /views/index.html 3 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | static 3 | -------------------------------------------------------------------------------- /web/.prettierrc.toml: -------------------------------------------------------------------------------- 1 | trailingComma = "es5" 2 | singleQuote = true 3 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pajbot2-web", 3 | "scripts": { 4 | "build": "node_modules/.bin/webpack --mode production", 5 | "watch": "node_modules/.bin/webpack --watch --mode development", 6 | "check_formatting": "prettier --check .", 7 | "reformat": "prettier --write ." 8 | }, 9 | "dependencies": { 10 | "@babel/plugin-proposal-class-properties": "^7.18.6", 11 | "react": "^19.0.0", 12 | "react-dom": "^19.1.0" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "^7.27.1", 16 | "@babel/preset-env": "^7.27.2", 17 | "@babel/preset-react": "^7.27.1", 18 | "babel-loader": "^10.0.0", 19 | "clean-webpack-plugin": "^4.0.0", 20 | "css-loader": "^7.1.2", 21 | "html-webpack-plugin": "^5.6.3", 22 | "mini-css-extract-plugin": "^2.9.2", 23 | "prettier": "^3.5.3", 24 | "sass": "^1.89.0", 25 | "sass-loader": "^16.0.5", 26 | "style-loader": "^4.0.0", 27 | "webpack": "^5.99.8", 28 | "webpack-cli": "^6.0.1" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/pajbot/pajbot2/issues" 32 | }, 33 | "homepage": "https://github.com/pajbot/pajbot2", 34 | "license": "MIT", 35 | "engines": { 36 | "node": ">=14" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import Dashboard from './js/Dashboard'; 4 | import Banphrases from './js/Banphrases'; 5 | import Admin from './js/Admin'; 6 | import Commands from './js/Commands'; 7 | import Menu from './js/Menu'; 8 | import ThemeLoader from './js/ThemeLoader'; 9 | import ThemeProvider from './js/ThemeProvider'; 10 | import './scss/app.scss'; 11 | 12 | const menuEl = document.getElementById('menu'); 13 | const dashboard = document.getElementById('dashboard'); 14 | const banphrases = document.getElementById('banphrases'); 15 | const admin = document.getElementById('admin'); 16 | const commands = document.getElementById('commands'); 17 | 18 | function App() { 19 | return ( 20 | 21 | 22 | 23 | {dashboard && } 24 | {banphrases && } 25 | {admin && } 26 | {commands && } 27 | 28 | ); 29 | } 30 | 31 | const root = ReactDOM.createRoot(document.getElementById('app')); 32 | root.render(); 33 | -------------------------------------------------------------------------------- /web/src/js/Admin.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import WebSocketHandler from './WebSocketHandler'; 3 | 4 | export default class Admin extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | let extra = JSON.parse(props.element.getAttribute('data-extra')); 9 | 10 | this.state = { 11 | bots: extra.Bots, 12 | }; 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 |

Admin

19 |
20 | Authenticate as bot -{' '} 21 | 22 | Use if you need to reauthenticate a bot below, or add a new one. You 23 | should probably copy-paste the link into incognito mode so you can 24 | log in as your bot account 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {this.state.bots.map((bot, index) => ( 36 | 37 | 38 | 39 | 40 | ))} 41 | 42 |
NameConnected
{bot.Name}{bot.Connected ? 'Yes' : 'No'}
43 |
44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/src/js/Commands.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import WebSocketHandler from './WebSocketHandler'; 3 | 4 | export default class Commands extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | let extra = JSON.parse(props.element.getAttribute('data-extra')); 9 | 10 | this.state = { 11 | commands: extra, 12 | }; 13 | 14 | console.log(this.state); 15 | } 16 | 17 | render() { 18 | return ( 19 |
20 |

Commands

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {this.state.commands.map((command, index) => ( 30 | 31 | 32 | 33 | 34 | ))} 35 | 36 |
NameDescription
{command.Name}{command.Description}
37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/src/js/LogInButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { isLoggedIn, logIn, logOut, loggedInUsername } from './auth'; 4 | import { readCookie } from './cookie'; 5 | 6 | export default class LogInButton extends Component { 7 | constructor(props) { 8 | super(props); 9 | } 10 | 11 | render() { 12 | return ( 13 |
14 | 17 |   18 | 25 | 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/src/js/Menu.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import LogInButton from './LogInButton'; 3 | import ThemeSwitcher from './ThemeSwitcher'; 4 | import { isLoggedIn } from './auth'; 5 | 6 | export default class Menu extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.menuItems = [ 11 | { 12 | link: '/', 13 | name: 'Home', 14 | requireLogin: false, 15 | }, 16 | { 17 | link: '/admin', 18 | name: 'Admin', 19 | requireLogin: true, 20 | }, 21 | { 22 | link: '/dashboard', 23 | name: 'Dashboard', 24 | requireLogin: true, 25 | }, 26 | ]; 27 | } 28 | 29 | render() { 30 | return ( 31 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/src/js/ThemeContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ThemeContext = React.createContext(); 4 | -------------------------------------------------------------------------------- /web/src/js/ThemeLoader.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { ThemeContext } from './ThemeContext'; 4 | 5 | export default class ThemeLoader extends Component { 6 | render() { 7 | return ( 8 | 9 | {({ theme }) => } 10 | 11 | ); 12 | } 13 | 14 | themePath = (t) => { 15 | return '/static/themes/' + t + '/bootstrap.min.css'; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /web/src/js/ThemeProvider.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { ThemeContext } from './ThemeContext'; 4 | 5 | import { createCookie, readCookie } from './cookie'; 6 | 7 | export default class ThemeProvider extends Component { 8 | validThemes = ['Light', 'Dark']; 9 | 10 | constructor(props) { 11 | super(props); 12 | 13 | let savedTheme = readCookie('currentTheme'); 14 | 15 | if (!savedTheme || this.validThemes.indexOf(savedTheme) === -1) { 16 | savedTheme = 'Dark'; 17 | } 18 | 19 | this.state = { 20 | theme: savedTheme, 21 | }; 22 | } 23 | 24 | render() { 25 | return ( 26 | 33 | {this.props.children} 34 | 35 | ); 36 | } 37 | 38 | setTheme = (t) => { 39 | this.setState({ 40 | theme: t, 41 | }); 42 | 43 | // Save theme 44 | createCookie('currentTheme', t, 3600); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /web/src/js/ThemeSwitcher.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import ThemeLoader from './ThemeLoader'; 4 | 5 | import { ThemeContext } from './ThemeContext'; 6 | 7 | export default class ThemeSwitcher extends Component { 8 | render() { 9 | return ( 10 | 11 | {({ theme, validThemes, setTheme }) => ( 12 |
13 |
14 | 25 |
29 | {validThemes.map((theme, index) => ( 30 | setTheme(theme)} 35 | > 36 | {theme} 37 | 38 | ))} 39 |
40 |
41 |
42 | )} 43 |
44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/src/js/auth.js: -------------------------------------------------------------------------------- 1 | import { readCookie } from './cookie'; 2 | 3 | export function loggedInUsername() { 4 | return readCookie('pb2username') || '???'; 5 | } 6 | 7 | export function isLoggedIn() { 8 | return readCookie('pb2sessionid') !== null; 9 | } 10 | 11 | export function logIn() { 12 | window.location.href = 13 | '/api/auth/twitch/user?redirect=' + window.location.pathname; 14 | } 15 | 16 | export function logOut() { 17 | window.location.href = '/logout?redirect=' + window.location.pathname; 18 | } 19 | -------------------------------------------------------------------------------- /web/src/js/cookie.js: -------------------------------------------------------------------------------- 1 | // taken from https://gist.github.com/thoov/984751 2 | export function createCookie(name, value, days) { 3 | if (days) { 4 | var date = new Date(); 5 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 6 | var expires = '; expires=' + date.toGMTString(); 7 | } else { 8 | var expires = ''; 9 | } 10 | document.cookie = name + '=' + value + expires + '; path=/'; 11 | } 12 | 13 | export function readCookie(name) { 14 | var nameEQ = name + '='; 15 | var ca = document.cookie.split(';'); 16 | for (var i = 0; i < ca.length; i++) { 17 | var c = ca[i]; 18 | while (c.charAt(0) == ' ') { 19 | c = c.substring(1, c.length); 20 | } 21 | if (c.indexOf(nameEQ) == 0) { 22 | return c.substring(nameEQ.length, c.length); 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | 29 | export function eraseCookie(name) { 30 | createCookie(name, '', -1); 31 | } 32 | -------------------------------------------------------------------------------- /web/src/scss/app.scss: -------------------------------------------------------------------------------- 1 | @use 'vendor/reset'; 2 | 3 | @use 'variables/colors'; 4 | @use 'variables/spacing'; 5 | 6 | @use 'tools/mixins'; 7 | 8 | @use 'base/body'; 9 | @use 'base/buttons'; 10 | @use 'base/links'; 11 | 12 | @use 'modules/dashboard'; 13 | -------------------------------------------------------------------------------- /web/src/scss/base/body.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Helvetica, Arial, sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /web/src/scss/base/buttons.scss: -------------------------------------------------------------------------------- 1 | .btn-twitch { 2 | background-color: #6441a4 !important; 3 | color: #fff !important; 4 | 5 | &:hover { 6 | background-color: rgb(80, 52, 131); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/src/scss/base/links.scss: -------------------------------------------------------------------------------- 1 | @use '../variables/colors'; 2 | 3 | a { 4 | color: colors.$flat-blue; 5 | 6 | &:hover { 7 | cursor: pointer !important; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /web/src/scss/modules/dashboard.scss: -------------------------------------------------------------------------------- 1 | @use '../variables/spacing'; 2 | 3 | .dashboard { 4 | padding: spacing.$spacing-XXL; 5 | 6 | .reports { 7 | margin-top: spacing.$spacing-XL; 8 | 9 | .report { 10 | min-height: 100px; 11 | -webkit-transition: all 500ms cubic-bezier(0.25, 0.1, 0.25, 1); 12 | -moz-transition: all 500ms cubic-bezier(0.25, 0.1, 0.25, 1); 13 | -o-transition: all 500ms cubic-bezier(0.25, 0.1, 0.25, 1); 14 | transition: all 500ms cubic-bezier(0.25, 0.1, 0.25, 1); /* ease (default) */ 15 | 16 | -webkit-transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); 17 | -moz-transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); 18 | -o-transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); 19 | transition-timing-function: cubic-bezier( 20 | 0.25, 21 | 0.1, 22 | 0.25, 23 | 1 24 | ); /* ease (default) */ 25 | 26 | .removing { 27 | height: 0px; 28 | opacity: 0; 29 | } 30 | 31 | .reporter, 32 | .target { 33 | font-weight: 800; 34 | } 35 | 36 | .reason { 37 | font-style: italic; 38 | } 39 | 40 | pre.logs { 41 | font-family: sans-serif; 42 | } 43 | } 44 | } 45 | 46 | .results.loading { 47 | opacity: 0.5; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/scss/tools/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin box-shadow($level) { 2 | @if $level == 1 { 3 | box-shadow: 4 | 0 1px 3px rgba(0, 0, 0, 0.12), 5 | 0 1px 2px rgba(0, 0, 0, 0.24); 6 | } @else if $level == 2 { 7 | box-shadow: 8 | 0 3px 6px rgba(0, 0, 0, 0.16), 9 | 0 3px 6px rgba(0, 0, 0, 0.23); 10 | } @else if $level == 3 { 11 | box-shadow: 12 | 0 10px 20px rgba(0, 0, 0, 0.19), 13 | 0 6px 6px rgba(0, 0, 0, 0.23); 14 | } @else if $level == 4 { 15 | box-shadow: 16 | 0 14px 28px rgba(0, 0, 0, 0.25), 17 | 0 10px 10px rgba(0, 0, 0, 0.22); 18 | } @else if $level == 5 { 19 | box-shadow: 20 | 0 19px 38px rgba(0, 0, 0, 0.3), 21 | 0 15px 12px rgba(0, 0, 0, 0.22); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/scss/variables/colors.scss: -------------------------------------------------------------------------------- 1 | $grey-light: #616161; 2 | $grey-medium: #424242; 3 | $grey-dark: #212121; 4 | 5 | $flat-red: #e74c3c; 6 | $flat-blue: #3498db; 7 | 8 | $twitch-purple: #6441a4; 9 | -------------------------------------------------------------------------------- /web/src/scss/variables/spacing.scss: -------------------------------------------------------------------------------- 1 | $spacing-S: 2px; 2 | $spacing-M: 5px; 3 | $spacing-L: 8px; 4 | $spacing-XL: 10px; 5 | $spacing-XXL: 15px; 6 | $spacing-3XL: 20px; 7 | -------------------------------------------------------------------------------- /web/src/scss/vendor/reset.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | 95 | /* HTML5 display-role reset for older browsers */ 96 | 97 | article, 98 | aside, 99 | details, 100 | figcaption, 101 | figure, 102 | footer, 103 | header, 104 | hgroup, 105 | menu, 106 | nav, 107 | section { 108 | display: block; 109 | } 110 | 111 | body { 112 | line-height: 1; 113 | } 114 | 115 | ol, 116 | ul { 117 | list-style: none; 118 | } 119 | 120 | blockquote, 121 | q { 122 | quotes: none; 123 | } 124 | 125 | blockquote { 126 | &:before, 127 | &:after { 128 | content: ''; 129 | content: none; 130 | } 131 | } 132 | 133 | q { 134 | &:before, 135 | &:after { 136 | content: ''; 137 | content: none; 138 | } 139 | } 140 | 141 | table { 142 | border-collapse: collapse; 143 | border-spacing: 0; 144 | } 145 | -------------------------------------------------------------------------------- /web/views/403.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | pajbot2 - 403 4 | 5 | 6 |

403 - Forbidden

7 |
8 | You do not have access to view this page. You might need to log in? Press 9 | back in your browser! 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /web/views/admin.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 | {{end}} 4 | -------------------------------------------------------------------------------- /web/views/banphrases.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 | {{end}} 4 | -------------------------------------------------------------------------------- /web/views/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dashboard 5 | 6 | 7 | 12 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | {{template "content" .}} 31 | 32 | 33 | -------------------------------------------------------------------------------- /web/views/commands.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 | {{end}} 4 | -------------------------------------------------------------------------------- /web/views/dashboard.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
7 | {{end}} 8 | -------------------------------------------------------------------------------- /web/views/home.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} XD {{end}} 2 | -------------------------------------------------------------------------------- /web/views/profile.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} profile lol {{end}} 2 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const BUILD_DIR = path.resolve(__dirname, './static/build'); 8 | 9 | module.exports = { 10 | entry: './src/index.jsx', 11 | output: { 12 | path: BUILD_DIR, 13 | filename: '[name].[contenthash].js', 14 | publicPath: '/static/build', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.jsx$/, 20 | exclude: /node_modules/, 21 | use: { 22 | loader: 'babel-loader', 23 | }, 24 | }, 25 | { 26 | test: /\.scss$/, 27 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 28 | }, 29 | ], 30 | }, 31 | resolve: { 32 | extensions: ['.js', '.jsx'], 33 | }, 34 | plugins: [ 35 | new CleanWebpackPlugin(), 36 | new MiniCssExtractPlugin({ 37 | filename: '[name].[contenthash].css', 38 | }), 39 | new HtmlWebpackPlugin({ 40 | filename: path.join(__dirname, './views/index.html'), 41 | template: path.join(__dirname, './views/base.html'), 42 | }), 43 | ], 44 | }; 45 | --------------------------------------------------------------------------------