├── lib └── JRAW-1.1.0.jar ├── webpack.config.d └── devServer.js ├── .gitignore ├── src ├── jsMain │ ├── resources │ │ ├── tada.mp3 │ │ ├── forsen.gif │ │ ├── favicon.ico │ │ ├── thumbnail.png │ │ ├── img │ │ │ ├── tts_on.webp │ │ │ ├── tts_off.webp │ │ │ ├── background.webp │ │ │ ├── markov │ │ │ │ ├── body.webp │ │ │ │ ├── head.webp │ │ │ │ ├── head_tape.webp │ │ │ │ ├── needle_0.webp │ │ │ │ ├── needle_1.webp │ │ │ │ ├── needle_2.webp │ │ │ │ ├── needle_3.webp │ │ │ │ ├── needle_4.webp │ │ │ │ ├── needle_5.webp │ │ │ │ ├── needle_6.webp │ │ │ │ ├── needle_7.webp │ │ │ │ ├── needle_8.webp │ │ │ │ ├── mouth_idle.webp │ │ │ │ ├── mouth_talking_0.webp │ │ │ │ ├── mouth_talking_1.webp │ │ │ │ ├── mouth_talking_2.webp │ │ │ │ ├── mouth_talking_3.webp │ │ │ │ ├── mouth_talking_4.webp │ │ │ │ └── mouth_talking_5.webp │ │ │ ├── menu_button.webp │ │ │ ├── achievements │ │ │ │ ├── 1.webp │ │ │ │ ├── 2.webp │ │ │ │ ├── 3.webp │ │ │ │ ├── 4.webp │ │ │ │ ├── 5.webp │ │ │ │ ├── 6.webp │ │ │ │ ├── 7.webp │ │ │ │ ├── 8.webp │ │ │ │ ├── 9.webp │ │ │ │ ├── 10.webp │ │ │ │ ├── 11.webp │ │ │ │ ├── 12.webp │ │ │ │ ├── 13.webp │ │ │ │ ├── 14.webp │ │ │ │ ├── 15.webp │ │ │ │ ├── 16.webp │ │ │ │ ├── 17.webp │ │ │ │ ├── 18.webp │ │ │ │ ├── 19.webp │ │ │ │ ├── 20.webp │ │ │ │ ├── 21.webp │ │ │ │ ├── 22.webp │ │ │ │ ├── 23.webp │ │ │ │ ├── 24.webp │ │ │ │ ├── 25.webp │ │ │ │ ├── 26.webp │ │ │ │ ├── 27.webp │ │ │ │ ├── 28.webp │ │ │ │ ├── 29.webp │ │ │ │ ├── 30.webp │ │ │ │ ├── 31.webp │ │ │ │ ├── 32.webp │ │ │ │ ├── 33.webp │ │ │ │ ├── 34.webp │ │ │ │ ├── 35.webp │ │ │ │ ├── 36.webp │ │ │ │ └── trophy.webp │ │ │ ├── chatinput │ │ │ │ ├── input_top.webp │ │ │ │ ├── input_left.webp │ │ │ │ ├── input_right.webp │ │ │ │ └── input_bottom.webp │ │ │ ├── chatmessages │ │ │ │ ├── box_corner_0.webp │ │ │ │ ├── box_corner_1.webp │ │ │ │ ├── box_corner_2.webp │ │ │ │ ├── box_corner_3.webp │ │ │ │ ├── box_corner_4.webp │ │ │ │ ├── box_corner_5.webp │ │ │ │ ├── box_corner_6.webp │ │ │ │ ├── box_side_0_horizontal.webp │ │ │ │ ├── box_side_0_vertical.webp │ │ │ │ ├── box_side_1_horizontal.webp │ │ │ │ ├── box_side_1_vertical.webp │ │ │ │ ├── box_side_2_horizontal.webp │ │ │ │ ├── box_side_2_vertical.webp │ │ │ │ ├── box_side_3_horizontal.webp │ │ │ │ └── box_side_3_vertical.webp │ │ │ └── socialmedia │ │ │ │ ├── twitch.svg │ │ │ │ ├── twitter.svg │ │ │ │ └── github.svg │ │ ├── discord │ │ │ ├── privacy.html │ │ │ └── tos.html │ │ └── index.html │ └── kotlin │ │ ├── Main.kt │ │ ├── JsBindings.kt │ │ ├── FocusHandler.kt │ │ ├── ScrollHandler.kt │ │ ├── LocalStorageBackedSnapshotStateMap.kt │ │ ├── components │ │ ├── Markov.kt │ │ ├── NotificationDisplay.kt │ │ ├── ChatInput.kt │ │ ├── MenuBox.kt │ │ └── ChatMessages.kt │ │ ├── App.kt │ │ ├── Achievements.kt │ │ └── Styles.kt ├── jvmMain │ ├── resources │ │ └── simplelogger.properties │ └── kotlin │ │ ├── Serializers.kt │ │ ├── TwitchBot.kt │ │ ├── backend │ │ ├── PublicApi.kt │ │ ├── Backend.kt │ │ ├── Lidlboards.kt │ │ └── JanitorBackend.kt │ │ ├── MarkovBaj.kt │ │ ├── DiscordBot.kt │ │ ├── RuntimeVariables.kt │ │ ├── InternalApi.kt │ │ └── RedditBot.kt ├── commonMain │ └── kotlin │ │ ├── CommonConstants.kt │ │ ├── WeightedSet.kt │ │ ├── Helpers.kt │ │ └── MarkovChain.kt └── jvmScriptsMain │ └── kotlin │ ├── GenerateDataSetFromJson.kt │ ├── GenerateExampleValues.kt │ ├── GenerateDataSetFromPushshift.kt │ └── MessageSanitizer.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── settings.gradle.kts ├── .github └── workflows │ └── gradle.yml ├── gradlew.bat └── gradlew /lib/JRAW-1.1.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/lib/JRAW-1.1.0.jar -------------------------------------------------------------------------------- /webpack.config.d/devServer.js: -------------------------------------------------------------------------------- 1 | if (config.devServer) { 2 | config.devServer.host = "0.0.0.0" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | .idea/ 3 | .run/ 4 | build/ 5 | kotlin-js-store/ 6 | out/ 7 | data*.json 8 | comments/ -------------------------------------------------------------------------------- /src/jsMain/resources/tada.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/tada.mp3 -------------------------------------------------------------------------------- /src/jsMain/resources/forsen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/forsen.gif -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/jsMain/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/favicon.ico -------------------------------------------------------------------------------- /src/jsMain/resources/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/thumbnail.png -------------------------------------------------------------------------------- /src/jsMain/resources/img/tts_on.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/tts_on.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/tts_off.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/tts_off.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/background.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/body.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/body.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/head.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/head.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/menu_button.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/menu_button.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/1.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/2.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/3.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/4.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/5.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/6.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/7.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/8.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/9.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/10.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/11.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/12.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/12.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/13.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/13.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/14.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/14.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/15.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/15.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/16.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/16.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/17.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/17.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/18.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/18.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/19.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/19.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/20.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/20.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/21.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/21.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/22.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/22.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/23.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/23.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/24.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/24.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/25.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/25.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/26.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/26.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/27.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/27.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/28.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/28.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/29.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/29.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/30.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/30.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/31.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/31.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/32.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/33.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/33.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/34.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/34.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/35.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/35.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/36.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/36.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/head_tape.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/head_tape.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_0.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_1.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_2.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_3.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_4.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_5.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_6.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_7.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/needle_8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/needle_8.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/achievements/trophy.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/achievements/trophy.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatinput/input_top.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatinput/input_top.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_idle.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_idle.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatinput/input_left.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatinput/input_left.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatinput/input_right.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatinput/input_right.webp -------------------------------------------------------------------------------- /src/jvmMain/resources/simplelogger.properties: -------------------------------------------------------------------------------- 1 | org.slf4j.simpleLogger.showDateTime=true 2 | org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd'T'HH:mm:ss.SSSZ -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatinput/input_bottom.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatinput/input_bottom.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_0.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_1.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_2.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_3.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_4.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/markov/mouth_talking_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/markov/mouth_talking_5.webp -------------------------------------------------------------------------------- /src/commonMain/kotlin/CommonConstants.kt: -------------------------------------------------------------------------------- 1 | object CommonConstants { 2 | const val triggerKeyword = "markov" 3 | const val consideredValuesForGeneration = 2 4 | } -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_0.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_1.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_2.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_3.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_4.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_5.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_corner_6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_corner_6.webp -------------------------------------------------------------------------------- /src/jsMain/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | 2 | import org.jetbrains.compose.web.renderComposable 3 | 4 | fun main() { 5 | renderComposable("root") { 6 | App() 7 | } 8 | } -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_0_horizontal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_0_horizontal.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_0_vertical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_0_vertical.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_1_horizontal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_1_horizontal.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_1_vertical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_1_vertical.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_2_horizontal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_2_horizontal.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_2_vertical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_2_vertical.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_3_horizontal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_3_horizontal.webp -------------------------------------------------------------------------------- /src/jsMain/resources/img/chatmessages/box_side_3_vertical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marczeugs/MarkovBaj/HEAD/src/jsMain/resources/img/chatmessages/box_side_3_vertical.webp -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.js.webpack.major.version=5 3 | 4 | kotlinVersion=1.9.21 5 | ktorVersion=2.2.3 6 | exposedVersion=0.45.0 7 | kotlinXSerializationVersion=1.4.1 8 | kotlinXCoroutinesVersion=1.6.4 9 | kordVersion=0.8.x-SNAPSHOT -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "MarkovBaj" 2 | 3 | pluginManagement { 4 | plugins { 5 | val kotlinVersion: String by settings 6 | 7 | kotlin("multiplatform") version kotlinVersion 8 | kotlin("plugin.serialization") version kotlinVersion 9 | } 10 | } -------------------------------------------------------------------------------- /src/jsMain/resources/discord/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MarkovBaj Discord Bot Privacy Statement 6 | 7 | 8 |

MarkovBaj Discord Bot Privacy Statement

9 | 10 |

The bot does not track any usage data from Discord. All input data is provided from reddit.com/r/forsen.

11 |

if you have any questions or concerns.*

12 | 13 | -------------------------------------------------------------------------------- /src/jsMain/resources/img/socialmedia/twitch.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jsMain/resources/discord/tos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MarkovBaj Discord Bot TOS 6 | 7 | 8 |

MarkovBaj Discord Bot TOS

9 | 10 |

Since the bot randomly generates responses, some of them may be perceived as hateful and breaking the Discord TOS. Please refrain from actively causing such messages to be posted and moderate/delete such posts if possible.

11 | 12 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/JsBindings.kt: -------------------------------------------------------------------------------- 1 | import org.w3c.files.Blob 2 | import kotlin.js.Promise 3 | 4 | external object speechSynthesis { 5 | fun speak(utterance: SpeechSynthesisUtterance) 6 | } 7 | 8 | external class SpeechSynthesisUtterance(text: String) 9 | 10 | external class Audio(path: String) { 11 | fun play(): Promise 12 | fun pause() 13 | var onended: () -> Unit 14 | } 15 | 16 | external object URL { 17 | fun createObjectURL(blob: Blob): String 18 | } 19 | 20 | external object Object { 21 | fun fromEntries(iterable: Array>): Map 22 | fun entries(jsObject: dynamic): Array 23 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/Serializers.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.Instant 2 | import kotlinx.serialization.KSerializer 3 | import kotlinx.serialization.descriptors.PrimitiveKind 4 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 5 | import kotlinx.serialization.encoding.Decoder 6 | import kotlinx.serialization.encoding.Encoder 7 | 8 | object InstantSerializer : KSerializer { 9 | override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG) 10 | 11 | override fun deserialize(decoder: Decoder): Instant = Instant.fromEpochMilliseconds(decoder.decodeLong()) 12 | override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeLong(value.toEpochMilliseconds()) 13 | } -------------------------------------------------------------------------------- /src/jsMain/resources/img/socialmedia/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/FocusHandler.kt: -------------------------------------------------------------------------------- 1 | 2 | import kotlinx.browser.document 3 | import org.jetbrains.compose.web.attributes.builders.InputAttrsScope 4 | import org.w3c.dom.HTMLElement 5 | import kotlin.math.absoluteValue 6 | import kotlin.random.Random 7 | 8 | class FocusHandler { 9 | private val generatedElementIdentifier = "focus-handler-${Random.nextInt().absoluteValue}" 10 | private var installed = false 11 | 12 | fun InputAttrsScope.install() { 13 | classes(generatedElementIdentifier) 14 | installed = true 15 | } 16 | 17 | fun focus() { 18 | check(installed) { "Not installed in a component." } 19 | (document.querySelector(".$generatedElementIdentifier")!! as HTMLElement).focus() 20 | } 21 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/ScrollHandler.kt: -------------------------------------------------------------------------------- 1 | 2 | import kotlinx.browser.document 3 | import org.jetbrains.compose.web.attributes.AttrsScope 4 | import org.w3c.dom.HTMLElement 5 | import kotlin.math.absoluteValue 6 | import kotlin.random.Random 7 | 8 | class ScrollHandler { 9 | private val generatedElementIdentifier = "scroll-handler-${Random.nextInt().absoluteValue}" 10 | private var installed = false 11 | 12 | fun AttrsScope<*>.install() { 13 | classes(generatedElementIdentifier) 14 | installed = true 15 | } 16 | 17 | fun scrollBy(x: Double, y: Double) { 18 | check(installed) { "Not installed in a component." } 19 | (document.querySelector(".$generatedElementIdentifier")!! as HTMLElement).scrollBy(x, y) 20 | } 21 | } -------------------------------------------------------------------------------- /src/jsMain/resources/img/socialmedia/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/WeightedSet.kt: -------------------------------------------------------------------------------- 1 | 2 | import kotlinx.serialization.Serializable 3 | import kotlin.random.Random 4 | 5 | @Serializable 6 | class WeightedSet() { 7 | val weightMap = mutableMapOf() 8 | 9 | constructor(initialWeightMap: Map) : this() { 10 | weightMap.putAll(initialWeightMap) 11 | } 12 | 13 | private var weightSum = weightMap.values.sum() 14 | 15 | fun addData(data: Collection) { 16 | data.forEach { entry -> 17 | weightMap[entry] = weightMap[entry]?.let { it + 1 } ?: 1 18 | } 19 | 20 | weightSum += data.size 21 | } 22 | 23 | fun randomValue(): T { 24 | val targetWeight = Random.nextInt(weightSum) 25 | val (matchingValue, _) = weightMap.entries 26 | .runningFold(@Suppress("UNCHECKED_CAST") (null as T) to 0) { (_, lastWeight), (nextValue, nextWeight) -> 27 | nextValue to lastWeight + nextWeight 28 | } 29 | .first { (_, weightSum) -> targetWeight < weightSum } 30 | 31 | return matchingValue 32 | } 33 | } -------------------------------------------------------------------------------- /src/jvmScriptsMain/kotlin/GenerateDataSetFromJson.kt: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.decodeFromString 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import java.io.File 9 | 10 | @Serializable 11 | data class Comment( 12 | val id: String, 13 | val author: String, 14 | val content: String, 15 | val posted: Instant 16 | ) 17 | 18 | fun main() { 19 | File("output.json").writeText( 20 | Json.encodeToString( 21 | File("comments").listFiles()!! 22 | .flatMap { file -> 23 | try { 24 | Json.decodeFromString>(file.readText()).map { it.author to it.content } 25 | } catch (_: Exception) { 26 | Json.decodeFromString>(file.readText()).map { "" to it } 27 | } 28 | .shuffled() 29 | .take(30000) 30 | } 31 | .mapNotNull { (author, content) -> sanitizeComment(author, content) } 32 | ) 33 | ) 34 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/Helpers.kt: -------------------------------------------------------------------------------- 1 | import mu.KotlinLogging 2 | 3 | 4 | private val logger = KotlinLogging.logger("MarkovBaj:Helpers") 5 | 6 | 7 | // language=regexp 8 | private const val WORD_PART_END_MARKER_REGEX = "[/()\\-_?!]" 9 | 10 | private val wordPartFinderRegex = Regex(" *[A-Z0]+?.?(?=\\s+|$|$WORD_PART_END_MARKER_REGEX|(?<=$WORD_PART_END_MARKER_REGEX))| *.+?(?=[A-Z]|\\s+|$|$WORD_PART_END_MARKER_REGEX|(?<=$WORD_PART_END_MARKER_REGEX))") 11 | 12 | fun String.toWordParts() = listOf(null) + wordPartFinderRegex.findAll(this).map { it.value }.toList() 13 | 14 | fun MarkovChain.generateRandomReply() = generateSequence().filterNotNull().joinToString("").trim() 15 | 16 | fun MarkovChain.tryGeneratingReplyFromWords(words: List, platform: String): String? { 17 | words 18 | .filter { word -> word?.let { CommonConstants.triggerKeyword !in it.lowercase() } ?: true } 19 | .windowed(CommonConstants.consideredValuesForGeneration) 20 | .shuffled() 21 | .forEach { potentialChainStart -> 22 | if (chainStarts.weightMap.keys.any { words -> words.map { it?.lowercase()?.trim() } == potentialChainStart.map { it?.lowercase()?.trim() } }) { 23 | logger.info { "[$platform] Generated response for chain start $potentialChainStart." } 24 | return generateSequence(start = potentialChainStart).filterNotNull().joinToString("").trim() 25 | } 26 | } 27 | 28 | logger.info { "[$platform] Unable to generate a response, using default instead..." } 29 | return null 30 | } -------------------------------------------------------------------------------- /src/jvmScriptsMain/kotlin/GenerateExampleValues.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package scripts 4 | 5 | import CommonConstants 6 | import MarkovChain 7 | import generateRandomReply 8 | import kotlinx.serialization.json.Json 9 | import kotlinx.serialization.json.decodeFromStream 10 | import mu.KotlinLogging 11 | import toWordParts 12 | import tryGeneratingReplyFromWords 13 | import java.io.File 14 | import kotlin.time.DurationUnit 15 | import kotlin.time.ExperimentalTime 16 | import kotlin.time.measureTime 17 | 18 | fun main() { 19 | val logger = KotlinLogging.logger("GenerateExampleValues.kts") 20 | 21 | val json = Json { 22 | ignoreUnknownKeys = true 23 | } 24 | 25 | val markovChain = MarkovChain(CommonConstants.consideredValuesForGeneration) { it?.trim() } 26 | 27 | logger.info("Building Markov chain...") 28 | 29 | val chainBuildTime = measureTime { 30 | val messages = json.decodeFromStream>(File("comments/data.json").inputStream()) 31 | val messageData = messages.map { it.toWordParts() } 32 | 33 | markovChain.addData( 34 | messageData, 35 | messageData.flatMap { values -> 36 | listOf( 37 | values.take(CommonConstants.consideredValuesForGeneration), 38 | values.drop(1).take(CommonConstants.consideredValuesForGeneration) 39 | ) 40 | } 41 | ) 42 | } 43 | 44 | logger.info("Building the chain took ${chainBuildTime.toDouble(DurationUnit.SECONDS)}s.") 45 | 46 | repeat(100) { 47 | logger.info(markovChain.generateRandomReply()) 48 | } 49 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/TwitchBot.kt: -------------------------------------------------------------------------------- 1 | 2 | import com.github.philippheuer.credentialmanager.domain.OAuth2Credential 3 | import com.github.twitch4j.TwitchClientBuilder 4 | import com.github.twitch4j.chat.events.channel.ChannelMessageEvent 5 | import com.github.twitch4j.common.enums.CommandPermission 6 | 7 | fun setupTwitchBot(markovChain: MarkovChain) { 8 | val credentials = OAuth2Credential("twitch", RuntimeVariables.Twitch.botToken) 9 | 10 | val twitchClient = TwitchClientBuilder.builder() 11 | .withEnableChat(true) 12 | .withChatAccount(credentials) 13 | .build() 14 | 15 | twitchClient.run { 16 | chat.run { 17 | connect() 18 | joinChannel(RuntimeVariables.Twitch.activeChannel) 19 | } 20 | } 21 | 22 | var enabled = true 23 | 24 | twitchClient.eventManager.onEvent(ChannelMessageEvent::class.java) { 25 | if (!RuntimeVariables.Twitch.actuallySendReplies || it.user.name == RuntimeVariables.Twitch.botUsername) { 26 | return@onEvent 27 | } 28 | 29 | if (it.message == "!toggle" && CommandPermission.MODERATOR in it.permissions) { 30 | enabled = !enabled 31 | twitchClient.chat.sendMessage(it.channel.name, "Bot is now ${if (enabled) "enabled" else "disabled"}.") 32 | } else if (enabled && CommonConstants.triggerKeyword in it.message.lowercase()) { 33 | val reply = ( 34 | markovChain.tryGeneratingReplyFromWords(it.message.toWordParts(), platform = "Twitch") 35 | ?: markovChain.generateRandomReply() 36 | ).take(500) 37 | 38 | twitchClient.chat.sendMessage(it.channel.name, reply) 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/LocalStorageBackedSnapshotStateMap.kt: -------------------------------------------------------------------------------- 1 | 2 | import androidx.compose.runtime.mutableStateMapOf 3 | import androidx.compose.runtime.snapshots.SnapshotStateMap 4 | import kotlinx.browser.window 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.decodeFromString 7 | import kotlinx.serialization.json.Json 8 | import kotlinx.serialization.serializer 9 | import org.w3c.dom.get 10 | 11 | class LocalStorageBackedSnapshotStateMap( 12 | private val localStorageKey: String, 13 | private val backingSnapshotStateMap: SnapshotStateMap, 14 | private val mapSerializer: KSerializer> 15 | ) 16 | : MutableMap by backingSnapshotStateMap 17 | { 18 | operator fun set(key: KeyType, value: ValueType) { 19 | window.localStorage[localStorageKey]?.let { serializedMap -> 20 | backingSnapshotStateMap.putAll(Json.decodeFromString(mapSerializer, serializedMap)) 21 | } 22 | 23 | backingSnapshotStateMap[key] = value 24 | 25 | window.localStorage.setItem(localStorageKey, Json.encodeToString(mapSerializer, backingSnapshotStateMap)) 26 | } 27 | } 28 | 29 | inline fun LocalStorageBackedSnapshotStateMap(localStorageKey: String): LocalStorageBackedSnapshotStateMap { 30 | val backingMap: SnapshotStateMap = 31 | window.localStorage[localStorageKey]?.let { serializedMap -> 32 | mutableStateMapOf().apply { 33 | putAll(Json.decodeFromString>(serializedMap)) 34 | } 35 | } ?: run { 36 | mutableStateMapOf() 37 | } 38 | 39 | return LocalStorageBackedSnapshotStateMap(localStorageKey, backingMap, serializer()) 40 | } -------------------------------------------------------------------------------- /src/jsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Markov Online 13 | 14 | 15 | 50 | 51 | 52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/backend/PublicApi.kt: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import MarkovChain 4 | import RuntimeVariables 5 | import generateRandomReply 6 | import io.ktor.http.* 7 | import io.ktor.server.application.* 8 | import io.ktor.server.request.* 9 | import io.ktor.server.response.* 10 | import io.ktor.util.pipeline.* 11 | import toWordParts 12 | import tryGeneratingReplyFromWords 13 | 14 | suspend fun PipelineContext.apiQuery(queryInput: Routes.Api.Query, markovChain: MarkovChain) { 15 | if (RuntimeVariables.Backend.checkedReferrer != null && context.request.header("Referer")?.startsWith(RuntimeVariables.Backend.checkedReferrer) != true) { 16 | logger.warn { "Rejected Markov chain query request from ${context.request.header("X-Real-Ip")} because of invalid referrer, input has length ${queryInput.input?.length}, starts with: \"${queryInput.input?.take(200)}\"" } 17 | call.respondText("This API is not public. Please refrain from using it from external sources.", status = HttpStatusCode.Forbidden) 18 | return 19 | } 20 | 21 | val query = queryInput.input 22 | 23 | if (query != null && query.length > 500) { 24 | logger.warn { "Rejected Markov chain query request from ${context.request.header("X-Real-Ip")} because of invalid query, input has length ${queryInput.input.length}, starts with: \"${queryInput.input.take(200)}\"" } 25 | call.respondText("Input too long.", status = HttpStatusCode.BadRequest) 26 | return 27 | } 28 | 29 | logger.info { "Serving Markov chain query request from ${context.request.header("X-Real-Ip")}, input has length ${queryInput.input?.length ?: 0}, starts with: \"${queryInput.input?.take(200)}\"" } 30 | 31 | val response = if (Math.random() > RuntimeVariables.Common.unrelatedAnswerChance) { 32 | query?.let { markovChain.tryGeneratingReplyFromWords(it.toWordParts(), "Backend") } 33 | } else { 34 | null 35 | } ?: run { 36 | markovChain.generateRandomReply() 37 | }.take(500) 38 | 39 | call.respondText(response) 40 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/MarkovChain.kt: -------------------------------------------------------------------------------- 1 | class MarkovChain(private val consideredValuesForGeneration: Int, private val inputValueMapperFunction: (T) -> T = { it }) { 2 | val chainStarts = WeightedSet>() 3 | private val followingValues = mutableMapOf, WeightedSet>() 4 | 5 | fun addData(data: List>, chainStarts: List> = data.map { values -> values.take(consideredValuesForGeneration).map { inputValueMapperFunction(it) } }) { 6 | this.chainStarts.addData(chainStarts) 7 | 8 | data.forEach { sequence -> 9 | (sequence + listOf(null)).windowed(consideredValuesForGeneration + 1).forEach { values -> 10 | // Cast is always safe because sequence ending marker (null) is only added at the end and always dropped by `dropLast(1)` 11 | @Suppress("UNCHECKED_CAST") 12 | val consideredValues = values.dropLast(1).map { inputValueMapperFunction(it as T) } 13 | 14 | val generatedValue = values.takeLast(1) 15 | 16 | followingValues.getOrPut(consideredValues) { WeightedSet() }.addData(generatedValue) 17 | } 18 | } 19 | } 20 | 21 | fun generateSequence(start: List = chainStarts.randomValue(), maxLength: Int = 100.coerceAtLeast(consideredValuesForGeneration)): List { 22 | require(maxLength >= consideredValuesForGeneration) { 23 | "Max length must be at least as large as the number of considered values. Is ${maxLength}, should be >= $consideredValuesForGeneration" 24 | } 25 | 26 | if (start.size < consideredValuesForGeneration) { 27 | return start 28 | } 29 | 30 | val generatedValues = start.toMutableList() 31 | 32 | for (index in start.size..(CommonConstants.consideredValuesForGeneration) { it?.trim() } 27 | 28 | logger.info("Building Markov chain...") 29 | 30 | val chainBuildTime = measureTime { 31 | val messages = json.decodeFromStream>(File("data.json").inputStream()) 32 | val messageData = messages.map { it.toWordParts() } 33 | 34 | markovChain.addData( 35 | messageData, 36 | messageData.flatMap { values -> 37 | listOf( 38 | values.take(CommonConstants.consideredValuesForGeneration), 39 | values.drop(1).take(CommonConstants.consideredValuesForGeneration) 40 | ) 41 | } 42 | ) 43 | } 44 | 45 | logger.info("Building the chain took ${chainBuildTime.toDouble(DurationUnit.SECONDS)}s.") 46 | 47 | 48 | val eventFlow = MutableSharedFlow(extraBufferCapacity = 1) 49 | 50 | val redditClient = if (RuntimeVariables.Reddit.enabled) setupRedditClient() else null 51 | 52 | if (RuntimeVariables.Reddit.enabled) { 53 | launch { 54 | setupRedditBot(redditClient!!, markovChain, eventFlow) 55 | } 56 | } else { 57 | logger.warn("Reddit bot is not enabled.") 58 | } 59 | 60 | if (RuntimeVariables.Discord.enabled) { 61 | launch { 62 | setupDiscordBot(markovChain) 63 | } 64 | } else { 65 | logger.warn("Discord bot is not enabled.") 66 | } 67 | 68 | if (RuntimeVariables.Twitch.enabled) { 69 | launch { 70 | setupTwitchBot(markovChain) 71 | } 72 | } else { 73 | logger.warn("Twitch bot is not enabled.") 74 | } 75 | 76 | launch { 77 | setupBackendApiWebsocketServer(redditClient, json, eventFlow) 78 | } 79 | 80 | withContext(Dispatchers.IO) { 81 | setupBackendServer(redditClient, json, markovChain) 82 | } 83 | } -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Build MarkovBaj Backend + Frontend 2 | 3 | on: 4 | push: 5 | branches: [ "main", "ci-test" ] 6 | pull_request: 7 | branches: [ "main", "ci-test" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up JDK 17 17 | uses: actions/setup-java@v3 18 | with: 19 | java-version: '17' 20 | distribution: 'temurin' 21 | - name: Get Release Version 22 | id: get_version 23 | run: VERSION=$(gradle properties --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}') && echo VERSION=$VERSION >> $GITHUB_OUTPUT 24 | - name: Create Release 25 | id: create_release 26 | uses: actions/create-release@v1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | tag_name: MarkovBaj-${{ steps.get_version.outputs.VERSION }} 31 | release_name: MarkovBaj ${{ steps.get_version.outputs.VERSION }} 32 | draft: false 33 | prerelease: false 34 | - name: Build Backend 35 | uses: gradle/gradle-build-action@v2 36 | with: 37 | arguments: shadowJar 38 | - name: Build Frontend Resources 39 | uses: gradle/gradle-build-action@v2 40 | with: 41 | arguments: jsBrowserProductionExecutableDistributeResources 42 | - name: Build Frontend JS 43 | uses: gradle/gradle-build-action@v2 44 | with: 45 | arguments: jsBrowserProductionWebpack 46 | - name: Upload Backend Jar 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./build/libs/MarkovBaj-${{ steps.get_version.outputs.VERSION }}-all.jar 53 | asset_name: MarkovBaj-Backend-${{ steps.get_version.outputs.VERSION }}.jar 54 | asset_content_type: application/octet-stream 55 | - name: Zip Frontend 56 | uses: vimtor/action-zip@v1 57 | with: 58 | files: build/distributions/ 59 | dest: frontend.zip 60 | - name: Upload Frontend Zip 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./frontend.zip 67 | asset_name: MarkovBaj-Frontend-${{ steps.get_version.outputs.VERSION }}.zip 68 | asset_content_type: application/octet-stream -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/Markov.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import Styles 4 | import androidx.compose.runtime.* 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.isActive 7 | import org.jetbrains.compose.web.dom.Div 8 | import org.jetbrains.compose.web.dom.Img 9 | import kotlin.random.Random 10 | import kotlin.time.Duration.Companion.seconds 11 | 12 | private const val MOUTH_IMAGE_COUNT = 6 13 | private const val NEEDLE_IMAGE_COUNT = 9 14 | 15 | @Composable 16 | fun Markov( 17 | talking: Boolean, 18 | muted: Boolean 19 | ) { 20 | var mouthImageIndex by remember { mutableStateOf(0) } 21 | var powerLevel by remember { mutableStateOf(0) } 22 | 23 | LaunchedEffect(talking) { 24 | while (talking) { 25 | mouthImageIndex = generateSequence { Random.nextInt(MOUTH_IMAGE_COUNT) }.first { it != mouthImageIndex } 26 | delay(Random.nextDouble(0.05, 0.15).seconds) 27 | } 28 | } 29 | 30 | LaunchedEffect(talking) { 31 | while (isActive) { 32 | if (talking) { 33 | powerLevel += when (Random.nextInt(100)) { 34 | in 0..<20 -> -1 35 | in 70..<100 -> 1 36 | else -> 0 37 | } 38 | } else { 39 | powerLevel -= 1 40 | } 41 | 42 | if (powerLevel < 0) { 43 | powerLevel = 0 44 | } else if (powerLevel >= NEEDLE_IMAGE_COUNT) { 45 | powerLevel = NEEDLE_IMAGE_COUNT - 1 46 | } 47 | 48 | delay(0.1.seconds) 49 | } 50 | } 51 | 52 | Div(attrs = { classes(Styles.markovContainer) }) { 53 | Img( 54 | src = "img/markov/body.webp", 55 | attrs = { classes(Styles.markovBodyPart) } 56 | ) 57 | 58 | Img( 59 | src = "img/markov/head.webp", 60 | attrs = { classes(Styles.markovHeadPart, Styles.markovBodyPart, *(if (!muted) arrayOf() else arrayOf(Styles.hidden))) } 61 | ) 62 | 63 | Img( 64 | src = "img/markov/mouth_idle.webp", 65 | attrs = { classes(Styles.markovHeadPart, Styles.markovBodyPart, *(if (!talking) arrayOf() else arrayOf(Styles.hidden)), *(if (!muted) arrayOf() else arrayOf(Styles.hidden))) } 66 | ) 67 | 68 | for (i in 0.. 19 | ) { 20 | var currentNotificationContent by remember { mutableStateOf("") } 21 | var notificationShown by remember { mutableStateOf(false) } 22 | 23 | val notificationSound = remember { 24 | (window.document.createElement("audio") as HTMLAudioElement).apply { 25 | src = "tada.mp3" 26 | } 27 | } 28 | 29 | LaunchedEffect(Unit) { 30 | notificationQueue.collect { 31 | if (it == null) { 32 | return@collect 33 | } 34 | 35 | currentNotificationContent = it 36 | notificationShown = true 37 | 38 | notificationSound.apply { currentTime = 0.0 }.play() 39 | 40 | delay(5.seconds) 41 | 42 | notificationShown = false 43 | 44 | delay(1.seconds) 45 | } 46 | } 47 | 48 | Div( 49 | attrs = { 50 | classes(Styles.notificationContainer) 51 | 52 | style { 53 | marginTop(if (notificationShown) 0.px else (-100).px) 54 | } 55 | } 56 | ) { 57 | Div(attrs = { classes(Styles.notificationBackground) }) 58 | 59 | Div(attrs = { classes(Styles.smallBorderContainer, Styles.notificationInnerContainer) }) { 60 | for (i in 0..<2) { 61 | Img( 62 | src = "img/chatinput/input_${if (i == 0) "left" else "right"}.webp", 63 | attrs = { 64 | classes(Styles.smallBorderHorizontalImage) 65 | 66 | draggable(Draggable.False) 67 | 68 | style { 69 | gridColumn((1 + i * 2).toString()) 70 | gridRow(1, 4) 71 | } 72 | } 73 | ) 74 | } 75 | 76 | for (i in 0..<2) { 77 | Div( 78 | attrs = { 79 | style { 80 | backgroundImage("url('img/chatinput/input_${if (i == 0) "top" else "bottom"}.webp')") 81 | backgroundRepeat("repeat-x") 82 | backgroundSize("auto 15px") 83 | gridColumn("2") 84 | gridRow((1 + i * 2).toString()) 85 | } 86 | } 87 | ) { } 88 | } 89 | 90 | Div(attrs = { classes(Styles.notificationContent) }) { 91 | Text(currentNotificationContent) 92 | } 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /src/jvmScriptsMain/kotlin/GenerateDataSetFromPushshift.kt: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import CommonConstants 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.engine.cio.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.plugins.logging.* 9 | import io.ktor.client.request.* 10 | import io.ktor.serialization.kotlinx.json.* 11 | import kotlinx.coroutines.delay 12 | import kotlinx.serialization.SerialName 13 | import kotlinx.serialization.Serializable 14 | import kotlinx.serialization.encodeToString 15 | import kotlinx.serialization.json.Json 16 | import mu.KotlinLogging 17 | import java.io.File 18 | import java.time.Instant 19 | import java.time.format.DateTimeFormatter 20 | import kotlin.time.Duration.Companion.seconds 21 | 22 | @Serializable 23 | private data class CommentsResponse( 24 | val data: List 25 | ) { 26 | @Serializable 27 | data class Comment( 28 | val author: String, 29 | val body: String, 30 | val locked: Boolean, 31 | @SerialName("created_utc") val createdUtc: Long, 32 | ) 33 | } 34 | 35 | suspend fun main() { 36 | val json = Json { 37 | ignoreUnknownKeys = true 38 | allowStructuredMapKeys = true 39 | } 40 | 41 | val client = HttpClient(CIO) { 42 | install(Logging) 43 | 44 | install(ContentNegotiation) { 45 | json(json) 46 | } 47 | } 48 | 49 | val urlTemplate = "https://api.pushshift.io/reddit/search/comment?subreddit=forsen&size=500&before=" 50 | val maxJsonSize = 10 * 1024 * 1024 // 10 MiB 51 | 52 | val logger = KotlinLogging.logger("GenerateDataSetFromPushshift") 53 | 54 | val allFetchedComments = mutableListOf() 55 | 56 | var lastTimestamp = System.currentTimeMillis() / 1000 57 | var lastJsonOutput = "" 58 | var last10PercentBarrier = 0 59 | 60 | do { 61 | try { 62 | val nextComments = client.get(urlTemplate + lastTimestamp).body().data 63 | 64 | val filteredComments = nextComments 65 | .filter { !it.locked } 66 | .mapNotNull { sanitizeComment(it.author, it.body) } 67 | 68 | allFetchedComments.addAll(filteredComments) 69 | 70 | lastJsonOutput = json.encodeToString(allFetchedComments) 71 | 72 | logger.info { 73 | "Fetched ${filteredComments.size} comments before ${ 74 | DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochSecond(lastTimestamp)) 75 | }, total comments: ${allFetchedComments.size}, JSON string size: ${lastJsonOutput.length} / $maxJsonSize (${ 76 | lastJsonOutput.length.toFloat() / maxJsonSize * 100 77 | }%)" 78 | } 79 | 80 | lastTimestamp = nextComments.last().createdUtc 81 | } catch (e: Exception) { 82 | logger.error { "Unable to fetch comments before ${DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochSecond(lastTimestamp))}, retrying..." } 83 | save("data-error.json", lastJsonOutput) 84 | } 85 | 86 | if ((lastJsonOutput.length.toFloat() / maxJsonSize * 100).toInt() / 10 != last10PercentBarrier) { 87 | last10PercentBarrier = (lastJsonOutput.length.toFloat() / maxJsonSize * 100).toInt() / 10 88 | save("data-${last10PercentBarrier * 10}.json", lastJsonOutput) 89 | logger.info { "Reached ${last10PercentBarrier * 10}%, saving backup..." } 90 | } 91 | 92 | delay(1.seconds) 93 | } while (lastJsonOutput.length < maxJsonSize) 94 | 95 | save("data.json", lastJsonOutput) 96 | logger.info { "Fetched ${allFetchedComments.size} comments in total." } 97 | } 98 | 99 | fun save(fileName: String, jsonOutput: String) { 100 | File(fileName).writeText(jsonOutput) 101 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/DiscordBot.kt: -------------------------------------------------------------------------------- 1 | 2 | import dev.kord.common.entity.Snowflake 3 | import dev.kord.core.Kord 4 | import dev.kord.core.behavior.interaction.respondPublic 5 | import dev.kord.core.entity.Message 6 | import dev.kord.core.event.gateway.ReadyEvent 7 | import dev.kord.core.event.guild.GuildCreateEvent 8 | import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent 9 | import dev.kord.core.event.message.MessageCreateEvent 10 | import dev.kord.core.on 11 | import dev.kord.gateway.Intent 12 | import dev.kord.gateway.Intents 13 | import dev.kord.gateway.PrivilegedIntent 14 | import dev.kord.rest.builder.interaction.string 15 | import kotlinx.coroutines.FlowPreview 16 | import kotlinx.coroutines.flow.MutableSharedFlow 17 | import kotlinx.coroutines.flow.debounce 18 | import kotlinx.coroutines.flow.firstOrNull 19 | import kotlinx.coroutines.launch 20 | import mu.KotlinLogging 21 | import kotlin.time.Duration.Companion.seconds 22 | 23 | private val logger = KotlinLogging.logger("MarkovBaj:Discord") 24 | 25 | suspend fun setupDiscordBot(markovChain: MarkovChain) { 26 | val perGuildMessageDebounceFlow = mutableMapOf>() 27 | 28 | Kord(RuntimeVariables.Discord.botToken).apply { 29 | on { 30 | logger.info { "Discord bot ready." } 31 | 32 | if (getGlobalApplicationCommands().firstOrNull { it.name == "markov" } == null) { 33 | createGlobalChatInputCommand("markov", "Make Markov respond to your query.") { 34 | string("query", "Input") 35 | } 36 | } 37 | } 38 | 39 | on { 40 | logger.info { "Added to server \"${guild.name}\" (${guild.id})" } 41 | } 42 | 43 | on { 44 | if ( 45 | (selfId in message.mentionedUserIds || CommonConstants.triggerKeyword in message.content.lowercase()) 46 | && RuntimeVariables.Discord.actuallySendReplies 47 | && (message.author?.id ?: message.data.author.id) != selfId 48 | ) { 49 | guildId?.let { guildId -> 50 | val flow = perGuildMessageDebounceFlow.getOrPut(guildId) { 51 | MutableSharedFlow(replay = 1).apply { 52 | launch { 53 | @OptIn(FlowPreview::class) 54 | debounce(2.seconds).collect { 55 | val response = markovChain.tryGeneratingReplyFromWords(it.content.toWordParts(), platform = "Discord") 56 | ?: markovChain.generateRandomReply() 57 | 58 | it.channel.createMessage(response) 59 | } 60 | } 61 | } 62 | } 63 | 64 | flow.emit(message) 65 | } 66 | } 67 | } 68 | 69 | on { 70 | if (interaction.data.applicationId != selfId || interaction.data.data.name.value != "markov" || !RuntimeVariables.Discord.actuallySendReplies) { 71 | return@on 72 | } 73 | 74 | val response = interaction.command.strings["query"]?.let { markovChain.tryGeneratingReplyFromWords(it.toWordParts(), platform = "Discord") } 75 | ?: markovChain.generateRandomReply() 76 | 77 | interaction.respondPublic { 78 | content = "Input: ${interaction.command.strings["query"] ?: "-"}\n\nOutput: ${response.take(1000)}" 79 | } 80 | } 81 | 82 | launch { 83 | login { 84 | @OptIn(PrivilegedIntent::class) 85 | intents = Intents.nonPrivileged + Intent.MessageContent 86 | } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/ChatInput.kt: -------------------------------------------------------------------------------- 1 | package components 2 | import FocusHandler 3 | import Styles 4 | import androidx.compose.runtime.* 5 | import kotlinx.browser.window 6 | import kotlinx.coroutines.launch 7 | import org.jetbrains.compose.web.attributes.* 8 | import org.jetbrains.compose.web.css.* 9 | import org.jetbrains.compose.web.dom.* 10 | import org.w3c.dom.url.URLSearchParams 11 | 12 | @Composable 13 | fun ChatInput( 14 | onMessageSent: suspend (message: String) -> Boolean 15 | ) { 16 | val coroutineScope = rememberCoroutineScope() 17 | 18 | var text by remember { mutableStateOf("") } 19 | var currentlyProcessing by remember { mutableStateOf(false) } 20 | 21 | val focusHandler = remember { FocusHandler() } 22 | 23 | LaunchedEffect(currentlyProcessing) { 24 | if (!currentlyProcessing) { 25 | focusHandler.focus() 26 | } 27 | } 28 | 29 | LaunchedEffect(Unit) { 30 | URLSearchParams(window.location.search).get("input")?.let { 31 | text = it 32 | } 33 | 34 | focusHandler.focus() 35 | } 36 | 37 | Div(attrs = { classes(Styles.chatInputContainer) }) { 38 | Div(attrs = { classes(Styles.chatInputBackground) }) 39 | 40 | Div(attrs = { classes(Styles.chatInputBorderContainer, Styles.smallBorderContainer) }) { 41 | for (i in 0..<2) { 42 | Img( 43 | src = "img/chatinput/input_${if (i == 0) "left" else "right"}.webp", 44 | attrs = { 45 | classes(Styles.smallBorderHorizontalImage) 46 | 47 | draggable(Draggable.False) 48 | 49 | style { 50 | gridColumn((1 + i * 2).toString()) 51 | gridRow(1, 4) 52 | } 53 | } 54 | ) 55 | } 56 | 57 | for (i in 0..<2) { 58 | Div( 59 | attrs = { 60 | style { 61 | backgroundImage("url('img/chatinput/input_${if (i == 0) "top" else "bottom"}.webp')") 62 | backgroundRepeat("repeat-x") 63 | backgroundSize("auto 15px") 64 | gridColumn("2") 65 | gridRow((1 + i * 2).toString()) 66 | } 67 | } 68 | ) { } 69 | } 70 | 71 | Form( 72 | attrs = { 73 | onSubmit { 74 | it.preventDefault() 75 | 76 | coroutineScope.launch { 77 | if (text.isBlank()) { 78 | return@launch 79 | } 80 | 81 | currentlyProcessing = true 82 | 83 | val success = onMessageSent(text) 84 | 85 | if (success) { 86 | text = "" 87 | } 88 | 89 | currentlyProcessing = false 90 | } 91 | } 92 | } 93 | ) { 94 | TextInput( 95 | value = text, 96 | attrs = { 97 | classes(Styles.chatInput) 98 | 99 | focusHandler.run { install() } // Should probably be implemented with a context receiver as soon as they are stable 100 | 101 | if (currentlyProcessing) { 102 | disabled() 103 | } 104 | 105 | placeholder("Type a message and send with Enter.") 106 | 107 | onInput { 108 | text = it.value 109 | } 110 | } 111 | ) 112 | 113 | Input(InputType.Submit, attrs = { hidden() }) 114 | } 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/RuntimeVariables.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.serialization.SerialName 2 | import kotlinx.serialization.Serializable 3 | import kotlinx.serialization.json.Json 4 | import kotlin.time.Duration.Companion.seconds 5 | 6 | @Serializable 7 | data class TableDefinition( 8 | val name: String, 9 | val displayName: String, 10 | val columns: List 11 | ) { 12 | @Serializable 13 | data class Column( 14 | val name: String, 15 | val displayName: String, 16 | val type: Type 17 | ) { 18 | @Serializable 19 | enum class Type { 20 | @SerialName("varchar32") VarChar32, 21 | @SerialName("text") Text, 22 | @SerialName("integer") Integer, 23 | @SerialName("boolean") Boolean, 24 | @SerialName("timestamp") Timestamp, 25 | } 26 | } 27 | } 28 | 29 | object RuntimeVariables { 30 | object Common { 31 | val unrelatedAnswerChance = System.getenv("markovbaj_unrelatedanswerchance").toFloat() 32 | } 33 | 34 | object Reddit { 35 | val enabled: Boolean = System.getenv("markovbaj_reddit_enabled") == "true" 36 | val botUsername: String by lazy { System.getenv("markovbaj_reddit_username") } 37 | val botPassword: String by lazy { System.getenv("markovbaj_reddit_password") } 38 | val botClientId: String by lazy { System.getenv("markovbaj_reddit_clientid") } 39 | val botClientSecret: String by lazy { System.getenv("markovbaj_reddit_clientsecret") } 40 | val botAppId: String by lazy { System.getenv("markovbaj_reddit_appid") } 41 | val botAuthorRedditUsername: String by lazy { System.getenv("markovbaj_reddit_authorusername") } 42 | val actuallySendReplies by lazy { System.getenv("markovbaj_reddit_actuallysendreplies") == "true" } 43 | val answerMentions by lazy { System.getenv("markovbaj_reddit_answermentions") == "true" } 44 | val activeSubreddit: String by lazy { System.getenv("markovbaj_reddit_activesubreddit") } 45 | val checkInterval by lazy { System.getenv("markovbaj_reddit_checkintervalinseconds").toInt().seconds } 46 | val maxCommentsPerCheck by lazy { System.getenv("markovbaj_reddit_maxcommentspercheck").toInt() } 47 | val delayBetweenComments by lazy { System.getenv("markovbaj_reddit_delaybetweencommentsinseconds").toInt().seconds } 48 | } 49 | 50 | object Backend { 51 | val serverUrl: String = System.getenv("markovbaj_backend_serverurl") 52 | val serverPort = System.getenv("markovbaj_backend_serverport").toInt() 53 | val databaseUrl: String = System.getenv("markovbaj_backend_databaseurl") 54 | val databaseUser: String = System.getenv("markovbaj_backend_databaseuser") 55 | val databasePassword: String = System.getenv("markovbaj_backend_databasepassword") 56 | val databaseTables = Json.decodeFromString>(System.getenv("markovbaj_backend_databasetables")) 57 | val databaseTableRowLimit = System.getenv("markovbaj_backend_databasetablerowlimit").toInt() 58 | val redditClientId: String = System.getenv("markovbaj_backend_clientid") 59 | val redditClientSecret: String = System.getenv("markovbaj_backend_clientsecret") 60 | val permittedUsers = System.getenv("markovbaj_backend_permittedusers").split(",") 61 | val checkedReferrer: String? = System.getenv("markovbaj_backend_checkedreferrer") 62 | } 63 | 64 | object Discord { 65 | val enabled: Boolean = System.getenv("markovbaj_discord_enabled") == "true" 66 | val botToken: String by lazy { System.getenv("markovbaj_discord_token") } 67 | val actuallySendReplies by lazy { System.getenv("markovbaj_discord_actuallysendreplies") == "true" } 68 | } 69 | 70 | object Twitch { 71 | val enabled: Boolean = System.getenv("markovbaj_twitch_enabled") == "true" 72 | val botToken: String by lazy { System.getenv("markovbaj_twitch_token") } 73 | val botUsername: String by lazy { System.getenv("markovbaj_twitch_username") } 74 | val activeChannel: String by lazy { System.getenv("markovbaj_twitch_activechannel") } 75 | val actuallySendReplies by lazy { System.getenv("markovbaj_twitch_actuallysendreplies") == "true" } 76 | } 77 | } -------------------------------------------------------------------------------- /src/jvmScriptsMain/kotlin/MessageSanitizer.kt: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import kotlin.io.encoding.Base64 4 | import kotlin.io.encoding.ExperimentalEncodingApi 5 | 6 | private sealed interface MessageExclusionCriteria { 7 | data class String(val value: kotlin.String) : MessageExclusionCriteria 8 | data class Regex(val value: kotlin.text.Regex) : MessageExclusionCriteria 9 | } 10 | 11 | private val emoteCodeMapping = mapOf( 12 | "9674" to "forsenE", 13 | "9677" to "gachiBASS", 14 | "9673" to "forsenDespair", 15 | "9685" to "forsenBased", 16 | "9682" to "OMEGALUL", 17 | "9669" to "Copesen", 18 | "9684" to "PagMan", 19 | "9679" to "Sadeg", 20 | "9672" to "forsenCD", 21 | "9671" to "BatChest", 22 | "9675" to ":tf:", 23 | "9666" to "Clueless", 24 | "9678" to "monkaOMEGA", 25 | "9683" to "WutFace", 26 | "9668" to "cmonBruh", 27 | "9680" to "pepeLaugh", 28 | "9676" to "FeelsOkayMan", 29 | "9681" to "LULE", 30 | "9670" to "forsenLevel", 31 | "9667" to "Okayeg", 32 | "10257" to "amongE", 33 | ) 34 | 35 | private val filteredAuthors = listOf( 36 | System.getenv("markovbaj_username").lowercase(), 37 | "[deleted]", 38 | ) 39 | 40 | private val replacedParts = mapOf String>( 41 | // Bot mentions 42 | Regex(CommonConstants.triggerKeyword, setOf(RegexOption.IGNORE_CASE, RegexOption.LITERAL)) to { "" }, 43 | // Emotes 44 | Regex("!?\\[img]\\(emote\\|.+?\\|([0-9]+)\\)", RegexOption.IGNORE_CASE) to { match -> emoteCodeMapping[match.groupValues[1]]?.let { " $it " } ?: "" }, 45 | // Reddit embedded GIFs 46 | Regex("!?\\[gif]\\(.+?\\)", RegexOption.IGNORE_CASE) to { "" }, 47 | // Remove Markdown links 48 | Regex("\\[(.*?)]\\(.*?\\)", RegexOption.IGNORE_CASE) to { it.groupValues[1] }, 49 | // Remove bare links 50 | // Regex("https?://.+?(?:$|\\s)", RegexOption.IGNORE_CASE) to { "" }, 51 | // Weird stuff with zero width spaces at the beginning of comments 52 | Regex("&#x200B;\\s*", RegexOption.IGNORE_CASE) to { "" }, 53 | // Unescape & 54 | Regex("&", RegexOption.IGNORE_CASE) to { "&" }, 55 | // Unescape < 56 | Regex("<", RegexOption.IGNORE_CASE) to { "<" }, 57 | // Unescape > 58 | Regex(">", RegexOption.IGNORE_CASE) to { ">" }, 59 | // Remove line breaks, handling them is just a pain 60 | Regex("\\n+", RegexOption.IGNORE_CASE) to { " " }, 61 | // Normalise spaces 62 | Regex(" +", RegexOption.IGNORE_CASE) to { " " }, 63 | // Remove remaining emotes and images 64 | // Regex("!?\\[(?:img|gif)]\\(.+\\)", RegexOption.IGNORE_CASE) to { "" }, 65 | ) 66 | 67 | private val encodedQuestionableStrings = listOf( 68 | "c2llZw==", 69 | "aGVpbA==", 70 | "a2lrZQ==", 71 | "Y2hpbms=", 72 | "ZmFn", 73 | "a3lz", 74 | "Z3Jvb20=", 75 | "MTQ=", 76 | "ODg=", 77 | "cmFwZQ==", 78 | ) 79 | 80 | @OptIn(ExperimentalEncodingApi::class) 81 | private val messageExclusionCriteriaWordParts = listOf( 82 | MessageExclusionCriteria.String("neg"), 83 | MessageExclusionCriteria.String("nek"), 84 | MessageExclusionCriteria.String("tran"), 85 | MessageExclusionCriteria.String("kfc"), 86 | MessageExclusionCriteria.String("melon"), 87 | MessageExclusionCriteria.String("black"), 88 | MessageExclusionCriteria.String("white"), 89 | MessageExclusionCriteria.String("afric"), 90 | MessageExclusionCriteria.String("migra"), 91 | MessageExclusionCriteria.String("crack"), 92 | MessageExclusionCriteria.String("kill"), 93 | MessageExclusionCriteria.String("uicid"), 94 | MessageExclusionCriteria.String("murd"), 95 | MessageExclusionCriteria.String("etar"), 96 | MessageExclusionCriteria.String("underag"), 97 | MessageExclusionCriteria.String("gun"), 98 | MessageExclusionCriteria.String("jew"), 99 | MessageExclusionCriteria.String("semit"), 100 | MessageExclusionCriteria.String("gender"), 101 | MessageExclusionCriteria.String("minor"), 102 | MessageExclusionCriteria.String("lgb"), 103 | MessageExclusionCriteria.String("sex"), 104 | MessageExclusionCriteria.String("gas"), 105 | MessageExclusionCriteria.String("genocid"), 106 | *encodedQuestionableStrings.map { MessageExclusionCriteria.String(Base64.decode(it).decodeToString()) }.toTypedArray(), 107 | MessageExclusionCriteria.Regex(Regex("n.?word", RegexOption.IGNORE_CASE)), 108 | MessageExclusionCriteria.Regex(Regex("shoo?t", RegexOption.IGNORE_CASE)), 109 | MessageExclusionCriteria.Regex(Regex("self.?harm", RegexOption.IGNORE_CASE)), 110 | MessageExclusionCriteria.Regex(Regex("\\brac(?:e\\b|is)", RegexOption.IGNORE_CASE)), 111 | MessageExclusionCriteria.Regex(Regex("hate (?:th|ni|'?em)", RegexOption.IGNORE_CASE)), 112 | MessageExclusionCriteria.Regex(Regex("\\bni(?:g|$|[^a-z])", RegexOption.IGNORE_CASE)), 113 | ) 114 | 115 | fun sanitizeComment(author: String, content: String) = 116 | if ( 117 | author.lowercase() !in filteredAuthors 118 | && messageExclusionCriteriaWordParts.none { 119 | when (it) { 120 | is MessageExclusionCriteria.String -> it.value in content.lowercase() 121 | is MessageExclusionCriteria.Regex -> it.value.containsMatchIn(content.lowercase()) 122 | } 123 | } 124 | ) { 125 | replacedParts.entries 126 | .fold(content) { acc, (replacedPart, replacingPart) -> acc.replace(replacedPart, replacingPart) } 127 | .trim() 128 | .takeIf { it.isNotEmpty() } 129 | } else { 130 | null 131 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/InternalApi.kt: -------------------------------------------------------------------------------- 1 | 2 | import io.ktor.server.application.* 3 | import io.ktor.server.cio.* 4 | import io.ktor.server.engine.* 5 | import io.ktor.server.routing.* 6 | import io.ktor.server.websocket.* 7 | import io.ktor.websocket.* 8 | import kotlinx.coroutines.CancellationException 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.datetime.Instant 11 | import kotlinx.serialization.SerialName 12 | import kotlinx.serialization.Serializable 13 | import kotlinx.serialization.encodeToString 14 | import kotlinx.serialization.json.Json 15 | import mu.KotlinLogging 16 | import net.dean.jraw.RedditClient 17 | 18 | private val logger = KotlinLogging.logger("MarkovBaj:InternalApi") 19 | 20 | @Serializable 21 | private sealed interface OneShotBackendApiRequest { 22 | @Serializable 23 | @SerialName("postRedditMessage") 24 | data class PostRedditMessage( 25 | val parentId: String, 26 | val content: String 27 | ) : OneShotBackendApiRequest 28 | } 29 | 30 | @Serializable 31 | private sealed interface OneShotBackendApiResponse { 32 | @Serializable 33 | @SerialName("success") 34 | data object Success : OneShotBackendApiResponse 35 | 36 | @Serializable 37 | @SerialName("error") 38 | data class Error( 39 | val error: String 40 | ) : OneShotBackendApiResponse 41 | } 42 | 43 | @Serializable 44 | sealed interface ApiEvent { 45 | @Serializable 46 | @SerialName("commentsCollected") 47 | data class CommentsCollected( 48 | val comments: List, 49 | ) : ApiEvent { 50 | @Serializable 51 | data class Comment( 52 | val created: Instant, 53 | val distinguished: String, 54 | val id: String, 55 | val fullName: String, 56 | val author: String, 57 | val body: String, 58 | val url: String?, 59 | val authorFlairText: String?, 60 | val submissionFullName: String, 61 | val submissionTitle: String?, 62 | val subredditType: String, 63 | val parentFullName: String, 64 | val subredditFullName: String, 65 | ) 66 | } 67 | 68 | @Serializable 69 | @SerialName("submissionsCollected") 70 | data class SubmissionsCollected( 71 | val submissions: List, 72 | ) : ApiEvent { 73 | @Serializable 74 | data class Submission( 75 | val created: Instant, 76 | val distinguished: String, 77 | val id: String, 78 | val author: String, 79 | val body: String?, 80 | val title: String, 81 | val url: String, 82 | val authorFlairText: String?, 83 | val domain: String, 84 | val embeddedMedia: Boolean, 85 | val isNsfw: Boolean, 86 | val isSelfPost: Boolean, 87 | val isSpoiler: Boolean, 88 | val linkFlairCssClass: String?, 89 | val linkFlairText: String?, 90 | val permalink: String, 91 | val postHint: String?, 92 | val preview: Boolean, 93 | val selfText: String?, 94 | val thumbnail: String?, 95 | val fullName: String, 96 | val subreddit: String, 97 | val subredditFullName: String, 98 | ) 99 | } 100 | } 101 | 102 | fun setupBackendApiWebsocketServer(redditClient: RedditClient?, json: Json, eventFlow: Flow) { 103 | embeddedServer( 104 | factory = CIO, 105 | host = "127.0.0.1", 106 | port = 14113, 107 | module = { 108 | install(WebSockets) 109 | 110 | routing { 111 | webSocket("/api/v1/oneshot") { 112 | logger.info { "Got /api/v1/oneshot connection." } 113 | 114 | try { 115 | for (frame in incoming) { 116 | try { 117 | when (val request = json.decodeFromString((frame as Frame.Text).readText())) { 118 | is OneShotBackendApiRequest.PostRedditMessage -> { 119 | if (redditClient != null) { 120 | redditClient.comment(request.parentId).reply(request.content) 121 | logger.info { "Commented '${request.content}' on comment ${request.parentId}." } 122 | send(Json.encodeToString(OneShotBackendApiResponse.Success)) 123 | } else { 124 | logger.warn { "Unable to comment as Reddit bot is not set up." } 125 | } 126 | } 127 | } 128 | } catch (e: Exception) { 129 | logger.error(e) { "Error while handling one shot API request:" } 130 | send(Json.encodeToString(OneShotBackendApiResponse.Error(error = e.stackTraceToString()))) 131 | } 132 | } 133 | 134 | close(CloseReason(CloseReason.Codes.NORMAL, "Message posted.")) 135 | } catch (e: Exception) { 136 | logger.error(e) { "Error while handling one shot API request connection:" } 137 | close(CloseReason(CloseReason.Codes.NOT_CONSISTENT, "Error while handling one shot API request connection.")) 138 | } 139 | 140 | logger.info { "/api/v1/oneshot connection disconnected." } 141 | } 142 | 143 | webSocket("/api/v1/events") { 144 | logger.info { "Got /api/v1/events connection." } 145 | 146 | try { 147 | eventFlow.collect { 148 | try { 149 | send(Frame.Text(Json.encodeToString(it))) 150 | } catch (e: CancellationException) { 151 | // This happens on every send even if it is successful for some reason, so just ignore it. 152 | } 153 | } 154 | } catch (e: Exception) { 155 | logger.error(e) { "Error while sending API event:" } 156 | close(CloseReason(CloseReason.Codes.NOT_CONSISTENT, "Error while sending API event.")) 157 | } 158 | 159 | logger.info { "/api/v1/events connection disconnected." } 160 | } 161 | } 162 | } 163 | ).start(wait = false) 164 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/MenuBox.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import BuildInfo 4 | import CompletedAchievement 5 | import LocalStorageBackedSnapshotStateMap 6 | import Styles 7 | import achievements 8 | import androidx.compose.runtime.* 9 | import kotlinx.browser.window 10 | import kotlinx.datetime.Instant 11 | import org.jetbrains.compose.web.attributes.ATarget 12 | import org.jetbrains.compose.web.attributes.Draggable 13 | import org.jetbrains.compose.web.attributes.target 14 | import org.jetbrains.compose.web.css.* 15 | import org.jetbrains.compose.web.dom.* 16 | 17 | @Composable 18 | fun MenuBox( 19 | achievementCompletionMap: LocalStorageBackedSnapshotStateMap 20 | ) { 21 | var expanded by remember { mutableStateOf(false) } 22 | 23 | Button( 24 | attrs = { 25 | classes(Styles.menuButton) 26 | 27 | onClick { 28 | expanded = !expanded 29 | } 30 | 31 | style { 32 | marginTop(if (expanded) 40.vh - Styles.bodyPadding else -Styles.bodyPadding) 33 | } 34 | } 35 | ) { 36 | Img( 37 | src = "img/menu_button.webp", 38 | attrs = { classes(Styles.menuButtonIcon) } 39 | ) 40 | } 41 | 42 | Div( 43 | attrs = { 44 | classes(Styles.menuContainer) 45 | 46 | style { 47 | marginTop(if (expanded) -Styles.bodyPadding else (-40).vh - Styles.bodyPadding) 48 | opacity(if (expanded) 100.percent else 0.percent) 49 | } 50 | } 51 | ) { 52 | H1(attrs = { classes(Styles.menuHeadline) }) { 53 | Text("Achievements (${achievementCompletionMap.size}/${achievements.size})") 54 | } 55 | 56 | Div(attrs = { classes(Styles.menuText) }) { 57 | Text("You cannot earn achievements about getting Markov to say specific phrases by using the exact same phrases to prompt a similar response. Hovering over / clicking the achievement will give you a hint.") 58 | } 59 | 60 | for (achievement in achievements) { 61 | Div(attrs = { 62 | classes(Styles.achievementContainer) 63 | 64 | val achievementDescription = achievementCompletionMap[achievement.id]?.let { 65 | """ 66 | ${achievement.description} 67 | 68 | Obtained: ${it.instant} 69 | Query: "${it.query}" 70 | Response: "${it.response}" 71 | """.trimIndent() 72 | } ?: run { 73 | achievement.description 74 | } 75 | 76 | title(achievementDescription) 77 | 78 | onClick { 79 | window.alert(achievementDescription) 80 | } 81 | }) { 82 | Div( 83 | attrs = { 84 | classes(Styles.achievementIconContainer) 85 | 86 | style { 87 | backgroundColor( 88 | if (achievementCompletionMap[achievement.id] != null) { 89 | Color.white 90 | } else { 91 | rgb(50, 50, 50) 92 | } 93 | ) 94 | } 95 | } 96 | ) { 97 | Img( 98 | src = "img/achievements/${achievement.id}.webp", 99 | attrs = { 100 | classes(Styles.achievementIcon) 101 | draggable(Draggable.False) 102 | } 103 | ) 104 | } 105 | 106 | Span( 107 | attrs = { 108 | classes(Styles.achievementCaption) 109 | 110 | style { 111 | color( 112 | if (achievementCompletionMap[achievement.id] != null) { 113 | Color.white 114 | } else { 115 | rgb(100, 100, 100) 116 | } 117 | ) 118 | } 119 | } 120 | ) { 121 | Text(achievement.name) 122 | } 123 | } 124 | } 125 | 126 | H1(attrs = { classes(Styles.menuHeadline) }) { 127 | Text("Credits") 128 | } 129 | 130 | Div(attrs = { classes(Styles.creditsLine) }) { 131 | Text("Design Lead / Visuals: 2pfrog") 132 | 133 | A(href = "https://twitter.com/2pfrog", attrs = { classes(Styles.socialMediaIcon); target(ATarget.Blank) }) { 134 | Img( 135 | src = "img/socialmedia/twitter.svg", 136 | attrs = { 137 | draggable(Draggable.False) 138 | } 139 | ) 140 | } 141 | } 142 | 143 | Div(attrs = { classes(Styles.creditsLine) }) { 144 | Text("Software Development Lead: the_marcster") 145 | 146 | A(href = "https://github.com/marczeugs", attrs = { classes(Styles.socialMediaIcon); target(ATarget.Blank) }) { 147 | Img( 148 | src = "img/socialmedia/github.svg", 149 | attrs = { 150 | draggable(Draggable.False) 151 | } 152 | ) 153 | } 154 | 155 | A(href = "https://www.twitch.tv/the_marcster", attrs = { classes(Styles.socialMediaIcon); target(ATarget.Blank) }) { 156 | Img( 157 | src = "img/socialmedia/twitch.svg", 158 | attrs = { 159 | draggable(Draggable.False) 160 | } 161 | ) 162 | } 163 | } 164 | 165 | Div( 166 | attrs = { 167 | classes(Styles.creditsLine) 168 | 169 | style { 170 | marginTop(24.px) 171 | } 172 | } 173 | ) { 174 | Text("Chief Stream Entertainment Leads: Scrafi1, ugworm_, okay_dudee, john7623, capybaraguy0, nishabtam, gakibas, toxcubed, Ramtinzx_, MrGreenMeme, AVlst, Peanut_Galaxy, FlamingDOTexe, Torrox_Morrox, racer4940, adamsero") 175 | } 176 | 177 | Div( 178 | attrs = { 179 | classes(Styles.menuText) 180 | 181 | style { 182 | marginTop(48.px) 183 | } 184 | } 185 | ) { 186 | Text("MarkovBaj Version ${BuildInfo.PROJECT_VERSION}, Frontend Build ${Instant.fromEpochMilliseconds(BuildInfo.PROJECT_BUILD_TIMESTAMP_MILLIS)}") 187 | } 188 | } 189 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/backend/Backend.kt: -------------------------------------------------------------------------------- 1 | package backend 2 | import InstantSerializer 3 | import MarkovChain 4 | import RuntimeVariables 5 | import TableDefinition 6 | import io.ktor.client.* 7 | import io.ktor.http.* 8 | import io.ktor.resources.* 9 | import io.ktor.server.application.* 10 | import io.ktor.server.auth.* 11 | import io.ktor.server.cio.* 12 | import io.ktor.server.engine.* 13 | import io.ktor.server.plugins.callloging.* 14 | import io.ktor.server.plugins.cors.routing.* 15 | import io.ktor.server.plugins.statuspages.* 16 | import io.ktor.server.resources.* 17 | import io.ktor.server.resources.Resources 18 | import io.ktor.server.response.* 19 | import io.ktor.server.routing.* 20 | import io.ktor.server.sessions.* 21 | import kotlinx.coroutines.delay 22 | import kotlinx.coroutines.isActive 23 | import kotlinx.coroutines.launch 24 | import kotlinx.datetime.Clock 25 | import kotlinx.datetime.Instant 26 | import kotlinx.serialization.SerialName 27 | import kotlinx.serialization.Serializable 28 | import kotlinx.serialization.encodeToString 29 | import kotlinx.serialization.json.Json 30 | import mu.KotlinLogging 31 | import net.dean.jraw.RedditClient 32 | import org.jetbrains.exposed.sql.* 33 | import org.jetbrains.exposed.sql.kotlin.datetime.KotlinInstantColumnType 34 | import org.jetbrains.exposed.sql.transactions.transaction 35 | import org.slf4j.event.Level 36 | import java.io.PrintWriter 37 | import java.io.StringWriter 38 | import kotlin.time.Duration.Companion.minutes 39 | 40 | @Serializable 41 | data class Session( 42 | val redditAccessToken: String, 43 | val redditRefreshToken: String?, 44 | @Serializable(with = InstantSerializer::class) val redditAccessTokenExpiration: Instant, 45 | ) 46 | 47 | object Routes { 48 | @Resource("/janitorbackend") 49 | @Serializable 50 | class JanitorBackend { 51 | @Resource("login") 52 | @Serializable 53 | data class Login(val janitorBackend: JanitorBackend = JanitorBackend()) 54 | 55 | @Resource("callback") 56 | @Serializable 57 | data class Callback(val janitorBackend: JanitorBackend = JanitorBackend()) 58 | 59 | @Resource("manage") 60 | @Serializable 61 | data class Manage(val janitorBackend: JanitorBackend = JanitorBackend()) 62 | 63 | @Resource("deletecomment") 64 | @Serializable 65 | data class DeleteComment(val janitorBackend: JanitorBackend = JanitorBackend(), @SerialName("comment_link") val commentLink: String) 66 | 67 | @Resource("styles.css") 68 | @Serializable 69 | data class StylesCss(val janitorBackend: JanitorBackend = JanitorBackend()) 70 | } 71 | 72 | @Resource("/lidlboards") 73 | @Serializable 74 | class Lidlboards 75 | 76 | @Resource("/api/v1") 77 | @Serializable 78 | class Api { 79 | @Resource("query") 80 | @Serializable 81 | data class Query(val api: Api = Api(), val input: String? = null) 82 | } 83 | } 84 | 85 | val logger = KotlinLogging.logger("MarkovBaj:Backend") 86 | 87 | val redditLoginRedirectUrl = "${RuntimeVariables.Backend.serverUrl}/janitorbackend/callback" 88 | 89 | fun setupBackendServer(redditClient: RedditClient?, json: Json, markovChain: MarkovChain) { 90 | embeddedServer( 91 | factory = CIO, 92 | host = "0.0.0.0", 93 | port = RuntimeVariables.Backend.serverPort, 94 | module = { 95 | backendModule(redditClient, json, markovChain) 96 | } 97 | ).start(wait = true) 98 | } 99 | 100 | fun Application.backendModule(redditClient: RedditClient?, json: Json, markovChain: MarkovChain) { 101 | Database.connect( 102 | url = "jdbc:postgresql://${RuntimeVariables.Backend.databaseUrl}", 103 | driver = "org.postgresql.Driver", 104 | user = RuntimeVariables.Backend.databaseUser, 105 | password = RuntimeVariables.Backend.databasePassword 106 | ) 107 | 108 | val tables = RuntimeVariables.Backend.databaseTables.associate { table -> 109 | table.displayName to (object : Table(table.name) { }).apply { 110 | table.columns.forEach { 111 | registerColumn( 112 | it.name, 113 | when (it.type) { 114 | TableDefinition.Column.Type.VarChar32 -> VarCharColumnType() 115 | TableDefinition.Column.Type.Text -> TextColumnType() 116 | TableDefinition.Column.Type.Integer -> IntegerColumnType() 117 | TableDefinition.Column.Type.Boolean -> BooleanColumnType() 118 | TableDefinition.Column.Type.Timestamp -> KotlinInstantColumnType() 119 | } 120 | ) 121 | } 122 | } 123 | } 124 | 125 | var tableValues: Map, List>>> = mapOf() 126 | var latestTableValuesUpdateInstant: Instant? = null 127 | 128 | launch { 129 | while (isActive) { 130 | tableValues = transaction { 131 | RuntimeVariables.Backend.databaseTables.associate { tableDefinition -> 132 | val actualTable = tables[tableDefinition.displayName]!! 133 | 134 | tableDefinition.displayName to ( 135 | tableDefinition.columns.map { it.displayName } to 136 | actualTable.selectAll().limit(RuntimeVariables.Backend.databaseTableRowLimit).map { row -> 137 | actualTable.columns.map { row[it]!!.toString() } 138 | } 139 | ) 140 | } 141 | } 142 | 143 | latestTableValuesUpdateInstant = Clock.System.now() 144 | 145 | delay(10.minutes) 146 | } 147 | } 148 | 149 | install(CallLogging) { 150 | level = Level.TRACE 151 | } 152 | 153 | install(StatusPages) { 154 | exception { call, cause -> 155 | call.respond( 156 | HttpStatusCode.InternalServerError, 157 | "Internal server error:\n${StringWriter().also { cause.printStackTrace(PrintWriter(it)) }}" 158 | ) 159 | } 160 | } 161 | 162 | install(Sessions) { 163 | cookie("reddit_login") { 164 | serializer = object : SessionSerializer { 165 | override fun deserialize(text: String) = json.decodeFromString(text) 166 | override fun serialize(session: Session) = json.encodeToString(session) 167 | } 168 | } 169 | } 170 | 171 | install(CORS) { 172 | anyHost() 173 | allowHeaders { true } 174 | allowMethod(HttpMethod.Get) 175 | } 176 | 177 | install(Resources) 178 | 179 | val redditOAuthName = "reddit-oauth" 180 | 181 | authentication { 182 | oauth(redditOAuthName) { 183 | urlProvider = { redditLoginRedirectUrl } 184 | providerLookup = { 185 | OAuthServerSettings.OAuth2ServerSettings( 186 | name = "reddit", 187 | authorizeUrl = "https://www.reddit.com/api/v1/authorize", 188 | accessTokenUrl = "https://www.reddit.com/api/v1/access_token", 189 | accessTokenRequiresBasicAuth = true, 190 | requestMethod = HttpMethod.Post, 191 | clientId = RuntimeVariables.Backend.redditClientId, 192 | clientSecret = RuntimeVariables.Backend.redditClientSecret, 193 | defaultScopes = listOf("identity") 194 | ) 195 | } 196 | client = HttpClient(io.ktor.client.engine.cio.CIO) 197 | } 198 | } 199 | 200 | routing { 201 | get { 202 | janitorBackendLogin() 203 | } 204 | 205 | authenticate(redditOAuthName) { 206 | get { 207 | // Redirects to `authorizeUrl` automatically 208 | } 209 | 210 | get { 211 | janitorBackendCallback() 212 | } 213 | } 214 | 215 | get { 216 | janitorBackendManage() 217 | } 218 | 219 | get { 220 | janitorBackendDeleteComment(redditClient, it) 221 | } 222 | 223 | get { 224 | janitorBackendStyles() 225 | } 226 | 227 | get { queryInput -> 228 | apiQuery(queryInput, markovChain) 229 | } 230 | 231 | get { 232 | lidlboards(latestTableValuesUpdateInstant, tableValues) 233 | } 234 | } 235 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/backend/Lidlboards.kt: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.html.* 5 | import io.ktor.util.pipeline.* 6 | import kotlinx.css.* 7 | import kotlinx.css.properties.border 8 | import kotlinx.datetime.Instant 9 | import kotlinx.html.* 10 | 11 | suspend fun PipelineContext.lidlboards( 12 | latestTableValuesUpdateInstant: Instant?, 13 | tableValues: Map, List>>> 14 | ) { 15 | call.respondHtml { 16 | head { 17 | title("MarkovBaj Lidlboards") 18 | 19 | meta(name = "viewport", content = "width=device-width, initial-scale=1") 20 | 21 | style { 22 | unsafe { 23 | +CssBuilder().apply { 24 | "body" { 25 | fontFamily = "Arial" 26 | margin(0.px) 27 | position = Position.relative 28 | } 29 | 30 | "div.header" { 31 | position = Position.sticky 32 | top = 0.px 33 | backgroundColor = Color.cornflowerBlue 34 | color = Color.white 35 | display = Display.grid 36 | gridTemplateColumns = GridTemplateColumns(1.fr, 1.fr) 37 | gridTemplateRows = GridTemplateRows(1.fr) 38 | alignItems = Align.center 39 | paddingLeft = 32.px 40 | paddingRight = 32.px 41 | paddingTop = 16.px 42 | paddingBottom = 16.px 43 | wordBreak = WordBreak.breakWord 44 | } 45 | 46 | "div.content" { 47 | padding(16.px) 48 | display = Display.flex 49 | gap = 16.px 50 | alignItems = Align.flexStart 51 | overflowX = Overflow.scroll 52 | } 53 | 54 | "table" { 55 | minWidth = 500.px 56 | border(1.px, BorderStyle.solid, Color.black) 57 | borderCollapse = BorderCollapse.collapse 58 | } 59 | 60 | "button.expand-toggle" { 61 | margin(8.px) 62 | padding(8.px) 63 | flexShrink = 0 64 | } 65 | 66 | "table.collapsed tr:not(:first-child)" { 67 | display = Display.none 68 | } 69 | 70 | "tr" { 71 | border(1.px, BorderStyle.solid, Color.black) 72 | } 73 | 74 | "th, td" { 75 | textAlign = TextAlign.left 76 | padding(8.px) 77 | } 78 | 79 | media("(max-width: 480px)") { 80 | "table" { 81 | width = 100.vw 82 | minWidth = LinearDimension.auto 83 | wordBreak = WordBreak.breakWord 84 | fontSize = 10.pt 85 | } 86 | 87 | "th, td" { 88 | padding(6.px) 89 | } 90 | 91 | "div.header" { 92 | gridTemplateColumns = GridTemplateColumns(1.fr) 93 | gridTemplateRows = GridTemplateRows("auto auto") 94 | justifyItems = JustifyItems.center 95 | paddingTop = 8.px 96 | paddingBottom = 8.px 97 | } 98 | 99 | "div.header div" { 100 | marginTop = 8.px 101 | marginBottom = 8.px 102 | put("text-align", "center !important") 103 | } 104 | 105 | "div.content" { 106 | padding(8.px) 107 | flexWrap = FlexWrap.wrap 108 | justifyItems = JustifyItems.center 109 | overflowX = Overflow.visible 110 | } 111 | } 112 | }.toString() 113 | } 114 | } 115 | 116 | script { 117 | unsafe { 118 | +""" 119 | function updateExpandToggleButtons() { 120 | for (const button of document.querySelectorAll('button.expand-toggle')) { 121 | if (button.closest('table').classList.contains('collapsed')) { 122 | button.textContent = 'Expand'; 123 | } else { 124 | button.textContent = 'Collapse'; 125 | } 126 | } 127 | } 128 | 129 | addEventListener('DOMContentLoaded', _ => { 130 | window.onresize(); 131 | updateExpandToggleButtons(); 132 | }); 133 | 134 | let lastWindowWidth = window.innerWidth - 1; 135 | 136 | window.onresize = _ => { 137 | if (window.innerWidth < 480 && window.innerWidth !== lastWindowWidth) { 138 | for (const table of document.querySelectorAll('table')) { 139 | table.classList.add('collapsed'); 140 | } 141 | 142 | updateExpandToggleButtons(); 143 | 144 | lastWindowWidth = window.innerWidth; 145 | } 146 | }; 147 | """.trimIndent() 148 | } 149 | } 150 | } 151 | 152 | body { 153 | div(classes = "header") { 154 | div { 155 | style = "font-size: 32px;" 156 | +"MarkovBaj Lidlboards" 157 | } 158 | 159 | div { 160 | style = "text-align: right;" 161 | +"Last updated at: $latestTableValuesUpdateInstant" 162 | } 163 | } 164 | 165 | div(classes = "content") { 166 | tableValues.forEach { (tableDisplayName, values) -> 167 | val (headers, rows) = values 168 | 169 | table { 170 | tr { 171 | th { 172 | colSpan = headers.size.toString() 173 | 174 | span { 175 | style = "display: flex; justify-content: space-between; align-items: center; padding-left: 16px;" 176 | 177 | +tableDisplayName 178 | 179 | button(classes = "expand-toggle") { 180 | onClick = "this.closest('table').classList.toggle('collapsed'); updateExpandToggleButtons();" 181 | +"" 182 | } 183 | } 184 | } 185 | } 186 | 187 | tr { 188 | headers.forEach { th { +it } } 189 | } 190 | 191 | rows.forEach { row -> 192 | tr { 193 | row.forEach { 194 | td { 195 | if (it.startsWith("https://")) { 196 | a(href = it) { 197 | +"Link" 198 | } 199 | } else { 200 | +it 201 | } 202 | } 203 | } 204 | } 205 | } 206 | } 207 | } 208 | } 209 | } 210 | } 211 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright � 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions �$var�, �${var}�, �${var:-default}�, �${var+SET}�, 36 | # �${var#prefix}�, �${var%suffix}�, and �$( cmd )�; 37 | # * compound commands having a testable exit status, especially �case�; 38 | # * various built-in commands including �command�, �set�, and �ulimit�. 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/ChatMessages.kt: -------------------------------------------------------------------------------- 1 | package components 2 | import CompletedAchievement 3 | import LocalStorageBackedSnapshotStateMap 4 | import ScrollHandler 5 | import Styles 6 | import achievements 7 | import androidx.compose.runtime.* 8 | import kotlinx.browser.window 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.isActive 11 | import org.jetbrains.compose.web.attributes.Draggable 12 | import org.jetbrains.compose.web.css.* 13 | import org.jetbrains.compose.web.dom.Div 14 | import org.jetbrains.compose.web.dom.Img 15 | import org.jetbrains.compose.web.dom.Span 16 | import org.jetbrains.compose.web.dom.Text 17 | import kotlin.random.Random 18 | import kotlin.time.Duration.Companion.milliseconds 19 | 20 | data class ChatMessage( 21 | val owner: Owner, 22 | val content: Content 23 | ) { 24 | enum class Owner { 25 | Markov, 26 | User 27 | } 28 | 29 | sealed interface Content { 30 | object Loading : Content 31 | value class Text(val text: String) : Content 32 | value class Error(val text: String) : Content 33 | } 34 | } 35 | 36 | private const val CORNER_IMAGE_COUNT = 7 37 | private const val SIDE_IMAGE_COUNT = 4 38 | 39 | @Composable 40 | fun ChatMessages( 41 | messages: List, 42 | muted: Boolean, 43 | onMutedChanged: (muted: Boolean) -> Unit, 44 | achievementCompletionMap: LocalStorageBackedSnapshotStateMap 45 | ) { 46 | val scrollHandler = remember { ScrollHandler() } 47 | 48 | LaunchedEffect(messages) { 49 | scrollHandler.scrollBy(0.0, 20000.0) 50 | } 51 | 52 | Div(attrs = { classes(Styles.chatContainer) }) { 53 | Img( 54 | src = "img/achievements/trophy.webp", 55 | attrs = { 56 | classes(Styles.achievementCompleteTrophyIcon) 57 | 58 | style { 59 | if (achievementCompletionMap.size != achievements.size) { 60 | display(DisplayStyle.None) 61 | } 62 | } 63 | 64 | draggable(Draggable.False) 65 | title("You have completed all the achievements! Never doubt the god gamer forsenSmug") 66 | 67 | onClick { 68 | window.alert("You have completed all the achievements! Never doubt the god gamer forsenSmug") 69 | } 70 | } 71 | ) 72 | 73 | Div( 74 | attrs = { 75 | classes(Styles.ttsMutedSettingContainer) 76 | 77 | onClick { 78 | onMutedChanged(!muted) 79 | } 80 | } 81 | ) { 82 | Img( 83 | src = "img/tts_off.webp", 84 | attrs = { 85 | classes(Styles.ttsMutedSettingIcon, *(if (muted) arrayOf() else arrayOf(Styles.hidden))) 86 | draggable(Draggable.False) 87 | } 88 | ) 89 | 90 | Img( 91 | src = "img/tts_on.webp", 92 | attrs = { 93 | classes(Styles.ttsMutedSettingIcon, *(if (!muted) arrayOf() else arrayOf(Styles.hidden))) 94 | draggable(Draggable.False) 95 | } 96 | ) 97 | } 98 | 99 | Div(attrs = { classes(Styles.chatBackground) }) 100 | 101 | Div(attrs = { classes(Styles.chatBorderContainer) }) { 102 | for (i in 0..<4) { 103 | val cornerImageIndex = remember { Random.nextInt(CORNER_IMAGE_COUNT) } 104 | 105 | Img( 106 | src = "img/chatmessages/box_corner_$cornerImageIndex.webp", 107 | attrs = { 108 | classes(Styles.chatBorderCorner) 109 | 110 | draggable(Draggable.False) 111 | 112 | style { 113 | gridColumn((1 + (i / 2) * 2).toString()) 114 | gridRow((1 + (i % 2) * 2).toString()) 115 | 116 | transform { 117 | rotate( 118 | when (i) { 119 | 0 -> 90.deg 120 | 1 -> 0.deg 121 | 2 -> 180.deg 122 | 3 -> 270.deg 123 | else -> error("Unreachable") 124 | } 125 | ) 126 | } 127 | } 128 | } 129 | ) 130 | } 131 | 132 | for (i in 0..<2) { 133 | val sideImageIndex = remember { Random.nextInt(SIDE_IMAGE_COUNT) } 134 | 135 | Div( 136 | attrs = { 137 | style { 138 | backgroundImage("url('img/chatmessages/box_side_${sideImageIndex}_horizontal.webp')") 139 | backgroundRepeat("repeat-x") 140 | margin(10.px) 141 | height(23.px) 142 | backgroundSize("auto 23px") 143 | gridColumn(1, 4) 144 | gridRow((1 + i * 2).toString()) 145 | 146 | if (i == 1) { 147 | alignSelf(AlignSelf.End) 148 | } 149 | } 150 | } 151 | ) { } 152 | } 153 | 154 | for (i in 0..<2) { 155 | val sideImageIndex = remember { Random.nextInt(SIDE_IMAGE_COUNT) } 156 | 157 | Div( 158 | attrs = { 159 | style { 160 | backgroundImage("url('img/chatmessages/box_side_${sideImageIndex}_vertical.webp')") 161 | backgroundRepeat("repeat-y") 162 | margin(10.px) 163 | width(23.px) 164 | backgroundSize("23px auto") 165 | gridColumn((1 + i * 2).toString()) 166 | gridRow(1, 4) 167 | 168 | if (i == 1) { 169 | justifySelf("end") 170 | } 171 | } 172 | } 173 | ) { 174 | 175 | } 176 | } 177 | 178 | Div( 179 | attrs = { 180 | classes(Styles.chatMessageContainer) 181 | scrollHandler.run { install() } // Should probably be implemented with a context receiver as soon as they are stable 182 | } 183 | ) { 184 | for (message in messages) { 185 | Div(attrs = { classes(Styles.chatMessage) }) { 186 | Span(attrs = { classes(if (message.owner == ChatMessage.Owner.User) Styles.chatMessageConsoleUser else Styles.chatMessageConsoleMarkov) }) { 187 | Text("${if (message.owner == ChatMessage.Owner.User) "baj" else "markov"}@markovonline") 188 | } 189 | 190 | Span(attrs = { classes(Styles.chatMessageConsoleUnimportant) }) { 191 | Text(":") 192 | } 193 | 194 | Span(attrs = { classes(Styles.chatMessageConsoleLocation) }) { 195 | Text("~") 196 | } 197 | 198 | Span(attrs = { classes(Styles.chatMessageConsoleUnimportant) }) { 199 | Text("$ ") 200 | } 201 | 202 | when (message.content) { 203 | ChatMessage.Content.Loading -> { 204 | var loadingIconCharacterIndex by remember { mutableStateOf(0) } 205 | 206 | LaunchedEffect(Unit) { 207 | while (isActive) { 208 | loadingIconCharacterIndex++ 209 | delay(200.milliseconds) 210 | } 211 | } 212 | 213 | Span { 214 | Text("[${ 215 | when (loadingIconCharacterIndex % 4) { 216 | 0 -> '|' 217 | 1 -> '/' 218 | 2 -> '-' 219 | 3 -> '\\' 220 | else -> error("Unreachable") 221 | } 222 | }]") 223 | } 224 | } 225 | is ChatMessage.Content.Text -> { 226 | Span { 227 | Text(message.content.text) 228 | } 229 | } 230 | is ChatMessage.Content.Error -> { 231 | Span(attrs = { classes(Styles.chatMessageError) }) { 232 | Text("[EXCEPTION] ${message.content.text}") 233 | } 234 | } 235 | } 236 | } 237 | } 238 | } 239 | } 240 | } 241 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/App.kt: -------------------------------------------------------------------------------- 1 | 2 | import androidx.compose.runtime.* 3 | import components.* 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.engine.js.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.request.* 9 | import io.ktor.client.statement.* 10 | import io.ktor.http.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import kotlinx.browser.document 13 | import kotlinx.browser.window 14 | import kotlinx.coroutines.* 15 | import kotlinx.coroutines.flow.MutableSharedFlow 16 | import kotlinx.datetime.Clock 17 | import org.jetbrains.compose.web.css.Style 18 | import org.jetbrains.compose.web.dom.Div 19 | import org.w3c.dom.HTMLElement 20 | import org.w3c.files.Blob 21 | import org.w3c.files.BlobPropertyBag 22 | import kotlin.coroutines.resume 23 | import kotlin.coroutines.suspendCoroutine 24 | import kotlin.time.Duration.Companion.milliseconds 25 | import kotlin.time.Duration.Companion.seconds 26 | 27 | val httpClient = HttpClient(Js) { 28 | install(ContentNegotiation) { 29 | json() 30 | } 31 | } 32 | 33 | 34 | private val easterEggInputQueue = listOf("ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a") 35 | 36 | @Composable 37 | fun App() { 38 | var talking by remember { mutableStateOf(false) } 39 | var messages by remember { mutableStateOf(listOf()) } 40 | var muted by remember { mutableStateOf(false) } 41 | 42 | val achievementCompletionMap = remember { LocalStorageBackedSnapshotStateMap("completedAchievements") } 43 | val notificationQueue = remember { MutableSharedFlow(replay = 10) } 44 | 45 | var easterEggInputQueueProgress by remember { mutableStateOf(0) } 46 | 47 | LaunchedEffect(Unit) { 48 | window.onkeydown = { 49 | if (it.key == easterEggInputQueue[easterEggInputQueueProgress]) { 50 | easterEggInputQueueProgress++ 51 | } 52 | 53 | if (easterEggInputQueueProgress == easterEggInputQueue.size) { 54 | document.body!!.innerHTML = "
" 55 | easterEggInputQueueProgress = 0 56 | } 57 | } 58 | } 59 | 60 | LaunchedEffect(Unit) { 61 | // Shitty hack to fix 100vh excluding URL bar on mobile devices, see: https://stackoverflow.com/questions/52848856/100vh-height-when-address-bar-is-shown-chrome-mobile 62 | (window.document.documentElement as HTMLElement).style.setProperty("--vh", "${window.innerHeight.toDouble() / 100}px") 63 | 64 | window.onresize = { 65 | (window.document.documentElement as HTMLElement).style.setProperty("--vh", "${window.innerHeight.toDouble() / 100}px") 66 | } 67 | } 68 | 69 | Style(Styles) 70 | 71 | Div(attrs = { classes(Styles.rootElement) }) { 72 | NotificationDisplay( 73 | notificationQueue = notificationQueue 74 | ) 75 | 76 | MenuBox( 77 | achievementCompletionMap = achievementCompletionMap 78 | ) 79 | 80 | Markov( 81 | talking = talking, 82 | muted = muted 83 | ) 84 | 85 | ChatMessages( 86 | messages = messages, 87 | muted = muted, 88 | onMutedChanged = { 89 | muted = it 90 | }, 91 | achievementCompletionMap = achievementCompletionMap 92 | ) 93 | 94 | ChatInput( 95 | onMessageSent = { message -> 96 | messages = messages + ChatMessage( 97 | owner = ChatMessage.Owner.User, 98 | content = ChatMessage.Content.Text(message) 99 | ) 100 | 101 | delay(0.5.seconds) 102 | 103 | val messagesBeforeAnswer = messages 104 | 105 | messages = messagesBeforeAnswer + ChatMessage( 106 | owner = ChatMessage.Owner.Markov, 107 | content = ChatMessage.Content.Loading 108 | ) 109 | 110 | delay(1.seconds) 111 | 112 | try { 113 | val response = withTimeout(10.seconds) { 114 | httpClient.get(if ("localhost" in window.location.href) "http://localhost:7777/api/v1/query?input=$message" else "/api/v1/query?input=$message").let { 115 | if (it.status.isSuccess()) { 116 | it.body() 117 | } else { 118 | throw MarkovBackendException(it.body()) 119 | } 120 | } 121 | } 122 | 123 | val ttsResponse = if (!muted) { 124 | withTimeout(10.seconds) { 125 | httpClient.get("https://api.streamelements.com/kappa/v2/speech") { 126 | parameter("voice", "Brian") 127 | parameter("text", response) 128 | }.also { 129 | check(it.status.isSuccess()) { it.bodyAsText() } 130 | } 131 | } 132 | } else { 133 | null 134 | } 135 | 136 | for (achievement in achievements) { 137 | if ( 138 | when (achievement.matcher) { 139 | is Achievement.Matcher.KeywordList -> achievement.matcher.keywords.any { it in response.lowercase() && it !in message.lowercase() } 140 | is Achievement.Matcher.Lambda -> achievement.matcher.matcher(message, response) 141 | is Achievement.Matcher.Regex -> achievement.matcher.regex.containsMatchIn(response.lowercase()) && !achievement.matcher.regex.containsMatchIn(message.lowercase()) 142 | } 143 | && achievement.id !in achievementCompletionMap 144 | ) { 145 | achievementCompletionMap[achievement.id] = CompletedAchievement( 146 | instant = Clock.System.now(), 147 | query = message, 148 | response = response 149 | ) 150 | 151 | console.log("Message \"$message\" with response \"$response\" rewarded user with achievement \"${achievement.name}\".") 152 | notificationQueue.tryEmit("Achievement unlocked: ${achievement.name}") 153 | } 154 | } 155 | 156 | messages = messagesBeforeAnswer + ChatMessage( 157 | owner = ChatMessage.Owner.Markov, 158 | content = ChatMessage.Content.Text(response) 159 | ) 160 | 161 | talking = true 162 | 163 | if (ttsResponse != null) { 164 | val audio = Audio(URL.createObjectURL(Blob(arrayOf(ttsResponse.readBytes()), BlobPropertyBag(type = "audio/mpeg")))) 165 | 166 | suspendCoroutine { 167 | var cancelled = false 168 | 169 | val scope = CoroutineScope(Dispatchers.Main) 170 | 171 | val muteListener = scope.launch { 172 | while (isActive) { 173 | if (muted) { 174 | cancelled = true 175 | it.resume(Unit) 176 | audio.pause() 177 | 178 | break 179 | } 180 | 181 | delay(100.milliseconds) 182 | } 183 | } 184 | 185 | audio.onended = { 186 | if (!cancelled) { 187 | scope.launch { 188 | muteListener.cancelAndJoin() 189 | } 190 | 191 | it.resume(Unit) 192 | } 193 | } 194 | 195 | audio.play() 196 | } 197 | } 198 | 199 | true 200 | } catch (e: MarkovBackendException) { 201 | console.error("Backend error while requesting response:", e) 202 | 203 | messages = messagesBeforeAnswer + ChatMessage( 204 | owner = ChatMessage.Owner.Markov, 205 | content = ChatMessage.Content.Error(e.information) 206 | ) 207 | 208 | false 209 | } catch (e: Exception) { 210 | console.error("Error while requesting response:", e) 211 | 212 | messages = messagesBeforeAnswer + ChatMessage( 213 | owner = ChatMessage.Owner.Markov, 214 | content = ChatMessage.Content.Error("Could not fetch response.") 215 | ) 216 | 217 | false 218 | } finally { 219 | talking = false 220 | } 221 | } 222 | ) 223 | } 224 | } 225 | 226 | class MarkovBackendException(val information: String) : Exception() -------------------------------------------------------------------------------- /src/jsMain/kotlin/Achievements.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.Instant 2 | import kotlinx.serialization.Serializable 3 | 4 | data class Achievement( 5 | val id: Int, 6 | val name: String, 7 | val description: String, 8 | val matcher: Matcher 9 | ) { 10 | sealed interface Matcher { 11 | data class KeywordList(val keywords: List) : Matcher 12 | data class Regex(val regex: kotlin.text.Regex) : Matcher 13 | data class Lambda(val matcher: (input: String, output: String) -> Boolean) : Matcher 14 | } 15 | } 16 | 17 | @Serializable 18 | data class CompletedAchievement( 19 | val instant: Instant, 20 | val query: String, 21 | val response: String 22 | ) 23 | 24 | private val lightningEmotes = listOf("\uD83C\uDF29", "\u26A1") 25 | 26 | val achievements = listOf( 27 | Achievement( 28 | id = 1, 29 | name = "Capybara", 30 | description = "Make Markov mention capybaras", 31 | matcher = Achievement.Matcher.KeywordList(listOf("capybara")) 32 | ), 33 | Achievement( 34 | id = 2, 35 | name = "xqcL", 36 | description = "Make Markov mention xQc", 37 | matcher = Achievement.Matcher.KeywordList(listOf("xqc")) 38 | ), 39 | Achievement( 40 | id = 3, 41 | name = "CV Paste", 42 | description = "Have Markov copy your message", 43 | matcher = Achievement.Matcher.Lambda { input, output -> 44 | input == output 45 | } 46 | ), 47 | Achievement( 48 | id = 4, 49 | name = "Forsen Related", 50 | description = "Make Markov use the word \"forsen\" at least 20 times in one response", 51 | matcher = Achievement.Matcher.Lambda { input, output -> 52 | output.lowercase().split(Regex("\\bforsen\\b")).size >= 21 && "forsen forsen" !in input.lowercase() 53 | } 54 | ), 55 | Achievement( 56 | id = 5, 57 | name = "+20 Social Credit Points", 58 | description = "Make Markov speak Chinese", 59 | matcher = Achievement.Matcher.Regex(Regex("[\\u4e00-\\u9fff]")) 60 | ), 61 | Achievement( 62 | id = 6, 63 | name = "Arigato", 64 | description = "Make Markov speak Japanese", 65 | matcher = Achievement.Matcher.Regex(Regex("[\\u3040-\\u309f]")) 66 | ), 67 | Achievement( 68 | id = 7, 69 | name = "Cyka Blyat", 70 | description = "Make Markov speak Russian", 71 | matcher = Achievement.Matcher.Regex(Regex("[\\u0400-\\u04ff]")) 72 | ), 73 | Achievement( 74 | id = 8, 75 | name = "Halal", 76 | description = "Make Markov speak Arabic", 77 | matcher = Achievement.Matcher.Regex(Regex("[\\u0600-\\u06ff]")) 78 | ), 79 | Achievement( 80 | id = 9, 81 | name = "TÜRKIYE 💪", 82 | description = "Make Markov speak Turkish", 83 | matcher = Achievement.Matcher.Regex(Regex("[\\u011f\\u0131\\u015f]")) 84 | ), 85 | Achievement( 86 | id = 10, 87 | name = ":tf: 🤜 🔔", 88 | description = "Make Markov ping someone", 89 | matcher = Achievement.Matcher.KeywordList(listOf("u/")) 90 | ), 91 | Achievement( 92 | id = 11, 93 | name = "Please clarify", 94 | description = "Make Markov ping u/TheSeaHorseHS", 95 | matcher = Achievement.Matcher.KeywordList(listOf("u/theseahorsehs")) 96 | ), 97 | Achievement( 98 | id = 12, 99 | name = "uwu", 100 | description = "Make Markov ping u/Furry_Degen", 101 | matcher = Achievement.Matcher.KeywordList(listOf("u/furry_degen")) 102 | ), 103 | Achievement( 104 | id = 13, 105 | name = "Not Suge Knight", 106 | description = "Make Markov talk about shungite", 107 | matcher = Achievement.Matcher.KeywordList(listOf("shungite")) 108 | ), 109 | Achievement( 110 | id = 14, 111 | name = "Kinda Snus", 112 | description = "Make Markov mention something Among Us related", 113 | matcher = Achievement.Matcher.KeywordList(listOf("among us", "amogus", "amonge", "sus ", "sussy", "crewmate", "impostor", "imposter", "emergency meeting", "ඞ")) 114 | ), 115 | Achievement( 116 | id = 15, 117 | name = "forsen", 118 | description = "Make Markov use a singular \"forsen\" as a response", 119 | matcher = Achievement.Matcher.Lambda { _, output -> 120 | output == "forsen" 121 | } 122 | ), 123 | Achievement( 124 | id = 16, 125 | name = "MrDestructoid", 126 | description = "Make Markov speak binary", 127 | matcher = Achievement.Matcher.Regex(Regex("\\b[01]{8}\\b")) 128 | ), 129 | Achievement( 130 | id = 17, 131 | name = "Concerned", 132 | description = "Make Markov ask if you have any questions or concerns", 133 | matcher = Achievement.Matcher.KeywordList(listOf("you have any questions or concerns")) 134 | ), 135 | Achievement( 136 | id = 18, 137 | name = "forsenCD", 138 | description = "Make Markov give you a completely transparent response", 139 | matcher = Achievement.Matcher.Regex(Regex("^\\u200b$")) 140 | ), 141 | Achievement( 142 | id = 19, 143 | name = "fr fr ong", 144 | description = "Make Markov say zoomer shit", 145 | matcher = Achievement.Matcher.Regex(Regex("\\bfr\\b|\\bong\\b|on god|deadass|bussin|no cap|goated")) 146 | ), 147 | Achievement( 148 | id = 20, 149 | name = "YOURMOM", 150 | description = "Make Markov talk about your mother", 151 | matcher = Achievement.Matcher.KeywordList(listOf("your mom", "your mother", "your mum")) 152 | ), 153 | Achievement( 154 | id = 21, 155 | name = "Different people Copesen", 156 | description = "Make Markov mention Gura", 157 | matcher = Achievement.Matcher.KeywordList(listOf("gura")) 158 | ), 159 | Achievement( 160 | id = 22, 161 | name = "forsenLevel", 162 | description = "Make Markov level", 163 | matcher = Achievement.Matcher.KeywordList(listOf("forsenlevel")) 164 | ), 165 | Achievement( 166 | id = 23, 167 | name = "Arcane", 168 | description = "Make Markov talk about Arcane", 169 | matcher = Achievement.Matcher.KeywordList(listOf("caitlyn", "jinx", "arcane")) 170 | ), 171 | Achievement( 172 | id = 24, 173 | name = "BatChest", 174 | description = "Make Markov bat IRL", 175 | matcher = Achievement.Matcher.KeywordList(listOf("marvel", "thanos", "batchest")) 176 | ), 177 | Achievement( 178 | id = 25, 179 | name = "FUCK♂YOU", 180 | description = "Make Markov talk about Gachi", 181 | matcher = Achievement.Matcher.KeywordList(listOf("gachi", "billy herrington")) 182 | ), 183 | Achievement( 184 | id = 26, 185 | name = "💦", 186 | description = "Make Markov coom to something", 187 | matcher = Achievement.Matcher.KeywordList(listOf("forsencoomer")) 188 | ), 189 | Achievement( 190 | id = 27, 191 | name = "NURSE??? 🔔", 192 | description = "Make Markov suggest that you take your pills", 193 | matcher = Achievement.Matcher.Regex(Regex("take (?:your|my|the) (?:fucking? )?(?:pills|meds|medicine)")) 194 | ), 195 | Achievement( 196 | id = 28, 197 | name = "You Should ...", 198 | description = "Make Markov use both the \uD83C\uDF29 (thunderstorm) emoji as well as \"NOW\" in the same response", 199 | matcher = Achievement.Matcher.Lambda { input, output -> 200 | lightningEmotes.any { it in output } && "NOW" in output && lightningEmotes.none { it in input } && "NOW" !in input 201 | } 202 | ), 203 | Achievement( 204 | id = 29, 205 | name = "Punk Kid", 206 | description = "Make Markov post the Among Us crewmate cock copypasta", 207 | matcher = Achievement.Matcher.KeywordList(listOf("⣿⣿⣿⠟⢹⣶⣶⣝⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿")) 208 | ), 209 | Achievement( 210 | id = 30, 211 | name = "monkaLaugh", 212 | description = "Make Markov mention Hitler", 213 | matcher = Achievement.Matcher.KeywordList(listOf("hitler")) 214 | ), 215 | Achievement( 216 | id = 31, 217 | name = "The Witch", 218 | description = "Make Markov mention N̵̨̮͉̟̣̥̰̫̹̣̱̩̭̦̿̅̆̈́̌́̍̾́͆͒̄̒̚̚i̷̧̩̯͖̖͎̱̹̿̓̈́̑̐͗͂̓́̇̕͠ņ̶̡̠̞̖͓͓̹̳̦̞͍̈̔̈́̉̀́̓͗̕͜a̴̡̨̫̤͈̳̜̺̩̗̗̖̼̖͔̍̅͑͋̈́͑̾̾̽̍̈́̚", 219 | matcher = Achievement.Matcher.Regex(Regex("\\bnina\\b|\\bnani\\b")) 220 | ), 221 | Achievement( 222 | id = 32, 223 | name = "Happy Birthday", 224 | description = "Make Markov wish you a happy birthday", 225 | matcher = Achievement.Matcher.KeywordList(listOf("happy birthday")) 226 | ), 227 | Achievement( 228 | id = 33, 229 | name = "eg?", 230 | description = "Make Markov talk about eggs", 231 | matcher = Achievement.Matcher.Regex(Regex("\uD83E\uDD5A|\\beg\\b|\\begg")) 232 | ), 233 | Achievement( 234 | id = 34, 235 | name = "MODS", 236 | description = "Make Markov say that he is chicken", 237 | matcher = Achievement.Matcher.KeywordList(listOf("i am chicken", "im chicken", "i'm chicken")) 238 | ), 239 | Achievement( 240 | id = 35, 241 | name = "Vi Sitter Här", 242 | description = "Make Markov rant about your Dota skills", 243 | matcher = Achievement.Matcher.Regex(Regex("(?:da|that)'?s a big problem|f[auo]king? tr[ea]sh|26 minute?|big r[ei]tard pl[ae]yer")) 244 | ), 245 | Achievement( 246 | id = 36, 247 | name = "Bruce U", 248 | description = "Make Markov mention Uganda or Uganda related topics", 249 | matcher = Achievement.Matcher.KeywordList(listOf("uganda", "wakaliwood", "captain alex", "tiger mafia", "bruce u", "pastor lul")) 250 | ), 251 | ) -------------------------------------------------------------------------------- /src/jvmMain/kotlin/backend/JanitorBackend.kt: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import io.ktor.http.* 4 | import io.ktor.resources.* 5 | import io.ktor.server.application.* 6 | import io.ktor.server.auth.* 7 | import io.ktor.server.html.* 8 | import io.ktor.server.resources.* 9 | import io.ktor.server.response.* 10 | import io.ktor.server.sessions.* 11 | import io.ktor.util.pipeline.* 12 | import kotlinx.css.* 13 | import kotlinx.css.properties.TextDecoration 14 | import kotlinx.css.properties.TextDecorationLine 15 | import kotlinx.datetime.Clock 16 | import kotlinx.datetime.Instant 17 | import kotlinx.datetime.toJavaInstant 18 | import kotlinx.datetime.toKotlinInstant 19 | import kotlinx.html.* 20 | import net.dean.jraw.RedditClient 21 | import net.dean.jraw.http.OkHttpNetworkAdapter 22 | import net.dean.jraw.http.UserAgent 23 | import net.dean.jraw.models.Comment 24 | import net.dean.jraw.models.Listing 25 | import net.dean.jraw.models.OAuthData 26 | import net.dean.jraw.oauth.Credentials 27 | import net.dean.jraw.oauth.NoopTokenStore 28 | import java.util.* 29 | import kotlin.time.Duration.Companion.days 30 | import kotlin.time.Duration.Companion.seconds 31 | 32 | private suspend fun ApplicationCall.respondReturnToLogin() { 33 | respondHtml { 34 | head { 35 | title("Invalid Reddit Login") 36 | styleLink(application.run { href(Routes.JanitorBackend.StylesCss()) }) 37 | } 38 | 39 | body { 40 | p { 41 | +"Invalid Reddit login." 42 | } 43 | 44 | br { } 45 | 46 | a(href = "/") { 47 | +"Return to login" 48 | } 49 | } 50 | } 51 | } 52 | 53 | private fun setupRedditClient(session: Session, validateUser: Boolean = true): RedditClient? { 54 | val userAgent = UserAgent( 55 | platform = "JVM/JRAW", 56 | appId = "${RuntimeVariables.Reddit.botAppId} Comment Sanitation and Waste Management Engineer Duties", 57 | version = BuildInfo.PROJECT_VERSION, 58 | redditUsername = RuntimeVariables.Reddit.botAuthorRedditUsername 59 | ) 60 | 61 | val redditClient = RedditClient::class.constructors.first().call( 62 | OkHttpNetworkAdapter(userAgent), 63 | OAuthData.create(session.redditAccessToken, listOf("identity"), session.redditRefreshToken, Date.from(session.redditAccessTokenExpiration.toJavaInstant())), 64 | Credentials.webapp( 65 | clientId = RuntimeVariables.Backend.redditClientId, 66 | clientSecret = RuntimeVariables.Backend.redditClientSecret, 67 | redirectUrl = redditLoginRedirectUrl 68 | ), 69 | NoopTokenStore(), 70 | null 71 | ).apply { 72 | logHttp = false 73 | } 74 | 75 | return if (redditClient.me().username.lowercase() in RuntimeVariables.Backend.permittedUsers || !validateUser) { 76 | redditClient 77 | } else { 78 | null 79 | } 80 | } 81 | 82 | suspend fun PipelineContext.janitorBackendLogin() { 83 | call.respondHtml { 84 | head { 85 | title("MarkovBaj Janitor Backend Login") 86 | styleLink(application.run { href(Routes.JanitorBackend.StylesCss()) }) 87 | } 88 | 89 | body { 90 | button { 91 | onClick = "location.href = '${application.run { href(Routes.JanitorBackend.Login()) }}'" 92 | 93 | +"Login" 94 | } 95 | 96 | footer { 97 | +"Version ${BuildInfo.PROJECT_VERSION}, Build ${Instant.fromEpochMilliseconds(BuildInfo.PROJECT_BUILD_TIMESTAMP_MILLIS)}" 98 | } 99 | } 100 | } 101 | } 102 | 103 | suspend fun PipelineContext.janitorBackendCallback() { 104 | val principal = call.authentication.principal() ?: run { 105 | call.respondReturnToLogin() 106 | return 107 | } 108 | 109 | val newSession = Session( 110 | redditAccessToken = principal.accessToken, 111 | redditRefreshToken = principal.refreshToken, 112 | redditAccessTokenExpiration = Clock.System.now() + principal.expiresIn.seconds 113 | ) 114 | 115 | val userRedditClientName = setupRedditClient(newSession, validateUser = false)!!.me().username 116 | 117 | if (userRedditClientName.lowercase() in RuntimeVariables.Backend.permittedUsers) { 118 | logger.info { "User '$userRedditClientName' has logged into the janitor backend." } 119 | call.sessions.set(newSession) 120 | } else { 121 | logger.info { "User '$userRedditClientName' has tried to log into the janitor backend, but isn't permitted to do so." } 122 | } 123 | 124 | call.respondRedirect(application.run { href(Routes.JanitorBackend.Manage()) }) 125 | } 126 | 127 | suspend fun PipelineContext.janitorBackendManage() { 128 | val session = call.sessions.get() ?: run { 129 | call.respondReturnToLogin() 130 | return 131 | } 132 | 133 | val userRedditClient = setupRedditClient(session) ?: run { 134 | call.respondReturnToLogin() 135 | return 136 | } 137 | 138 | call.respondHtml { 139 | head { 140 | title("MarkovBaj Janitor Backend") 141 | styleLink(application.run { href(Routes.JanitorBackend.StylesCss()) }) 142 | } 143 | 144 | body { 145 | img { 146 | src = "https://styles.redditmedia.com/t5_4wpxrc/styles/profileIcon_1ypmzxwn0hn71.png?width=256&height=256&crop=256:256,smart&s=da81d12487728dfa78e33f9ae2af8a5df87ee317" 147 | } 148 | 149 | p { 150 | +"Hello /u/${userRedditClient.me().username}, welcome to the " 151 | span(classes = "strikethrough") { +"Janitor Room" } 152 | +" MarkovBaj Comment Sanitation and Waste Management Engineer Duties Centre." 153 | } 154 | 155 | br { } 156 | 157 | form(action = call.application.href(Routes.JanitorBackend.DeleteComment(commentLink = "")), method = FormMethod.get) { 158 | h1 { 159 | +"Comment deleter" 160 | } 161 | 162 | p { 163 | +"Only comments with a maximum age of 24 hours can be deleted. Only delete TOS comments. Deletions will be logged. Link can be obtained with Share -> Copy Link." 164 | } 165 | 166 | input { 167 | type = InputType.text 168 | name = "comment_link" 169 | placeholder = "Direct comment perma link (e.g. https://www.reddit.com/r/forsen/comments/pgamez/mods_update/hbbiwe7/)" 170 | } 171 | 172 | input { 173 | type = InputType.submit 174 | value = "Delete" 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | suspend fun PipelineContext.janitorBackendDeleteComment(redditClient: RedditClient?, deleteCommentRequest: Routes.JanitorBackend.DeleteComment) { 182 | val session = call.sessions.get() ?: run { 183 | call.respondReturnToLogin() 184 | return 185 | } 186 | 187 | val userRedditClientName = setupRedditClient(session)?.me()?.username ?: run { 188 | call.respondReturnToLogin() 189 | return 190 | } 191 | 192 | if (redditClient == null) { 193 | call.respondText("Unable to delete comment because Reddit bot is not set up.") 194 | return 195 | } 196 | 197 | try { 198 | val pathSegments = Url(deleteCommentRequest.commentLink).pathSegments 199 | 200 | val commentToDelete = @Suppress("UNCHECKED_CAST") (redditClient.lookup("t1_${pathSegments[6]}") as Listing) 201 | .children 202 | .first() 203 | 204 | if ((Clock.System.now() - commentToDelete.created.toInstant().toKotlinInstant() - 1.days).isPositive()) { 205 | call.respondText("Comment is too old to be deleted.", status = HttpStatusCode.BadRequest) 206 | return 207 | } 208 | 209 | if (commentToDelete.author.lowercase() != RuntimeVariables.Reddit.botUsername.lowercase()) { 210 | call.respondText("Comment was not posted by authenticated account.", status = HttpStatusCode.BadRequest) 211 | return 212 | } 213 | 214 | redditClient.comment(pathSegments[6]).delete() 215 | logger.info { "Comment '${pathSegments[6]}' at '${deleteCommentRequest.commentLink}' with the content '${commentToDelete.body}' was deleted by user '${userRedditClientName}'." } 216 | call.respondText("Comment was deleted.") 217 | } catch (e: Exception) { 218 | call.respondText("Invalid comment URL.", status = HttpStatusCode.BadRequest) 219 | logger.warn { "Comment deletion by '$userRedditClientName' failed: $e" } 220 | return 221 | } 222 | } 223 | 224 | suspend fun PipelineContext.janitorBackendStyles() { 225 | call.respondText( 226 | text = CssBuilder().apply { 227 | rule("body") { 228 | width = 800.px 229 | paddingTop = 50.px 230 | margin(LinearDimension.auto) 231 | fontFamily = "Arial" 232 | } 233 | 234 | rule("body *") { 235 | width = 100.pct 236 | boxSizing = BoxSizing.borderBox 237 | } 238 | 239 | rule("footer") { 240 | position = Position.fixed 241 | bottom = 0.px 242 | padding(16.px) 243 | } 244 | 245 | rule("button") { 246 | height = 48.px 247 | } 248 | 249 | rule("input[type=\"text\"], input[type=\"submit\"]") { 250 | marginTop = 8.px 251 | padding(8.px) 252 | } 253 | 254 | rule("img") { 255 | width = 256.px 256 | display = Display.block 257 | margin(LinearDimension.auto) 258 | } 259 | 260 | rule(".strikethrough") { 261 | textDecoration = TextDecoration(setOf(TextDecorationLine.lineThrough)) 262 | } 263 | }.toString(), 264 | contentType = ContentType.Text.CSS 265 | ) 266 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/Styles.kt: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.web.css.* 2 | 3 | object Styles : StyleSheet() { 4 | val bodyPadding = 16.px 5 | 6 | 7 | init { 8 | "body" style { 9 | margin(0.px) 10 | backgroundImage("url('img/background.webp')") 11 | backgroundRepeat("repeat") 12 | backgroundSize("contain") 13 | color(Color.white) 14 | property("overscroll-behavior", "contain") 15 | } 16 | 17 | "::-webkit-scrollbar" style { 18 | display(DisplayStyle.None) 19 | } 20 | 21 | "input:focus" style { 22 | outline("none") 23 | } 24 | } 25 | 26 | 27 | val rootElement by style { 28 | position(Position.Relative) 29 | 30 | width(100.percent) 31 | maxWidth(768.px) 32 | height(100.vh) 33 | 34 | // Shitty hack to fix 100vh excluding URL bar on mobile devices, see: https://stackoverflow.com/questions/52848856/100vh-height-when-address-bar-is-shown-chrome-mobile 35 | property("height", "calc(var(--vh, 1vh) * 100)") 36 | 37 | property("margin", "auto") 38 | padding(bodyPadding) 39 | 40 | display(DisplayStyle.Flex) 41 | flexDirection(FlexDirection.Column) 42 | 43 | boxSizing("border-box") 44 | fontFamily("Consolas", "monospace") 45 | } 46 | 47 | val hidden by style { 48 | opacity(0) 49 | } 50 | 51 | val smallBorderContainer by style { 52 | display(DisplayStyle.Grid) 53 | gridTemplateColumns("20px auto 20px") 54 | gridTemplateRows("15px auto 15px") 55 | width(100.percent) 56 | } 57 | 58 | val smallBorderHorizontalImage by style { 59 | width(100.percent) 60 | height(69.px) 61 | } 62 | 63 | val notificationContainer by style { 64 | position(Position.Absolute) 65 | left(64.px) 66 | right(128.px) 67 | backgroundColor(rgba(50, 50, 50, 0.8)) 68 | display(DisplayStyle.Flex) 69 | justifyItems("center") 70 | } 71 | 72 | val notificationInnerContainer by style { 73 | property("z-index", 3) 74 | } 75 | 76 | val notificationBackground by style { 77 | position(Position.Absolute) 78 | left(10.px) 79 | right(10.px) 80 | top(10.px) 81 | bottom(10.px) 82 | backgroundColor(rgb(60, 60, 60)) 83 | property("z-index", 3) 84 | } 85 | 86 | val notificationContent by style { 87 | fontSize(16.px) 88 | gridColumn("2") 89 | gridRow("2") 90 | property("z-index", 3) 91 | display(DisplayStyle.Flex) 92 | alignItems(AlignItems.Center) 93 | } 94 | 95 | val menuButton by style { 96 | position(Position.Absolute) 97 | right(bodyPadding) 98 | padding(8.px) 99 | property("z-index", 1) 100 | property("border", "none") 101 | backgroundColor(rgb(30, 30, 30)) 102 | borderRadius(bottomLeft = 8.px, bottomRight = 8.px, topLeft = 0.px, topRight = 0.px) 103 | cursor("pointer") 104 | } 105 | 106 | val menuButtonIcon by style { 107 | width(36.px) 108 | } 109 | 110 | val menuContainer by style { 111 | position(Position.Absolute) 112 | display(DisplayStyle.Flex) 113 | height(40.vh) 114 | left(0.px) 115 | right(0.px) 116 | padding(16.px) 117 | backgroundColor(rgba(20, 20, 20, 0.9)) 118 | property("z-index", 1) 119 | flexWrap(FlexWrap.Wrap) 120 | property("place-content", "flex-start") 121 | justifyContent(JustifyContent.SpaceBetween) 122 | overflowY("scroll") 123 | boxSizing("border-box") 124 | color(Color.white) 125 | borderRadius(bottomLeft = 8.px, bottomRight = 8.px, topLeft = 0.px, topRight = 0.px) 126 | } 127 | 128 | val menuHeadline by style { 129 | flexBasis(100.percent) 130 | padding(16.px) 131 | textAlign("justify") 132 | fontSize(18.pt) 133 | marginBottom(0.px) 134 | } 135 | 136 | val menuText by style { 137 | flexBasis(100.percent) 138 | padding(16.px) 139 | paddingTop(0.px) 140 | textAlign("justify") 141 | fontSize(13.pt) 142 | } 143 | 144 | val creditsLine by style { 145 | flexBasis(100.percent) 146 | padding(16.px) 147 | paddingTop(0.px) 148 | paddingBottom(0.px) 149 | display(DisplayStyle.Flex) 150 | alignItems(AlignItems.Center) 151 | } 152 | 153 | val socialMediaIcon by style { 154 | marginTop(2.px) 155 | marginLeft(10.px) 156 | } 157 | 158 | val achievementContainer by style { 159 | display(DisplayStyle.Flex) 160 | margin(8.px) 161 | flexDirection(FlexDirection.Column) 162 | width(96.px) 163 | } 164 | 165 | val achievementIconContainer by style { 166 | width(100.percent) 167 | height(96.px) 168 | borderRadius(9.px) 169 | display(DisplayStyle.Flex) 170 | } 171 | 172 | val achievementIcon by style { 173 | width(100.percent) 174 | height(100.percent) 175 | borderRadius(8.px) 176 | } 177 | 178 | val achievementCaption by style { 179 | width(100.percent) 180 | paddingTop(8.px) 181 | textAlign("center") 182 | boxSizing("border-box") 183 | property("word-wrap", "break-word") 184 | fontSize(10.pt) 185 | } 186 | 187 | val markovContainer by style { 188 | position(Position.Relative) 189 | height(40.vh) 190 | } 191 | 192 | val markovBodyPart by style { 193 | position(Position.Absolute) 194 | width(100.percent) 195 | height(100.percent) 196 | property("object-fit", "contain") 197 | } 198 | 199 | val markovHeadPart by style { 200 | // animation(swayAnimation) { 201 | // duration(3.s) 202 | // timingFunction(AnimationTimingFunction.EaseInOut) 203 | // iterationCount(Int.MAX_VALUE) 204 | // direction(AnimationDirection.Alternate) 205 | // } 206 | // 207 | // animation(bobAnimation) { 208 | // duration(1.1.s) 209 | // timingFunction(AnimationTimingFunction.EaseInOut) 210 | // iterationCount(Int.MAX_VALUE) 211 | // direction(AnimationDirection.Alternate) 212 | // } 213 | } 214 | 215 | // val swayAnimation by keyframes { 216 | // from { 217 | // marginLeft((-1).percent) 218 | // } 219 | // 220 | // to { 221 | // marginTop(1.percent) 222 | // } 223 | // } 224 | // 225 | // val bobAnimation by keyframes { 226 | // from { 227 | // marginTop((-0.3).percent) 228 | // } 229 | // 230 | // to { 231 | // marginTop(0.3.percent) 232 | // } 233 | // } 234 | 235 | val chatContainer by style { 236 | width(100.percent) 237 | height(100.percent) 238 | maxHeight(65.vh) 239 | flexShrink(1) 240 | position(Position.Relative) 241 | } 242 | 243 | val ttsMutedSettingContainer by style { 244 | position(Position.Absolute) 245 | top((-96).px) 246 | right(50.px) 247 | width(96.px) 248 | height(96.px) 249 | cursor("pointer") 250 | } 251 | 252 | val ttsMutedSettingIcon by style { 253 | position(Position.Absolute) 254 | width(96.px) 255 | height(96.px) 256 | } 257 | 258 | val achievementCompleteTrophyIcon by style { 259 | position(Position.Absolute) 260 | top((-96).px) 261 | left(40.px) 262 | width(96.px) 263 | height(96.px) 264 | cursor("pointer") 265 | } 266 | 267 | val chatBackground by style { 268 | position(Position.Absolute) 269 | left(20.px) 270 | right(20.px) 271 | top(20.px) 272 | bottom(20.px) 273 | backgroundColor(Color.black) 274 | property("z-index", -1) 275 | } 276 | 277 | val chatBorderContainer by style { 278 | width(100.percent) 279 | height(100.percent) 280 | display(DisplayStyle.Grid) 281 | gridTemplateColumns("75px auto 75px") 282 | gridTemplateRows("75px auto 75px") 283 | } 284 | 285 | val chatMessageContainer by style { 286 | width(100.percent) 287 | height(100.percent) 288 | display(DisplayStyle.Flex) 289 | flexDirection(FlexDirection.Column) 290 | overflow("scroll") 291 | gridColumn("2") 292 | gridRow("2") 293 | lineHeight("1.3") 294 | property("scrollbar-width", "none") 295 | } 296 | 297 | val chatBorderCorner by style { 298 | width(100.percent) 299 | } 300 | 301 | val chatMessage by style { 302 | fontSize(16.px) 303 | property("word-wrap", "break-word") 304 | paddingBottom(32.px) 305 | } 306 | 307 | val chatMessageConsoleUser by style { 308 | color(Color.lime) 309 | } 310 | 311 | val chatMessageConsoleMarkov by style { 312 | color(rgb(255, 40, 20)) 313 | } 314 | 315 | val chatMessageConsoleLocation by style { 316 | color(Color.dodgerblue) 317 | } 318 | 319 | val chatMessageConsoleUnimportant by style { 320 | color(Color.lightgray) 321 | } 322 | 323 | val chatMessageError by style { 324 | color(rgb(0xcc, 0x00, 0x00)) 325 | } 326 | 327 | val chatInputContainer by style { 328 | width(100.percent) 329 | position(Position.Relative) 330 | paddingLeft(8.px) 331 | paddingRight(8.px) 332 | marginTop(8.px) 333 | boxSizing("border-box") 334 | } 335 | 336 | val chatInputBackground by style { 337 | position(Position.Absolute) 338 | left(10.px) 339 | right(10.px) 340 | top(10.px) 341 | bottom(10.px) 342 | backgroundColor(Color.black) 343 | property("z-index", -1) 344 | } 345 | 346 | val chatInput by style { 347 | width(100.percent) 348 | backgroundColor(Color.transparent) 349 | property("border", "none") 350 | fontSize(16.px) 351 | paddingTop(10.px) 352 | paddingBottom(10.px) 353 | gridColumn("2") 354 | gridRow("2") 355 | boxSizing("border-box") 356 | fontFamily("Consolas", "monospace") 357 | color(Color.white) 358 | } 359 | 360 | val chatInputBorderContainer by style { 361 | width(100.percent) 362 | height(100.percent) 363 | } 364 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/RedditBot.kt: -------------------------------------------------------------------------------- 1 | 2 | import kotlinx.coroutines.coroutineScope 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.MutableSharedFlow 5 | import kotlinx.coroutines.isActive 6 | import kotlinx.coroutines.launch 7 | import kotlinx.datetime.Clock 8 | import kotlinx.datetime.toKotlinInstant 9 | import mu.KotlinLogging 10 | import net.dean.jraw.RedditClient 11 | import net.dean.jraw.http.OkHttpNetworkAdapter 12 | import net.dean.jraw.http.UserAgent 13 | import net.dean.jraw.models.SubredditSort 14 | import net.dean.jraw.oauth.Credentials 15 | import net.dean.jraw.oauth.OAuthHelper 16 | import net.dean.jraw.references.PublicContributionReference 17 | import kotlin.concurrent.fixedRateTimer 18 | import kotlin.time.Duration.Companion.hours 19 | 20 | private val logger = KotlinLogging.logger("MarkovBaj:Reddit") 21 | 22 | fun setupRedditClient(): RedditClient { 23 | val redditBotCredentials = Credentials.script( 24 | username = RuntimeVariables.Reddit.botUsername, 25 | password = RuntimeVariables.Reddit.botPassword, 26 | clientId = RuntimeVariables.Reddit.botClientId, 27 | clientSecret = RuntimeVariables.Reddit.botClientSecret 28 | ) 29 | 30 | val userAgent = UserAgent( 31 | platform = "JVM/JRAW", 32 | appId = RuntimeVariables.Reddit.botAppId, 33 | version = BuildInfo.PROJECT_VERSION, 34 | redditUsername = RuntimeVariables.Reddit.botAuthorRedditUsername 35 | ) 36 | 37 | val redditClient = OAuthHelper.automatic(OkHttpNetworkAdapter(userAgent), redditBotCredentials).apply { 38 | logHttp = false 39 | } 40 | 41 | logger.info("Connected to Reddit.") 42 | 43 | return redditClient 44 | } 45 | 46 | suspend fun setupRedditBot(redditClient: RedditClient, markovChain: MarkovChain, eventFlow: MutableSharedFlow) = coroutineScope { 47 | val activeSubreddit = redditClient.subreddit(RuntimeVariables.Reddit.activeSubreddit) 48 | 49 | var alreadyProcessedPostIds = listOf() 50 | var alreadyProcessedCommentsIds = listOf() 51 | 52 | logger.info("Bot running.") 53 | 54 | fixedRateTimer(period = RuntimeVariables.Reddit.checkInterval.inWholeMilliseconds) { 55 | launch { 56 | try { 57 | val newInboxMessages = redditClient.me() 58 | .inbox() 59 | .iterate("unread") 60 | .build() 61 | .accumulateMerged(1) 62 | .filter { 63 | it.subject == "username mention" || 64 | it.subject.startsWith("comment reply") && CommonConstants.triggerKeyword.lowercase() in it.body.lowercase() && it.subreddit != RuntimeVariables.Reddit.activeSubreddit 65 | } 66 | 67 | val newPosts = activeSubreddit.posts() 68 | .sorting(SubredditSort.NEW) 69 | .limit(100) 70 | .build() 71 | .accumulateMerged(1) 72 | .filter { it.created.toInstant().toKotlinInstant() > Clock.System.now() - RuntimeVariables.Reddit.checkInterval * 5 && it.id !in alreadyProcessedPostIds } 73 | 74 | val newComments = activeSubreddit.comments() 75 | .limit(100) 76 | .build() 77 | .accumulateMerged(1) 78 | .filter { 79 | it.created.toInstant().toKotlinInstant() > Clock.System.now() - RuntimeVariables.Reddit.checkInterval * 2 && 80 | it.id !in alreadyProcessedCommentsIds && 81 | it.id !in newInboxMessages.filter { message -> message.subreddit == RuntimeVariables.Reddit.activeSubreddit }.map { message -> message.id } 82 | } 83 | 84 | logger.info("${newInboxMessages.size} new mention(s), ${newPosts.size} new post(s), ${newComments.size} new comment(s).") 85 | 86 | eventFlow.tryEmit( 87 | ApiEvent.CommentsCollected( 88 | comments = newComments.map { comment -> 89 | ApiEvent.CommentsCollected.Comment( 90 | id = comment.id, 91 | created = comment.created.toInstant().toKotlinInstant(), 92 | author = comment.author, 93 | body = comment.body, 94 | url = comment.url, 95 | authorFlairText = comment.authorFlairText, 96 | submissionFullName = comment.submissionFullName, 97 | submissionTitle = comment.submissionTitle, 98 | subredditType = comment.subredditType.name, 99 | distinguished = comment.distinguished.name, 100 | fullName = comment.fullName, 101 | parentFullName = comment.parentFullName, 102 | subredditFullName = comment.subredditFullName, 103 | ) 104 | } 105 | ) 106 | ) 107 | 108 | eventFlow.tryEmit( 109 | ApiEvent.SubmissionsCollected( 110 | submissions = newPosts.map { post -> 111 | ApiEvent.SubmissionsCollected.Submission( 112 | created = post.created.toInstant().toKotlinInstant(), 113 | distinguished = post.distinguished.name, 114 | id = post.id, 115 | author = post.author, 116 | body = post.body, 117 | title = post.title, 118 | url = post.url, 119 | authorFlairText = post.authorFlairText, 120 | domain = post.domain, 121 | embeddedMedia = post.embeddedMedia != null, 122 | isNsfw = post.isNsfw, 123 | isSelfPost = post.isSelfPost, 124 | isSpoiler = post.isSpoiler, 125 | linkFlairCssClass = post.linkFlairCssClass, 126 | linkFlairText = post.linkFlairText, 127 | permalink = post.permalink, 128 | postHint = post.postHint, 129 | preview = post.preview != null, 130 | selfText = post.selfText, 131 | thumbnail = post.thumbnail, 132 | fullName = post.fullName, 133 | subreddit = post.subreddit, 134 | subredditFullName = post.subredditFullName, 135 | ) 136 | } 137 | ) 138 | ) 139 | 140 | alreadyProcessedPostIds = newPosts.map { it.id } 141 | alreadyProcessedCommentsIds = newInboxMessages.map { it.id }.union(newComments.map { it.id }).toList() 142 | 143 | var commentCounter = 0 144 | 145 | if (RuntimeVariables.Reddit.answerMentions) { 146 | for (message in newInboxMessages) { 147 | if (commentCounter >= RuntimeVariables.Reddit.maxCommentsPerCheck) { 148 | logger.warn("Hit comment limit, not posting any more replies.") 149 | return@launch 150 | } 151 | 152 | if (!message.isComment) { 153 | logger.warn("Username mention with id ${message.id} was not a comment, skipping...") 154 | return@launch 155 | } 156 | 157 | val wordsInTitle = message.body.toWordParts() 158 | 159 | val relatedReply = if (Math.random() > RuntimeVariables.Common.unrelatedAnswerChance) { 160 | markovChain.tryGeneratingReplyFromWords(wordsInTitle, platform = "Reddit") 161 | } else { 162 | null 163 | } 164 | 165 | val actualReply = if (relatedReply != null) { 166 | logger.info("Replying to mention by ${message.author} in message ${message.id} in ${message.subreddit?.let { "r/$it" } ?: "-"} ('${message.body}') with related answer...") 167 | relatedReply 168 | } else { 169 | markovChain.generateRandomReply().also { 170 | logger.info("Default replying to mention by ${message.author} in message ${message.id} in ${message.subreddit?.let { "r/$it" } ?: "-"} ('${message.body}')...") 171 | } 172 | } 173 | 174 | redditClient.comment(message.id).safeReply(actualReply) 175 | redditClient.me().inbox().markRead(true, message.fullName) 176 | commentCounter++ 177 | 178 | delay(RuntimeVariables.Reddit.delayBetweenComments) 179 | } 180 | } 181 | 182 | for (post in newPosts) { 183 | if (commentCounter >= RuntimeVariables.Reddit.maxCommentsPerCheck) { 184 | logger.warn("Hit comment limit, not posting any more replies.") 185 | return@launch 186 | } 187 | 188 | if (post.isRemoved) { 189 | continue 190 | } 191 | 192 | if (CommonConstants.triggerKeyword.lowercase() in post.title.lowercase()) { 193 | val wordsInTitle = post.title.toWordParts() 194 | 195 | val relatedReply = if (Math.random() > RuntimeVariables.Common.unrelatedAnswerChance) { 196 | markovChain.tryGeneratingReplyFromWords(wordsInTitle, platform = "Reddit") 197 | } else { 198 | null 199 | } 200 | 201 | val actualReply = if (relatedReply != null) { 202 | logger.info("Replying to post ${post.id} ('${post.title}') with related answer...") 203 | relatedReply 204 | } else { 205 | markovChain.generateRandomReply().also { 206 | logger.info("Default replied to post ${post.id} ('${post.title}')...") 207 | } 208 | } 209 | 210 | post.toReference(redditClient).safeReply(actualReply) 211 | commentCounter++ 212 | 213 | delay(RuntimeVariables.Reddit.delayBetweenComments) 214 | } 215 | } 216 | 217 | for (comment in newComments) { 218 | if (commentCounter >= RuntimeVariables.Reddit.maxCommentsPerCheck) { 219 | logger.warn("Hit comment limit, not posting any more replies.") 220 | return@launch 221 | } 222 | 223 | if (comment.id in newInboxMessages.map { it.id }) { 224 | continue 225 | } 226 | 227 | if (CommonConstants.triggerKeyword.lowercase() in comment.body.lowercase()) { 228 | val wordsInComment = comment.body.toWordParts() 229 | 230 | val relatedReply = if (Math.random() > RuntimeVariables.Common.unrelatedAnswerChance) { 231 | markovChain.tryGeneratingReplyFromWords(wordsInComment, platform = "Reddit") 232 | } else { 233 | null 234 | } 235 | 236 | val actualReply = if (relatedReply != null) { 237 | logger.info("Replying to comment ${comment.id} ('${comment.body}') with related answer...") 238 | relatedReply 239 | } else { 240 | markovChain.generateRandomReply().also { 241 | logger.info("Default replying to comment ${comment.id} ('${comment.body}')...") 242 | } 243 | } 244 | 245 | comment.toReference(redditClient).safeReply(actualReply) 246 | commentCounter++ 247 | 248 | delay(RuntimeVariables.Reddit.delayBetweenComments) 249 | } 250 | } 251 | } catch (e: Exception) { 252 | logger.error("Error while running timer loop:", e) 253 | } 254 | } 255 | } 256 | 257 | // Keep coroutine scope alive 258 | launch { 259 | while (isActive) { 260 | delay(1.hours) 261 | } 262 | } 263 | } 264 | 265 | private fun PublicContributionReference.safeReply(text: String) { 266 | if (RuntimeVariables.Reddit.actuallySendReplies) { 267 | try { 268 | reply(text.take(5000)) 269 | logger.info("Replied with '${text.take(5000)}'.") 270 | } catch (e: Exception) { 271 | logger.error("Reply failed:", e) 272 | } 273 | } else { 274 | logger.info("[NOT ACTUALLY REPLYING] Would have replied with '$text'.") 275 | } 276 | } --------------------------------------------------------------------------------