├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── docker.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── readme │ ├── live-event.png │ ├── rank-joining.gif │ ├── update-event.png │ └── vod-event.png ├── build.gradle.kts ├── containers ├── docker-compose.yml ├── minimal │ └── Dockerfile └── normal │ └── Dockerfile ├── example-config.json ├── example-docker-compose.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── guides ├── HOW_TO_CREATE_A_BOT.md ├── HOW_TO_CREATE_A_TWITCH_APP.md └── HOW_TO_CREATE_A_WEBHOOK.md ├── settings.gradle └── src ├── main ├── kotlin │ ├── javax │ │ └── naming │ │ │ └── NamingException.kt │ └── strumbot │ │ ├── ActivityService.kt │ │ ├── Configuration.kt │ │ ├── FixedSizeMap.kt │ │ ├── WebhookAppender.kt │ │ ├── WebhookService.kt │ │ ├── http.kt │ │ ├── i18n.kt │ │ ├── main.kt │ │ ├── twitch.kt │ │ ├── utils.kt │ │ └── watcher.kt └── resources │ ├── logback.xml │ └── strumbot │ ├── de.properties │ └── en.properties └── scripts ├── docker-setup.bat ├── docker-setup.sh ├── run.bat └── run.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: minndevelopment 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Java CI with Gradle 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repo 13 | uses: actions/checkout@v3 14 | - name: Setup Java 15 | uses: actions/setup-java@v2 16 | with: 17 | distribution: temurin 18 | java-version: 11 19 | - name: Grant execute permission for gradlew 20 | run: chmod +x gradlew 21 | - name: Build with Gradle 22 | uses: gradle/gradle-build-action@v2.4.2 23 | with: 24 | arguments: clean build test release 25 | gradle-version: wrapper 26 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | paths: 7 | - 'build.gradle.kts' 8 | - '.github/workflows/docker.yml' 9 | 10 | jobs: 11 | push_to_registry: 12 | name: Push Docker image to Docker Hub 13 | runs-on: ubuntu-latest 14 | environment: DockerDeploy 15 | steps: 16 | - name: Check out the repo 17 | uses: actions/checkout@v3 18 | - name: Setup Java 19 | uses: actions/setup-java@v2 20 | with: 21 | distribution: temurin 22 | java-version: 11 23 | - name: Grant execute permission for gradlew 24 | run: chmod +x gradlew 25 | - name: Build with Gradle 26 | uses: gradle/gradle-build-action@v2.4.2 27 | with: 28 | arguments: clean release 29 | gradle-version: wrapper 30 | - name: Log in to Docker Hub 31 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 32 | with: 33 | username: minnced 34 | password: ${{ secrets.DOCKER_TOKEN }} 35 | - name: Build 36 | working-directory: containers 37 | run: docker compose build 38 | - name: Push to Docker Hub 39 | working-directory: containers 40 | run: docker compose push 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | build/ 3 | .gradle/ 4 | .idea/ 5 | gradle.properties 6 | docker-compose.yml 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019-present Florian Spieß and the Strumbot Contributors 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [live-event]: https://raw.githubusercontent.com/MinnDevelopment/strumbot/master/assets/readme/live-event.png 2 | [update-event]: https://raw.githubusercontent.com/MinnDevelopment/strumbot/master/assets/readme/update-event.png 3 | [vod-event]: https://raw.githubusercontent.com/MinnDevelopment/strumbot/master/assets/readme/vod-event.png 4 | [rank-joining]: https://raw.githubusercontent.com/MinnDevelopment/strumbot/master/assets/readme/rank-joining.gif 5 | [example-config]: https://github.com/MinnDevelopment/strumbot/blob/master/example-config.json 6 | 7 | [ ![docker-pulls](https://img.shields.io/docker/pulls/minnced/strumbot?logo=docker&logoColor=white) ](https://hub.docker.com/r/minnced/strumbot) 8 | [ ![](https://img.shields.io/docker/image-size/minnced/strumbot/1.3.6-min?logo=docker&logoColor=white) ](https://hub.docker.com/layers/minnced/strumbot/1.3.6-min/images/sha256-8ee42948585d4cf869633de6cb9a713c131e81e68cb26fe8d94f576ba7a85601) 9 | [ ![release](https://img.shields.io/github/v/tag/minndevelopment/strumbot) ](https://github.com/MinnDevelopment/strumbot/releases/latest) 10 | 11 | # Strumbot 12 | 13 | A Twitch Stream Notification Bot. This will send notifications to a webhook in your Discord server when the subscribed streamer goes live or changes their game. 14 | 15 | ## Requirements 16 | 17 | - JDK 11 or better 18 | - Stable Internet 19 | 20 | ## Configurations 21 | 22 | The configuration file must be called `config.json` and has to be in the working directory. An example configuration can be found in [`example-config.json`][example-config]. 23 | Anything marked with **(optional)** can be set to `null` to be disabled. 24 | 25 | ### Logger 26 | 27 | In this section you can configure some custom logging pattern and the minimum logging level to show in your Discord Webhook. 28 | By default, this will only log for messages with level WARN or ERROR. 29 | 30 | - `level` The severity level at which to start logging (INFO > WARN > ERROR) 31 | - `pattern` The custom logback pattern to use 32 | 33 | The webhook URL is configured in `discord.logging` below. 34 | 35 | ### Discord 36 | 37 | This section of the configuration contains settings for the discord side of the bot such as role names and webhook URLs. 38 | Note that the bot uses global role cache, independent of servers, and it is recommended to only have the bot account in one server. 39 | 40 | If you don't know how to create a discord bot and get access to the token: [How to make a discord bot](https://github.com/MinnDevelopment/strumbot/blob/master/guides/HOW_TO_CREATE_A_BOT.md) 41 | 42 | - `token` The discord bot token 43 | - `stream_notifications` The webhook URL to send stream updates to (see [How to create a webhook](https://github.com/MinnDevelopment/strumbot/blob/master/guides/HOW_TO_CREATE_A_WEBHOOK.md)) 44 | - `role_name` Configuration of `type`->`role` to change the default names of the update roles (empty value `""` disables the role, and removes the role mention from notifications) 45 | - `enabled_events` Array of events to publish to the `stream_notifications` webhook 46 | - `logging` Optional webhook URL for errors and warnings printed at runtime (omit or null to disable) 47 | - `show_notify_hints` Whether to show a hint in the embed footer about the `/notify` command 48 | 49 | The roles used for updates can be managed by the bot with the `/notify role: ` command. 50 | This command will automatically assign the role to the user. 51 | 52 | For example, with the configuration `"live": "Stream is Live"` the bot will accept the command `/notify role: live` and assign/remove the role `Stream is Live` for the user. 53 | These commands are *ephemeral*, which means they only show up to the user who invokes them. This way you can use them anywhere without having any clutter in chat! 54 | 55 | ![rank-joining.gif][rank-joining] 56 | 57 | 58 | #### Events 59 | 60 | ![vod-event.png][vod-event] 61 | 62 | - [`live`][live-event] When the streamer goes live 63 | - [`update`][update-event] When the streamer changes the current game 64 | - [`vod`][vod-event] When the streamer goes offline (includes vod timestamps for game changes) 65 | 66 | ### Twitch 67 | 68 | This configuration section contains required information to track the stream status. 69 | 70 | If you don't know how to make a twitch application and access the client_id: [How to make a twitch app](https://github.com/MinnDevelopment/strumbot/blob/master/guides/HOW_TO_CREATE_A_TWITCH_APP.md) 71 | 72 | - `offline_grace_period` Minutes to wait before firing a VOD event after channel appears offline (Default: 2) 73 | - `top_clips` The maximum number of top clips to show in the vod event (0 <= x <= 5) 74 | - `client_id` The twitch application's client_id 75 | - `client_secret` The twitch application's client_secret 76 | - `user_login` The username of the tracked streamer 77 | 78 | The `offline_grace_period` is an engineering parameter which is helpful to handle cases where streams temporarily appear offline due to outages or otherwise unwanted connection issues. 79 | 80 | ### Example 81 | 82 | ```json 83 | { 84 | "discord": { 85 | "token": "NjUzMjM1MjY5MzM1NjQ2MjA4.*******.*******", 86 | "stream_notifications": "https://discord.com/api/webhooks/*******/******", 87 | "logging": null, 88 | "show_notify_hints": true, 89 | "role_name": { 90 | "live": "live", 91 | "vod": "vod", 92 | "update": "update" 93 | }, 94 | "enabled_events": ["live", "update", "vod"] 95 | }, 96 | "twitch": { 97 | "top_clips": 5, 98 | "offline_grace_period": 2, 99 | "client_id": "*******", 100 | "client_secret": "*******", 101 | "user_login": ["Elajjaz", "Distortion2"] 102 | } 103 | } 104 | ``` 105 | 106 | ## Setup 107 | 108 | Currently, I only provide 2 setups. Either [docker](https://hub.docker.com) or through a script. 109 | I'm open for pull requests that introduce more or better setups. 110 | 111 | ### Docker 112 | 113 | The image is hosted at [docker hub](https://hub.docker.com/r/minnced/strumbot). 114 | 115 | 1. Open a terminal in the directory of your choice (which includes the `config.json`!) 116 | 1. Pull the image with `docker pull minnced/strumbot:%VERSION%` (Replace `%VERSION%` with the version here: [latest release](https://github.com/MinnDevelopment/strumbot/releases/latest)) 117 | 1. Change the configuration in `config.json` 118 | 1. Create and start a container with this command: 119 | ```sh 120 | docker run -d \ 121 | -v ./config.json:/etc/strumbot/config.json \ 122 | --name strumbot \ 123 | --restart unless-stopped \ 124 | minnced/strumbot:%VERSION% 125 | ``` 126 | 127 | ### Script 128 | 129 | 1. Download the zip archive from the [latest release](https://github.com/MinnDevelopment/strumbot/releases/latest) 130 | 1. Unzip and open the resulting directory in a terminal of your choice 131 | 1. Change the configuration in `config.json` 132 | 1. Run the script for your current platform: 133 | 1. Windows: `run.bat` 134 | 1. Linux: `run.sh` 135 | -------------------------------------------------------------------------------- /assets/readme/live-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinnDevelopment/strumbot/3c978fc85bdf0bc7bdaf41c23802154ec5eed19a/assets/readme/live-event.png -------------------------------------------------------------------------------- /assets/readme/rank-joining.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinnDevelopment/strumbot/3c978fc85bdf0bc7bdaf41c23802154ec5eed19a/assets/readme/rank-joining.gif -------------------------------------------------------------------------------- /assets/readme/update-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinnDevelopment/strumbot/3c978fc85bdf0bc7bdaf41c23802154ec5eed19a/assets/readme/update-event.png -------------------------------------------------------------------------------- /assets/readme/vod-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinnDevelopment/strumbot/3c978fc85bdf0bc7bdaf41c23802154ec5eed19a/assets/readme/vod-event.png -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | 4 | plugins { 5 | id("com.github.johnrengelman.shadow") version "7.1.1" 6 | kotlin("jvm") version "1.6.10" 7 | application 8 | } 9 | 10 | application { 11 | mainClass.set("strumbot.Main") 12 | } 13 | 14 | group = "dev.minn" 15 | version = "1.3.6" 16 | 17 | repositories { 18 | mavenLocal() // caching optimization 19 | mavenCentral() // everything else 20 | maven("https://m2.dv8tion.net/releases") // jda 21 | maven("https://jitpack.io") // jda-reactor and slash commands 22 | } 23 | 24 | dependencies { 25 | implementation("net.dv8tion:JDA:4.+") { 26 | exclude(module="opus-java") 27 | } 28 | 29 | implementation("com.github.minndevelopment:jda-ktx:34b55c0") 30 | implementation("ch.qos.logback:logback-classic:1.2.8") 31 | implementation("com.squareup.okhttp3:okhttp:4.9.3") 32 | 33 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0") 34 | implementation(kotlin("stdlib-jdk8")) 35 | } 36 | 37 | val clean by tasks 38 | val build by tasks 39 | val compileKotlin: KotlinCompile by tasks 40 | val shadowJar: ShadowJar by tasks 41 | 42 | compileKotlin.kotlinOptions.apply { 43 | jvmTarget = "11" 44 | freeCompilerArgs = listOf( 45 | "-Xjvm-default=all", // use default methods in interfaces 46 | "-Xlambdas=indy" // use invokedynamic lambdas instead of synthetic classes 47 | ) 48 | } 49 | 50 | build.dependsOn(shadowJar) 51 | 52 | fun setupScript(path: String) { 53 | val file = File(path) 54 | file.writeText(file.readText() 55 | .replace("%NAME%", "strumbot.jar") 56 | .replace("%VERSION%", version.toString())) 57 | file.setExecutable(true) 58 | file.setReadable(true) 59 | } 60 | 61 | tasks.create("release") { 62 | group = "build" 63 | shadowJar.mustRunAfter(clean) 64 | dependsOn(shadowJar) 65 | dependsOn(clean) 66 | 67 | from(shadowJar.archiveFile.get()) 68 | from("src/scripts") 69 | from("example-config.json") 70 | val output = "$buildDir/release/strumbot" 71 | into("$output/") 72 | doFirst { File(output).delete() } 73 | doLast { 74 | setupScript("$output/run.bat") 75 | setupScript("$output/run.sh") 76 | setupScript("$output/docker-setup.sh") 77 | setupScript("$output/docker-setup.bat") 78 | val archive = File("$output/${shadowJar.archiveFileName.get()}") 79 | archive.renameTo(File("$output/strumbot.jar")) 80 | val config = File("$output/example-config.json") 81 | config.renameTo(File("$output/config.json")) 82 | 83 | File("containers/docker-compose.yml").let { file -> 84 | file.writeText( 85 | file.readText() 86 | .replace( 87 | Regex("(minnced/strumbot):.+?(-min)?(\\s+)"), 88 | "\$1:$version\$2\$3" 89 | ) 90 | ) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /containers/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.2" 2 | 3 | services: 4 | strumbot-min: 5 | image: minnced/strumbot:1.3.6-min 6 | build: 7 | context: .. 8 | dockerfile: containers/minimal/Dockerfile 9 | strumbot: 10 | image: minnced/strumbot:1.3.6 11 | build: 12 | context: .. 13 | dockerfile: containers/normal/Dockerfile 14 | -------------------------------------------------------------------------------- /containers/minimal/Dockerfile: -------------------------------------------------------------------------------- 1 | # Preprocessing a minimal JRE distribution 2 | FROM alpine as packager 3 | 4 | ENV JAVA_MINIMAL="/opt/java-minimal" 5 | 6 | RUN apk add openjdk17-jdk 7 | RUN apk add openjdk17-jmods 8 | RUN apk add binutils 9 | 10 | RUN jlink \ 11 | --verbose \ 12 | --add-modules java.base,java.logging,java.xml,jdk.crypto.ec \ 13 | --compress 2 \ 14 | --strip-debug \ 15 | --no-header-files \ 16 | --no-man-pages \ 17 | --output "$JAVA_MINIMAL" 18 | 19 | # Build our image with the minimal JRE 20 | FROM alpine 21 | 22 | WORKDIR /opt/strumbot 23 | 24 | ENV JAVA_HOME=/opt/java-minimal 25 | ENV PATH="$PATH:$JAVA_HOME/bin" 26 | 27 | COPY build/release/strumbot/strumbot.jar . 28 | 29 | COPY --from=packager "$JAVA_HOME" "$JAVA_HOME" 30 | 31 | RUN apk --no-cache add ca-certificates 32 | 33 | CMD [ \ 34 | "java", \ 35 | "-Xmx256m", \ 36 | "-XX:+ShowCodeDetailsInExceptionMessages", \ 37 | "-XX:+CrashOnOutOfMemoryError", \ 38 | "-jar", \ 39 | "strumbot.jar" \ 40 | ] 41 | -------------------------------------------------------------------------------- /containers/normal/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | WORKDIR /opt/strumbot 4 | 5 | COPY build/release/strumbot/strumbot.jar . 6 | 7 | RUN apk --no-cache add openjdk17-jre-headless 8 | RUN apk --no-cache add ca-certificates 9 | 10 | CMD [ "java", "-Xmx256m", "-XX:+ShowCodeDetailsInExceptionMessages", "-XX:+CrashOnOutOfMemoryError", "-jar", "strumbot.jar" ] 11 | -------------------------------------------------------------------------------- /example-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "level": "info" 4 | }, 5 | "discord": { 6 | "token": "NjUzMjM1MjY5MzM1NjQ2MjA4.*******.*******", 7 | "stream_notifications": "https://discord.com/api/webhooks/*******/******", 8 | "logging": null, 9 | "show_notify_hints": true, 10 | "role_name": { 11 | "live": "live", 12 | "vod": "vod", 13 | "update": "update" 14 | }, 15 | "enabled_events": ["live", "update", "vod"] 16 | }, 17 | "twitch": { 18 | "top_clips": 5, 19 | "offline_grace_period": 2, 20 | "client_id": "*******", 21 | "client_secret": "*******", 22 | "user_login": ["Elajjaz"] 23 | } 24 | } -------------------------------------------------------------------------------- /example-docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.2" 2 | 3 | services: 4 | strumbot: 5 | image: minnced/strumbot:1.3.3-min 6 | volumes: 7 | - ./config.json:/etc/strumbot/config.json 8 | restart: unless-stopped 9 | container_name: strumbot 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinnDevelopment/strumbot/3c978fc85bdf0bc7bdaf41c23802154ec5eed19a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /guides/HOW_TO_CREATE_A_BOT.md: -------------------------------------------------------------------------------- 1 | # How to create a bot 2 | 3 | To use strumbot you need a discord bot and the authorization token. A bot can be created from the [Developer Dashboard][1]. 4 | 5 | 1. Create an Application 6 | 1. Open the **BOT** tab 7 | 1. Click **Add Bot** 8 | 1. You should see a token section, click **Copy** 9 | 1. Insert the token in your `config.json` for `discord.token`. 10 | 1. Open the **OAuth2** tab 11 | 1. Click the `bot` and `applications.commands` checkbox in the scopes 12 | 1. Check the permissions `Send Messages`, `View Channels`, and `Manage Roles` 13 | 1. Copy the link and open it in a new tab 14 | 1. Select the server of your choice and authorize the application 15 | 1. You are done! The bot should now be ready to use. 16 | 17 | [1]: https://discordapp.com/developers/applications 18 | -------------------------------------------------------------------------------- /guides/HOW_TO_CREATE_A_TWITCH_APP.md: -------------------------------------------------------------------------------- 1 | # How to create a twtich app 2 | 3 | The twitch app provides a `client_id` which is required to access stream information. 4 | 5 | 1. Open the developer dashboard [here][1] 6 | 1. Click **Register Your Application** 7 | 1. Insert a unique name for your application 8 | 1. Set your OAuth redirect URL to `http://localhost` (this is irrelevant) 9 | 1. Set the category to **Chat Bot** (also rather irrelevant for our use) 10 | 1. Click **Create** and **Manage** on the created application 11 | 1. Copy the **Client ID** and insert it into your `config.json` under `twitch.client_id` 12 | 1. You are done! 13 | 14 | 15 | [1]: https://dev.twitch.tv/console/apps 16 | -------------------------------------------------------------------------------- /guides/HOW_TO_CREATE_A_WEBHOOK.md: -------------------------------------------------------------------------------- 1 | # How to create a webhook (discord) 2 | 3 | To use the twitch notifications in your Discord you have to first create a **Webhook** in the channel you want the notifications to happen in: 4 | 5 | 1. Open the channel settings 6 | 1. Open the **integrations** tab 7 | 1. Click **Create Webhook** (or **View Webhooks** > **New Webhook**) 8 | 1. Change the name to **Twitch Notifications** so you know what it was created for 9 | 1. Click **Copy Webhook URL** to get the URL for your configuration 10 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'strumbot' 2 | 3 | -------------------------------------------------------------------------------- /src/main/kotlin/javax/naming/NamingException.kt: -------------------------------------------------------------------------------- 1 | /////////////////////////////////////////////////////////////////////////////////////////////////// 2 | /// This file is only here such that we can exclude java.naming from our JRE distribution. /// 3 | /// The exception is required for compatibility with logback-classic and is otherwise unused. /// 4 | /////////////////////////////////////////////////////////////////////////////////////////////////// 5 | 6 | 7 | 8 | 9 | @file:JvmName("NamingException") 10 | package javax.naming 11 | 12 | class NamingException constructor(explanation: String?) : Throwable(explanation) { 13 | constructor() : this(null) 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/ActivityService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import net.dv8tion.jda.api.JDA 20 | import net.dv8tion.jda.api.entities.Activity 21 | import java.util.concurrent.CopyOnWriteArrayList 22 | import kotlin.time.Duration.Companion.seconds 23 | 24 | class ActivityService(private val jda: JDA) { 25 | private val activities = CopyOnWriteArrayList() 26 | private var currentIndex = 0 27 | 28 | fun addActivity(activity: Activity) { 29 | activities.add(activity) 30 | } 31 | 32 | fun removeActivity(activity: Activity) { 33 | activities.remove(activity) 34 | } 35 | 36 | fun start() { 37 | jda.repeatUntilShutdown(15.seconds, 5.seconds) { 38 | if (activities.isEmpty()) { 39 | if (jda.presence.activity != null) 40 | jda.presence.activity = null 41 | } else { 42 | activities[currentIndex++ % activities.size].let { activity -> 43 | if (jda.presence.activity != activity) 44 | jda.presence.activity = activity 45 | } 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/Configuration.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import net.dv8tion.jda.api.utils.data.DataObject 20 | import net.dv8tion.jda.api.utils.data.DataType 21 | import org.slf4j.LoggerFactory 22 | import java.io.File 23 | import java.io.FileNotFoundException 24 | import kotlin.math.max 25 | import kotlin.math.min 26 | 27 | data class TwitchConfig( 28 | /** Client ID of twitch application */ 29 | val clientId: String, 30 | /** Client secret of twitch application */ 31 | val clientSecret: String, 32 | /** The twitch streamer names to keep track off */ 33 | val userNames: Set, 34 | /** How many top clips to show in the VOD event (0-5) */ 35 | val topClips: Int, 36 | /** How many minutes of grace period to apply before firing VOD events (allows for stream outage) */ 37 | val offlineThreshold: Int, 38 | ) 39 | 40 | data class DiscordConfig( 41 | /** The discord bot token used to handle role assignments */ 42 | val token: String, 43 | /** Logger webhook URL */ 44 | val logging: String?, 45 | /** The webhook url used for stream notifications */ 46 | val notifications: String, 47 | /** Whether to add a footer to stream notifications with command hints */ 48 | val notifyHint: Boolean, 49 | /** The guild in which to handle commands */ 50 | val guildId: Long, 51 | /** The role names used for notification mentions */ 52 | val ranks: Map, 53 | /** Which events to enable for notifications */ 54 | val events: Set, 55 | ) 56 | 57 | data class LoggerConfig( 58 | /** The logging level used for webhook logging */ 59 | val level: String?, 60 | /** The logging pattern for webhook logging */ 61 | val pattern: String?, 62 | ) 63 | 64 | data class Configuration( 65 | val discord: DiscordConfig, 66 | val twitch: TwitchConfig, 67 | val logger: LoggerConfig 68 | ) 69 | 70 | private val log = LoggerFactory.getLogger(Configuration::class.java) 71 | 72 | fun loadConfiguration(path: String, fallback: String = "/etc/strumbot/config.json"): Configuration { 73 | var file: File 74 | val json = try { 75 | file = File(path) 76 | DataObject.fromJson(file.reader()) 77 | } catch (ex: FileNotFoundException) { 78 | file = File(fallback) 79 | DataObject.fromJson(file.reader()) 80 | } 81 | 82 | log.info("Loaded config from ${file.canonicalPath}") 83 | 84 | val discord = json.getObject("discord") 85 | val twitch = json.getObject("twitch") 86 | val roles = discord.getObject("role_name").run { 87 | mapOf( 88 | "live" to getString("live", ""), 89 | "update" to getString("update", ""), 90 | "vod" to getString("vod", "") 91 | ) 92 | } 93 | val events = discord.getArray("enabled_events").map(Any::toString).toSet() 94 | val userLogin = if (twitch.isType("user_login", DataType.ARRAY)) 95 | twitch.getArray("user_login").map(Any::toString).toSet() 96 | else 97 | setOf(twitch.getString("user_login")) 98 | 99 | val logging = json.optObject("logger").orElseGet(DataObject::empty) 100 | 101 | val discordConfig = DiscordConfig( 102 | discord.getString("token"), 103 | discord.getString("logging", null), 104 | discord.getString("stream_notifications"), 105 | discord.getBoolean("show_notify_hints", false), 106 | discord.getUnsignedLong("server_id", 0L), 107 | roles, events 108 | ) 109 | 110 | val twitchConfig = TwitchConfig( 111 | twitch.getString("client_id"), 112 | twitch.getString("client_secret"), 113 | userLogin, 114 | min(5, max(0, twitch.getInt("top_clips", 0))), 115 | max(0, twitch.getInt("offline_grace_period", 2)) 116 | ) 117 | 118 | val loggingConfig = LoggerConfig( 119 | logging.getString("level", null), 120 | logging.getString("pattern", null), 121 | ) 122 | 123 | return Configuration(discordConfig, twitchConfig, loggingConfig) 124 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/FixedSizeMap.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import java.util.* 20 | 21 | private class FixedSizeMap0(val capacity: Int) : LinkedHashMap(capacity + 2) { 22 | override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { 23 | return size > capacity 24 | } 25 | } 26 | 27 | @Suppress("FunctionName") 28 | fun FixedSizeMap(capacity: Int): MutableMap = Collections.synchronizedMap(FixedSizeMap0(capacity)) -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/WebhookAppender.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:JvmName("WebhookAppender") 17 | package strumbot 18 | 19 | import ch.qos.logback.classic.Level 20 | import ch.qos.logback.classic.encoder.PatternLayoutEncoder 21 | import ch.qos.logback.classic.spi.LoggingEvent 22 | import ch.qos.logback.core.AppenderBase 23 | import kotlinx.coroutines.CoroutineScope 24 | import kotlinx.coroutines.delay 25 | import kotlinx.coroutines.launch 26 | import net.dv8tion.jda.api.JDA 27 | import net.dv8tion.jda.api.entities.WebhookClient 28 | 29 | class WebhookAppender : AppenderBase() { 30 | companion object { 31 | lateinit var instance: WebhookAppender 32 | private var client: WebhookClient<*>? = null 33 | private val buffer = StringBuilder(2000) 34 | 35 | fun init(api: JDA, url: String, scope: CoroutineScope) { 36 | client = url.asWebhook(api) 37 | scope.launch { 38 | while (true) { 39 | delay(500) 40 | send() 41 | } 42 | } 43 | } 44 | 45 | fun send() = synchronized(buffer) { 46 | if (buffer.isEmpty()) return@synchronized 47 | val message = "```ansi\n${buffer}\n```" 48 | buffer.setLength(0) 49 | client?.sendMessage(message)?.queue(null) { it.printStackTrace() } 50 | } 51 | } 52 | 53 | var level: String? = null 54 | lateinit var encoder: PatternLayoutEncoder 55 | 56 | private val minLevel: Level get() = Level.toLevel(level ?: "warn") 57 | 58 | override fun start() { 59 | if (!::encoder.isInitialized) 60 | throw AssertionError("Missing pattern encoder") 61 | 62 | instance = this 63 | encoder.start() 64 | super.start() 65 | } 66 | 67 | override fun append(event: LoggingEvent) { 68 | if (!event.level.isGreaterOrEqual(minLevel)) return 69 | synchronized(buffer) { 70 | buffer.append(encoder.encode(event).toString(Charsets.UTF_8).take(1500)) 71 | if (buffer.length > 1000) 72 | send() 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/WebhookService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import net.dv8tion.jda.api.JDA 20 | import net.dv8tion.jda.api.entities.Message 21 | import net.dv8tion.jda.api.entities.Webhook 22 | import net.dv8tion.jda.api.entities.WebhookClient 23 | import net.dv8tion.jda.internal.JDAImpl 24 | import net.dv8tion.jda.internal.entities.AbstractWebhookClient 25 | import net.dv8tion.jda.internal.requests.Route 26 | import net.dv8tion.jda.internal.requests.restaction.WebhookMessageActionImpl 27 | import net.dv8tion.jda.internal.requests.restaction.WebhookMessageUpdateActionImpl 28 | import java.util.regex.Matcher 29 | 30 | fun String.asWebhook(api: JDA) = Webhook.WEBHOOK_URL.matcher(this).apply(Matcher::matches).run { 31 | WebhookService(api, group("id").toLong(), group("token")) 32 | } 33 | 34 | class WebhookService(api: JDA, id: Long, token: String) : WebhookClient, AbstractWebhookClient(id, token, api) { 35 | override fun sendRequest(): WebhookMessageActionImpl { 36 | val route = Route.Webhooks.EXECUTE_WEBHOOK.compile(id.toString(), token).run { withQueryParams("wait", "true") } 37 | return WebhookMessageActionImpl(api, null, route) { 38 | (api as JDAImpl).entityBuilder.createMessage(it) 39 | }.apply { run() } 40 | } 41 | 42 | override fun editRequest(messageId: String?): WebhookMessageUpdateActionImpl { 43 | val route = Route.Webhooks.EXECUTE_WEBHOOK_EDIT.compile(id.toString(), token).run { withQueryParams("wait", "true") } 44 | return WebhookMessageUpdateActionImpl(api, route) { 45 | (api as JDAImpl).entityBuilder.createMessage(it) 46 | }.apply { run() } 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/http.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import kotlinx.coroutines.CoroutineScope 20 | import kotlinx.coroutines.launch 21 | import kotlinx.coroutines.suspendCancellableCoroutine 22 | import okhttp3.* 23 | import java.io.IOException 24 | import kotlin.coroutines.resumeWithException 25 | 26 | inline fun T.useCatching(fn: () -> R) = runCatching { 27 | fn() 28 | }.also { close() } 29 | 30 | suspend inline fun Call.await(scope: CoroutineScope, crossinline callback: suspend (Response) -> T) = suspendCancellableCoroutine { sink -> 31 | sink.invokeOnCancellation { cancel() } 32 | enqueue(object : Callback { 33 | override fun onFailure(call: Call, e: IOException) { 34 | sink.resumeWithException(e) 35 | } 36 | 37 | override fun onResponse(call: Call, response: Response) { 38 | scope.launch { 39 | response.useCatching { 40 | callback(response) 41 | }.also(sink::resumeWith) 42 | } 43 | } 44 | }) 45 | } 46 | 47 | class HttpException( 48 | route: String, status: Int, meaning: String 49 | ): Exception("$route > $status: $meaning") 50 | 51 | class NotAuthorized( 52 | response: Response 53 | ): Exception("Authorization failed. Code: ${response.code} Body: ${response.body?.string()}") 54 | 55 | fun Response.asException() = HttpException(request.url.toString(), code, message) 56 | 57 | inline fun post(url: String, form: FormBody.Builder.() -> Unit): Request { 58 | val body = FormBody.Builder() 59 | body.form() 60 | return Request.Builder() 61 | .url(url) 62 | .method("POST", body.build()) 63 | .build() 64 | } -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/i18n.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import java.util.* 20 | 21 | class LocalizationManager { 22 | private val translations: MutableMap = mutableMapOf() 23 | 24 | fun getText(locale: Locale, name: String): String { 25 | if (locale !in translations) { 26 | val path = "/strumbot/${locale.language}.properties" 27 | println("Loading resource $path") 28 | val resource = javaClass.getResourceAsStream(path) 29 | if (resource == null) { 30 | if (locale.language == "en") 31 | throw IllegalStateException("Could not load default resource for english!") 32 | val translation = getText(Locale.forLanguageTag("en"), name) 33 | translations[locale] = translations[Locale.forLanguageTag("en")]!! 34 | return translation 35 | } 36 | translations[locale] = Properties().apply { load(resource) } 37 | } 38 | 39 | return translations.getValue(locale).getProperty(name) 40 | } 41 | } 42 | 43 | val localizationManager: LocalizationManager = LocalizationManager() 44 | 45 | fun getLocale(stream: Stream): Locale = Locale.forLanguageTag(stream.language) ?: Locale.forLanguageTag("en") 46 | 47 | fun Locale.getText(name: String): String { 48 | return localizationManager.getText(this, name) 49 | } 50 | 51 | fun Locale.getText(name: String, tokens: Map): String { 52 | var template = getText(name) 53 | tokens.forEach { (key, value) -> 54 | template = template.replace("{{$key}}", value.toString()) 55 | } 56 | 57 | return template 58 | } 59 | 60 | fun Locale.getText(name: String, vararg tokens: Pair): String { 61 | return getText(name, mapOf(*tokens)) 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/main.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:JvmName("Main") 18 | package strumbot 19 | 20 | import ch.qos.logback.classic.PatternLayout 21 | import dev.minn.jda.ktx.CoroutineEventManager 22 | import dev.minn.jda.ktx.await 23 | import dev.minn.jda.ktx.interactions.choice 24 | import dev.minn.jda.ktx.interactions.command 25 | import dev.minn.jda.ktx.interactions.option 26 | import dev.minn.jda.ktx.interactions.updateCommands 27 | import dev.minn.jda.ktx.light 28 | import dev.minn.jda.ktx.onCommand 29 | import kotlinx.coroutines.* 30 | import net.dv8tion.jda.api.JDA 31 | import net.dv8tion.jda.api.entities.Member 32 | import net.dv8tion.jda.api.entities.Message 33 | import net.dv8tion.jda.api.entities.Role 34 | import net.dv8tion.jda.api.events.ShutdownEvent 35 | import net.dv8tion.jda.api.events.guild.GenericGuildEvent 36 | import net.dv8tion.jda.api.events.guild.GuildJoinEvent 37 | import net.dv8tion.jda.api.events.guild.GuildReadyEvent 38 | import net.dv8tion.jda.api.exceptions.HierarchyException 39 | import net.dv8tion.jda.api.exceptions.InsufficientPermissionException 40 | import net.dv8tion.jda.api.exceptions.PermissionException 41 | import net.dv8tion.jda.api.requests.RestAction 42 | import net.dv8tion.jda.api.utils.AllowedMentions 43 | import okhttp3.ConnectionPool 44 | import okhttp3.OkHttpClient 45 | import org.slf4j.Logger 46 | import org.slf4j.LoggerFactory 47 | import java.lang.Integer.max 48 | import java.util.* 49 | import java.util.concurrent.CancellationException 50 | import java.util.concurrent.Executors 51 | import java.util.concurrent.ForkJoinPool 52 | import java.util.concurrent.TimeUnit 53 | import kotlin.concurrent.thread 54 | import kotlin.time.Duration.Companion.minutes 55 | 56 | private val log = LoggerFactory.getLogger("Main") as Logger 57 | 58 | fun getThreadCount(): Int = max(2, ForkJoinPool.getCommonPoolParallelism()) 59 | 60 | private val pool = Executors.newScheduledThreadPool(getThreadCount()) { 61 | thread(start=false, name="Worker-Thread", isDaemon=true, block=it::run) 62 | } 63 | 64 | fun main() { 65 | RestAction.setDefaultTimeout(10, TimeUnit.SECONDS) 66 | AllowedMentions.setDefaultMentions(EnumSet.of(Message.MentionType.ROLE)) 67 | 68 | val config = loadConfiguration("config.json") 69 | val okhttp = OkHttpClient.Builder() 70 | .connectionPool(ConnectionPool(2, 20, TimeUnit.SECONDS)) 71 | .build() 72 | 73 | // Use the global thread pool for coroutine dispatches 74 | val dispatcher = pool.asCoroutineDispatcher() 75 | // Using a SupervisorJob allows coroutines to fail without cancelling all other jobs 76 | val supervisor = SupervisorJob() 77 | // Implement a logging exception handler for uncaught throws in launched jobs 78 | val handler = CoroutineExceptionHandler { _, throwable -> 79 | if (throwable !is CancellationException) 80 | log.error("Uncaught exception in coroutine", throwable) 81 | if (throwable is Error) { 82 | supervisor.cancel() 83 | throw throwable 84 | } 85 | } 86 | 87 | // Create our coroutine scope 88 | val context = dispatcher + supervisor + handler 89 | val scope = CoroutineScope(context) 90 | 91 | // Create a coroutine manager with this scope and a default event timeout of 1 minute 92 | val manager = CoroutineEventManager(scope, 1.minutes) 93 | manager.initCommands(config.discord) 94 | manager.initRoles(config.discord) 95 | 96 | manager.listener { 97 | supervisor.cancel() 98 | } 99 | 100 | log.info("Initializing twitch api") 101 | val twitch: TwitchApi = runBlocking { 102 | createTwitchApi(okhttp, config.twitch.clientId, config.twitch.clientSecret, scope) 103 | } 104 | 105 | log.info("Initializing discord connection") 106 | val jda = light(config.discord.token, enableCoroutines=false, intents=emptyList()) { 107 | setEventManager(manager) 108 | setHttpClient(okhttp) 109 | setCallbackPool(pool) 110 | setGatewayPool(pool) 111 | setRateLimitPool(pool) 112 | } 113 | 114 | config.discord.logging?.let { cfg -> 115 | config.logger.level?.let { 116 | WebhookAppender.instance.level = it 117 | } 118 | config.logger.pattern?.let { 119 | val layout = WebhookAppender.instance.encoder.layout as PatternLayout 120 | layout.pattern = it 121 | layout.start() 122 | } 123 | 124 | WebhookAppender.init(jda, cfg, scope) 125 | } 126 | 127 | // Cycling streaming status 128 | val activityService = ActivityService(jda) 129 | activityService.start() 130 | 131 | // Handle rank command 132 | setupRankListener(jda, config.discord) 133 | // Wait for cache to finish initializing 134 | jda.awaitReady() 135 | 136 | val watchedStreams = mutableMapOf() 137 | for (userLogin in config.twitch.userNames) { 138 | val key = userLogin.lowercase(Locale.ROOT) // make sure we don't insert things twice 139 | watchedStreams[key] = StreamWatcher(twitch, jda, config, userLogin, activityService) 140 | } 141 | 142 | val twitchJob = startTwitchService(twitch, jda, watchedStreams) 143 | 144 | twitchJob.invokeOnCompletion { 145 | if (it != null && it !is CancellationException) { 146 | log.error("Twitch service terminated unexpectedly", it) 147 | supervisor.cancel() 148 | } 149 | } 150 | 151 | supervisor.invokeOnCompletion { 152 | if (it != null && it !is CancellationException) { 153 | log.error("Supervisor failed with unexpected error", it) 154 | } else { 155 | log.info("Shutting down") 156 | } 157 | 158 | jda.shutdown() 159 | } 160 | 161 | System.gc() 162 | } 163 | 164 | /** 165 | * Creates the roles which are mentioned for webhook notifications 166 | */ 167 | private fun CoroutineEventManager.initRoles(config: DiscordConfig) = listener { event -> 168 | if (event !is GuildReadyEvent && event !is GuildJoinEvent) return@listener 169 | val guild = event.guild 170 | 171 | if (!filterId(guild, config.guildId)) return@listener 172 | 173 | config.ranks.values 174 | .asSequence() 175 | .filter(String::isNotEmpty) 176 | .filter { guild.getRolesByName(it, true).isEmpty() } 177 | .map { guild.createRole().setName(it) } 178 | .forEach { 179 | val role = it.await() 180 | log.info("Created role {} in {}", role.name, guild.name) 181 | } 182 | } 183 | 184 | /** 185 | * Creates the relevant commands for role management 186 | */ 187 | private fun CoroutineEventManager.initCommands(config: DiscordConfig) = listener { event -> 188 | if (event !is GuildReadyEvent && event !is GuildJoinEvent) return@listener 189 | val guild = event.guild 190 | 191 | if (!filterId(guild, config.guildId)) return@listener 192 | 193 | if (config.ranks.all { it.value.isBlank() }) { 194 | guild.updateCommands().queue() 195 | } else { 196 | guild.updateCommands { 197 | command("notify", "Add or remove one of the notification roles") { 198 | option("role", "The role to assign or remove you from", required = true) { 199 | config.ranks.forEach { (type, name) -> 200 | if (name.isNotBlank()) 201 | choice(name, type) 202 | } 203 | } 204 | } 205 | }.queue() 206 | } 207 | } 208 | 209 | /** 210 | * Handles the rank command 211 | */ 212 | private fun setupRankListener(jda: JDA, config: DiscordConfig) = jda.onCommand("notify") { event -> 213 | val guild = event.guild ?: return@onCommand 214 | val member = event.member ?: return@onCommand 215 | 216 | // Get the role instance for the requested rank 217 | val type = event.getOption("role")?.asString ?: "" 218 | val role = guild.getRoleById(jda.getRoleByType(config, type)) ?: run { 219 | event.reply("Role for `$type` event could not be identified.").queue() 220 | return@onCommand 221 | } 222 | 223 | event.deferReply(true).queue() // This is required to handle delayed response 224 | event.hook.setEphemeral(true) // Make messages only visible to command user 225 | 226 | try { 227 | val added = member.toggleRole(role) 228 | event.hook.sendMessage( 229 | if (added) "Added the role" 230 | else "Removed the role" 231 | ).await() 232 | } catch (ex: PermissionException) { 233 | // If there is a permission issue, handle it by telling the user about the problem 234 | event.hook.sendMessage(handlePermissionError(ex, role)).await() 235 | log.error("Failed to add or remove role for a member. Member: {} ({}) Role: {} ({})", 236 | member.user.asTag, member.id, role.name, role.id, ex) 237 | } 238 | } 239 | 240 | private suspend fun Member.toggleRole( 241 | role: Role 242 | ) = if (role in roles) { 243 | log.debug("Removing {} from {}", role.name, user.asTag) 244 | role.guild.removeRoleFromMember(this, role).await() 245 | false 246 | } else { 247 | log.debug("Adding {} to {}", role.name, user.asTag) 248 | role.guild.addRoleToMember(this, role).await() 249 | true 250 | } 251 | 252 | private fun handlePermissionError( 253 | error: PermissionException, 254 | role: Role? 255 | ): String { 256 | return when (error) { 257 | is InsufficientPermissionException -> 258 | "I'm missing the permission **${error.permission.getName()}**" 259 | is HierarchyException -> 260 | "I can't assign a role to you because the role is too high! Role: ${role?.name}" 261 | else -> 262 | "Encountered an error: `$error`!" 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/twitch.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import dev.minn.jda.ktx.SLF4J 20 | import kotlinx.coroutines.CoroutineScope 21 | import kotlinx.coroutines.Deferred 22 | import kotlinx.coroutines.async 23 | import kotlinx.coroutines.delay 24 | import net.dv8tion.jda.api.utils.data.DataArray 25 | import net.dv8tion.jda.api.utils.data.DataObject 26 | import okhttp3.OkHttpClient 27 | import okhttp3.Request 28 | import okhttp3.Response 29 | import org.slf4j.Logger 30 | import java.io.ByteArrayInputStream 31 | import java.io.ByteArrayOutputStream 32 | import java.io.InputStream 33 | import java.lang.Integer.min 34 | import java.time.Instant 35 | import java.time.ZonedDateTime 36 | 37 | suspend fun createTwitchApi(http: OkHttpClient, clientId: String, clientSecret: String, scope: CoroutineScope): TwitchApi { 38 | val api = TwitchApi(http, clientId, clientSecret, "N/A", scope) 39 | api.authorize() 40 | return api 41 | } 42 | 43 | class TwitchApi( 44 | private val http: OkHttpClient, 45 | private val clientId: String, 46 | private val clientSecret: String, 47 | private var accessToken: String, 48 | private val scope: CoroutineScope 49 | ) { 50 | 51 | private val log: Logger by SLF4J 52 | private val warnedMissingVod = mutableSetOf() 53 | private val games = FixedSizeMap(10) 54 | 55 | internal suspend fun authorize() { 56 | val request = post("https://id.twitch.tv/oauth2/token") { 57 | add("client_id", clientId) 58 | add("client_secret", clientSecret) 59 | add("grant_type", "client_credentials") 60 | } 61 | 62 | val call = http.newCall(request) 63 | call.await(scope) { response -> 64 | when { 65 | response.isSuccessful -> { 66 | val json = DataObject.fromJson(response.body!!.byteStream()) 67 | accessToken = json.getString("access_token") 68 | } 69 | response.code < 500 -> throw NotAuthorized(response) 70 | else -> throw response.asException() 71 | } 72 | } 73 | } 74 | 75 | private suspend fun makeRequest(request: Request, failed: Boolean = false, handler: (Response) -> T?): T? { 76 | log.trace("Making request to {}", request.url) 77 | val call = http.newCall(request) 78 | return call.await(scope) { response -> 79 | log.trace("Got response {} for url {}", response.code, request.url) 80 | when { 81 | failed && !response.isSuccessful -> { // Prevent infinite loop on broken API 82 | throw response.asException() 83 | } 84 | response.code == 401 -> { // oauth token expires after a few months of uptime 85 | log.warn("Authorization expired, refreshing token...") 86 | authorize() 87 | // Update authorization header to new token 88 | retryRequest(request, handler) 89 | } 90 | response.code == 404 -> { 91 | log.warn("Received 404 response for request to ${request.url}") 92 | null 93 | } 94 | response.code == 429 -> { // I have never seen this actually happen 95 | log.warn("Hit rate limit, retrying request. Headers:\n{}", response.headers) 96 | val reset = response.header("ratelimit-reset")?.let { 97 | it.toLong() - System.currentTimeMillis() 98 | } ?: 1000 99 | 100 | delay(reset) 101 | retryRequest(request, handler) 102 | } 103 | response.isSuccessful -> handler(response) 104 | else -> throw response.asException() 105 | } 106 | } 107 | } 108 | 109 | private suspend fun retryRequest(request: Request, handler: (Response) -> T?): T? { 110 | return makeRequest(request.newBuilder().authorization().build(), true, handler) 111 | } 112 | 113 | private fun get(url: String, vararg params: Pair): Request { 114 | val query = if (params.isEmpty()) "" 115 | else params.joinToString("&", "?") { "${it.first}=${it.second}" } 116 | return Request.Builder() 117 | .url("https://api.twitch.tv/helix/$url$query") 118 | .header("Client-ID", clientId) 119 | .authorization() 120 | .build() 121 | } 122 | 123 | private fun Request.Builder.authorization() = header("Authorization", "Bearer $accessToken") 124 | 125 | fun getStreamByLogin(login: Collection) = scope.async { 126 | val request = get("streams", 127 | *login.map { "user_login" to it }.toTypedArray() 128 | ) 129 | 130 | makeRequest(request) { response -> 131 | val data = body(response) 132 | if (data.isEmpty) { 133 | emptyList() 134 | } else { 135 | List(data.length()) { i -> 136 | val stream = data.getObject(i) 137 | Stream( 138 | stream.getString("id"), 139 | stream.getString("game_id"), 140 | stream.getString("title"), 141 | stream.getString("type"), 142 | stream.getString("language", "en"), 143 | stream.getString("thumbnail_url"), 144 | stream.getString("user_id"), 145 | stream.getString("user_login"), 146 | stream.getString("user_name"), 147 | ZonedDateTime.parse(stream.getString("started_at")).toEpochSecond() 148 | ) 149 | } 150 | } 151 | } 152 | } 153 | 154 | fun getGame(stream: Stream): Deferred = scope.async { 155 | if (stream.gameId.isEmpty()) { 156 | EMPTY_GAME 157 | } else if (stream.gameId in games) { 158 | games[stream.gameId] 159 | } else { 160 | val request = get("games", "id" to stream.gameId) 161 | makeRequest(request) { response -> 162 | val data = body(response) 163 | if (data.isEmpty) { 164 | EMPTY_GAME 165 | } else { 166 | val game = data.getObject(0) 167 | games.computeIfAbsent(stream.gameId) { 168 | Game( 169 | game.getString("id"), 170 | game.getString("name") 171 | ) 172 | } 173 | } 174 | } 175 | } 176 | } 177 | 178 | fun getUserIdByLogin(login: String) = scope.async { 179 | val request = get("users", "login" to login) 180 | makeRequest(request) { response -> 181 | val data = body(response) 182 | if (data.isEmpty) 183 | null 184 | else 185 | data.getObject(0).getString("id") 186 | } 187 | } 188 | 189 | fun getVideoById(id: String, type: String? = "archive") = scope.async { 190 | val request = get("videos?id=$id" + if (type != null) "&type=$type" else "") 191 | makeRequest(request) { response -> 192 | handleVideo(response) 193 | } 194 | } 195 | 196 | fun getVideoByStream(stream: Stream) = scope.async { 197 | val userId = stream.userId 198 | val request = get("videos", 199 | "type" to "archive", // archive = vod 200 | "first" to "5", // check 5 most recent videos, just in case it might ignore my type (default 20) 201 | "user_id" to userId 202 | ) 203 | 204 | makeRequest(request) { response -> 205 | val data = body(response) 206 | 207 | repeat(data.length()) { i -> 208 | val video = data.getObject(i) 209 | val type = video.getString("type") 210 | val createdAt = ZonedDateTime.parse(video.getString("created_at")).toEpochSecond() 211 | // Stream vods are always type archive (other types are highlight and upload) 212 | if (type == "archive" && stream.startedAt <= createdAt) { 213 | return@makeRequest buildVideo(video) 214 | } 215 | } 216 | if (warnedMissingVod.add(stream.userLogin)) 217 | log.warn("Could not find vod for current stream by ${stream.userLogin}. Did you enable archives?") 218 | return@makeRequest null 219 | } 220 | } 221 | 222 | fun getTopClips(userId: String, startedAt: Long, num: Int = 5) = scope.async { 223 | // this endpoint has horrible api design 224 | // "closed" https://discuss.dev.twitch.tv/t/new-twitch-api-getclips-missing-some-clips-but-not-all/23888/6 225 | // twitch filters *after* limiting the number. we need to just get max and then filter 226 | 227 | val request = get("clips", 228 | "broadcaster_id" to userId, 229 | "first" to "100", 230 | "started_at" to "${Instant.ofEpochSecond(startedAt)}" 231 | ) 232 | 233 | makeRequest(request) { response -> 234 | val data = body(response) 235 | if (data.length() == 0) 236 | emptyList() 237 | else { 238 | List(min(num, data.length())) { 239 | buildVideo(data.getObject(it)) 240 | } 241 | } 242 | } 243 | } 244 | 245 | fun getThumbnail(stream: Stream, width: Int = 1920, height: Int = 1080) = getThumbnail(stream.thumbnail, width, height) 246 | fun getThumbnail(video: Video, width: Int = 1920, height: Int = 1080) = getThumbnail(video.thumbnail, width, height) 247 | fun getThumbnail(url: String, width: Int, height: Int) = scope.async { 248 | // Stream url uses {width} and video url uses %{width} ??????????????? OK TWITCH ??????????? 249 | val thumbnailUrl = url.replace(Regex("%?\\{width}"), width.toString()) 250 | .replace(Regex("%?\\{height}"), height.toString()) + "?v=${System.currentTimeMillis()}" // add random number to avoid cache! 251 | val request = Request.Builder() 252 | .url(thumbnailUrl) 253 | .build() 254 | 255 | try { 256 | makeRequest(request) { response -> 257 | val buffer = ByteArrayOutputStream() 258 | response.body!!.byteStream().copyTo(buffer) 259 | ByteArrayInputStream(buffer.toByteArray()) 260 | } 261 | } catch (ex: Exception) { 262 | log.error("Failed to download thumbnail with url '{}'", url, ex) 263 | null 264 | } 265 | } 266 | 267 | private fun handleVideo(response: Response): Video? { 268 | val data = body(response) 269 | return if (data.isEmpty) 270 | null 271 | else { 272 | val video = data.getObject(0) 273 | buildVideo(video) 274 | } 275 | } 276 | 277 | private fun body(response: Response): DataArray { 278 | val json = DataObject.fromJson(response.body!!.byteStream()) 279 | return json.getArray("data") 280 | } 281 | 282 | private fun buildVideo(video: DataObject): Video { 283 | val id = video.getString("id") 284 | val url = video.getString("url") 285 | val title = video.getString("title") 286 | val thumbnail = video.getString("thumbnail_url") 287 | val views = video.getInt("view_count", 0) 288 | return Video(id, url, title, thumbnail, views) 289 | } 290 | } 291 | 292 | data class Stream( 293 | val streamId: String, 294 | val gameId: String, 295 | val title: String, 296 | val type: String, 297 | val language: String, 298 | val thumbnail: String, 299 | val userId: String, 300 | val userLogin: String, 301 | val userName: String, 302 | val startedAt: Long) 303 | data class Video( 304 | val id: String, 305 | val url: String, 306 | val title: String, 307 | val thumbnail: String, 308 | val views: Int) 309 | data class Game(val gameId: String, val name: String) 310 | 311 | val EMPTY_GAME = Game("", "No Category") -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/utils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import dev.minn.jda.ktx.scope 20 | import kotlinx.coroutines.CoroutineScope 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.delay 23 | import kotlinx.coroutines.launch 24 | import net.dv8tion.jda.api.JDA 25 | import net.dv8tion.jda.api.entities.Guild 26 | import kotlin.time.Duration 27 | 28 | // Convert role type to role id 29 | private val rankByType: MutableMap = mutableMapOf() 30 | 31 | fun filterId(guild: Guild, id: Long) = guild.idLong == id || id == 0L 32 | 33 | fun JDA.getRoleByType(config: DiscordConfig, type: String): String { 34 | val roleName = config.ranks[type] ?: "0" 35 | if (type !in rankByType) { 36 | if (roleName.isEmpty()) { 37 | rankByType[type] = "0" 38 | } else { 39 | // Find role by name 40 | val roleId = getRolesByName(roleName, true) 41 | .firstOrNull { filterId(it.guild, config.guildId) } // filter by server id (if applicable) 42 | ?.id ?: return "0" // take id or return "0" as fallback 43 | rankByType[type] = roleId 44 | } 45 | } 46 | return rankByType[type] ?: "0" 47 | } 48 | 49 | inline fun JDA.repeatUntilShutdown(rate: Duration, initDelay: Duration = rate, crossinline task: suspend CoroutineScope.() -> Unit): Job { 50 | return scope.launch { 51 | delay(initDelay) 52 | while (status != JDA.Status.SHUTDOWN) { 53 | task() 54 | delay(rate) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/strumbot/watcher.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-present Florian Spieß and the Strumbot Contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package strumbot 18 | 19 | import dev.minn.jda.ktx.* 20 | import kotlinx.coroutines.Deferred 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.async 23 | import kotlinx.coroutines.awaitAll 24 | import net.dv8tion.jda.api.JDA 25 | import net.dv8tion.jda.api.entities.Activity 26 | import net.dv8tion.jda.api.entities.Message 27 | import net.dv8tion.jda.api.entities.WebhookClient 28 | import net.dv8tion.jda.api.requests.RestAction 29 | import net.dv8tion.jda.api.utils.MarkdownUtil.maskedLink 30 | import net.dv8tion.jda.api.utils.TimeFormat.DATE_TIME_LONG 31 | import org.slf4j.Logger 32 | import org.slf4j.LoggerFactory 33 | import java.io.InputStream 34 | import java.net.SocketException 35 | import java.net.SocketTimeoutException 36 | import java.net.UnknownHostException 37 | import java.time.Duration 38 | import java.time.OffsetDateTime 39 | import java.util.* 40 | import kotlin.time.Duration.Companion.ZERO 41 | import kotlin.time.Duration.Companion.seconds 42 | 43 | private val log = LoggerFactory.getLogger(StreamWatcher::class.java) as Logger 44 | 45 | private val ignoredErrors = setOf>( 46 | SocketException::class.java, // Issues on socket creation 47 | SocketTimeoutException::class.java, // Timeouts 48 | UnknownHostException::class.java // DNS errors 49 | ) 50 | 51 | fun suppressExpected(t: Throwable) = t::class.java !in ignoredErrors 52 | 53 | fun startTwitchService( 54 | twitch: TwitchApi, 55 | jda: JDA, 56 | watchedStreams: Map 57 | ): Job { 58 | log.info("Listening for stream(s) from {}", 59 | if (watchedStreams.size == 1) 60 | watchedStreams.keys.first() 61 | else 62 | watchedStreams.keys.toString() 63 | ) 64 | 65 | return jda.repeatUntilShutdown(30.seconds, ZERO) { 66 | try { 67 | val streams = twitch.getStreamByLogin(watchedStreams.keys).await() ?: return@repeatUntilShutdown 68 | 69 | // Launch all the watcher updates in parallel 70 | val jobs = watchedStreams.map { (name, watcher) -> 71 | val stream = streams.find { it.userLogin.equals(name, true) } 72 | watcher.handle(stream) 73 | } 74 | 75 | // Then await them 76 | jobs.awaitAll() 77 | } catch (ex: Exception) { 78 | if (suppressExpected(ex)) 79 | log.error("Error in twitch stream service", ex) 80 | // Authorization errors should cancel our process 81 | if (ex is NotAuthorized) 82 | throw ex 83 | } 84 | } 85 | } 86 | 87 | data class Timestamps(val display: String, val twitchFormat: String) { 88 | companion object { 89 | fun from(timestamp: Int): Timestamps { 90 | val duration = Duration.ofSeconds(timestamp.toLong()) 91 | val hours = duration.toHours() 92 | val minutes = duration.minusHours(hours).toMinutes() 93 | val seconds = duration.minusHours(hours).minusMinutes(minutes).toSeconds() 94 | return Timestamps( 95 | "%02d:%02d:%02d".format(hours, minutes, seconds), 96 | "%02dh%02dm%02ds".format(hours, minutes, seconds)) 97 | } 98 | } 99 | } 100 | 101 | data class StreamElement(val game: Game, val timestamp: Int, var videoId: String) { 102 | fun toVideoUrl() = "https://www.twitch.tv/videos/${videoId}" 103 | 104 | fun toVodLink(comment: String = game.name): String { 105 | val (display, twitchFormat) = Timestamps.from(timestamp) 106 | if (videoId.isEmpty()) 107 | return "`${display}` $comment" 108 | val url = "${toVideoUrl()}?t=${twitchFormat}" 109 | return "[`${display}`](${url}) $comment" 110 | } 111 | } 112 | 113 | suspend fun Deferred.getOrNull(comment: String) = try { 114 | await() 115 | } catch (ex: Exception) { 116 | log.error("Failed to fetch {}", comment, ex) 117 | null 118 | } 119 | 120 | class StreamWatcher( 121 | private val twitch: TwitchApi, 122 | private val jda: JDA, 123 | private val config: Configuration, 124 | private val userLogin: String, 125 | private val activityService: ActivityService 126 | ) { 127 | @Volatile 128 | private var currentElement: StreamElement? = null 129 | 130 | private var userName: String = userLogin 131 | private var offlineTimestamp = 0L 132 | private var streamStarted = 0L 133 | private var currentActivity: Activity? = null 134 | private var userId: String = "" 135 | private var language: Locale = Locale.forLanguageTag("en") 136 | private val timestamps: MutableList = mutableListOf() 137 | private val webhook: WebhookClient = config.discord.notifications.asWebhook(jda) 138 | 139 | fun handle(stream: Stream?) = jda.scope.async { 140 | // There are 4 states we can process 141 | if (currentElement != null) { 142 | when { 143 | // 1. The stream was online and is now offline 144 | // => Send offline notification (vod event) 145 | stream == null -> handleOffline() 146 | 147 | // 2. The stream was online and has switched the game 148 | // => Send update game notification (update event) 149 | stream.gameId != currentElement?.game?.gameId -> { 150 | offlineTimestamp = 0 // We can skip one offline event since we are currently live and it might hickup 151 | val video = twitch.getVideoByStream(stream).getOrNull("video") 152 | handleUpdate(stream, video?.id ?: "") 153 | } 154 | 155 | // 3. The stream was online and has not switched the game 156 | // => Do nothing 157 | else -> { 158 | offlineTimestamp = 0 // We can skip one offline event since we are currently live, and it might hickup 159 | if (currentElement?.videoId == "") { // if twitch failed to provide a vod link, try updating it 160 | val video = twitch.getVideoByStream(stream).getOrNull("video") 161 | currentElement?.videoId = video?.id ?: "" 162 | } 163 | } 164 | } 165 | } else { 166 | // 4. The stream was offline and has come online 167 | // => Send go live notification (live event) 168 | if (stream != null) { 169 | offlineTimestamp = 0 // We can skip one offline event since we are currently live and it might hickup 170 | val game: Game 171 | val videoId: String 172 | val thumbnail: InputStream? 173 | // Run stuff async 174 | jda.scope.apply { 175 | // Launch each getter in parallel 176 | val getGame = async { twitch.getGame(stream).getOrNull("game") ?: EMPTY_GAME } 177 | val getVod = async { twitch.getVideoByStream(stream).getOrNull("video") } 178 | val getThumbnail = async { twitch.getThumbnail(stream).getOrNull("thumbnail") } 179 | 180 | game = getGame.await() 181 | videoId = getVod.await()?.id ?: "" 182 | thumbnail = getThumbnail.await() 183 | } 184 | 185 | handleGoLive(stream, game, videoId, thumbnail) 186 | } 187 | } 188 | } 189 | 190 | private fun updateActivity(newActivity: Activity?) { 191 | currentActivity?.let(activityService::removeActivity) 192 | currentActivity = newActivity 193 | newActivity?.let(activityService::addActivity) 194 | } 195 | 196 | /// EVENTS 197 | 198 | private suspend fun handleOffline() { 199 | if (offlineTimestamp == 0L) { 200 | offlineTimestamp = OffsetDateTime.now().toEpochSecond() 201 | return 202 | } else if (OffsetDateTime.now().toEpochSecond() - offlineTimestamp < (config.twitch.offlineThreshold * 60L)) { 203 | return 204 | } 205 | 206 | log.info("Stream from $userLogin went offline!") 207 | updateActivity(null) 208 | timestamps.add(currentElement!!) 209 | currentElement = null 210 | val timestamps = this.timestamps.toList() 211 | this.timestamps.clear() 212 | 213 | val index = timestamps.fold(StringBuilder()) { a, b -> a.append('\n').append(b.toVodLink()) } 214 | 215 | // Find most recent video available, the streamer might delete a vod during the stream 216 | val video = timestamps 217 | .asReversed() 218 | .map { it.videoId } 219 | .filter { it.isNotEmpty() } 220 | .firstNotNullOfOrNull { twitch.getVideoById(it).await() } 221 | 222 | // This will just be null if no video is available 223 | val thumbnail = video?.let { twitch.getThumbnail(it).await() } 224 | // If no VOD is available we don't include a link 225 | val videoUrl = video?.url 226 | 227 | val clips = if (config.twitch.topClips > 0) 228 | twitch.getTopClips(userId, streamStarted, config.twitch.topClips).await() ?: emptyList() 229 | else 230 | emptyList() 231 | 232 | val rankName = config.discord.ranks["vod"].takeIf { config.discord.notifyHint } 233 | val embed = makeEmbedBase(video?.title ?: "