├── 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* guildId */ Snowflake, MutableSharedFlow>()
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("​\\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 | }
--------------------------------------------------------------------------------