├── .github └── workflows │ ├── publish-docker-release.yaml │ ├── test-and-build.yaml │ └── test-feature-branch.yaml ├── .gitignore ├── .sdkmanrc ├── Dockerfile ├── LICENSE ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── java │ └── lequentin │ │ └── cocobot │ │ ├── CocoApplicationMain.java │ │ ├── ImpersonationTestingApplicationMain.java │ │ ├── JsonMapper.java │ │ ├── SynchroniseStorageApplicationMain.java │ │ ├── application │ │ ├── BotMessage.java │ │ ├── ChatBot.java │ │ ├── CocoChatBotApplication.java │ │ ├── CocoCommandParser.java │ │ ├── Command.java │ │ ├── ExcludeChatCommandsMessagesFilter.java │ │ ├── ImpersonationTestingChatBotApplication.java │ │ ├── IncomingMessage.java │ │ ├── RemoveQuotesAndBlocksStringSanitizer.java │ │ ├── commands │ │ │ ├── HelpCommand.java │ │ │ ├── ImpersonateCommand.java │ │ │ ├── RegisterMessageCommand.java │ │ │ └── UnknownCommand.java │ │ └── messages │ │ │ ├── ApplicationMessageCode.java │ │ │ ├── ApplicationMessageProvider.java │ │ │ └── InMemoryApplicationMessageProvider.java │ │ ├── config │ │ ├── Config.java │ │ ├── Language.java │ │ └── Secrets.java │ │ ├── discord │ │ ├── DiscordConverter.java │ │ ├── DiscordDirectAccessMessagesSource.java │ │ ├── DiscordIncomingMessage.java │ │ └── DiscordMessageListener.java │ │ ├── domain │ │ ├── Impersonator.java │ │ ├── Message.java │ │ ├── MessagesFilter.java │ │ ├── MessagesRepository.java │ │ ├── MessagesSource.java │ │ ├── StringSanitizer.java │ │ ├── StringTokenizer.java │ │ ├── User.java │ │ ├── UserNotFoundException.java │ │ ├── impersonator │ │ │ ├── LongImpersonationImpersonatorDecorator.java │ │ │ ├── MarkovImpersonator.java │ │ │ ├── MessagesFilterImpersonatorDecorator.java │ │ │ ├── MultipleSentencesImpersonatorDecorator.java │ │ │ └── SimpleTokensRandomImpersonator.java │ │ ├── markov │ │ │ ├── FindMaxOverBatchOfPathWalkerDecorator.java │ │ │ ├── MarkovChains.java │ │ │ ├── MarkovChainsWalker.java │ │ │ ├── MarkovPath.java │ │ │ ├── MarkovState.java │ │ │ ├── MarkovTokenizer.java │ │ │ ├── MarkovWordsGenerator.java │ │ │ ├── SimpleMarkovChainsWalker.java │ │ │ └── WordsTuple.java │ │ ├── sanitizer │ │ │ └── SpacePunctuationSanitizer.java │ │ └── tokenizer │ │ │ ├── SanitizerStringTokenizerDecorator.java │ │ │ ├── SentencesStringTokenizer.java │ │ │ └── WordsStringTokenizer.java │ │ └── storage │ │ ├── JsonFileMessagesRepository.java │ │ ├── MessageJson.java │ │ ├── UserMessagesJson.java │ │ └── UserMessagesJsonConverter.java └── resources │ └── logback.xml └── test ├── java └── lequentin │ └── cocobot │ ├── CocoApplicationMainUnitTest.java │ ├── application │ ├── BotMessageTest.java │ ├── CocoChatBotApplicationUnitTest.java │ ├── CocoCommandParserUnitTest.java │ ├── ExcludeChatCommandsMessagesFilterUnitTest.java │ ├── ImpersonationTestingChatBotApplicationUnitTest.java │ ├── commands │ │ ├── HelpCommandTest.java │ │ ├── ImpersonateCommandTest.java │ │ ├── RegisterMessageCommandTest.java │ │ ├── RemoveQuotesAndBlocksStringSanitizerUnitTest.java │ │ └── UnknownCommandTest.java │ └── messages │ │ └── InMemoryApplicationMessageProviderTest.java │ ├── config │ └── ConfigTest.java │ ├── discord │ ├── DiscordConverterTest.java │ ├── DiscordDirectAccessMessagesSourceUnitTest.java │ ├── DiscordIncomingMessageTest.java │ └── DiscordMessageListenerUnitTest.java │ ├── domain │ ├── SentencesStringTokenizerUnitTest.java │ ├── SimpleTokensRandomImpersonatorUnitTest.java │ ├── SpacePunctuationSanitizerUnitTest.java │ ├── WordsStringTokenizerUnitTest.java │ └── markov │ │ ├── MarkovStateUnitTest.java │ │ └── MarkovTokenizerUnitTest.java │ └── storage │ └── JsonFileMessagesRepositoryIntTest.java └── resources ├── mockito-extensions └── org.mockito.plugins.MockMaker └── storage.json /.github/workflows/publish-docker-release.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish docker release 11 | 12 | on: 13 | release: 14 | types: [ published ] 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | build-and-publish: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout project sources 25 | uses: actions/checkout@v3 26 | - name: Compute the artifact name from commit's SHA short 27 | id: vars 28 | run: echo "artifact_name=coco-package-$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT 29 | - name: Download artifact 30 | uses: bettermarks/action-artifact-download@0.3.0 31 | with: 32 | repo: le-quentin/cocobot 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | artifact_name: ${{ steps.vars.outputs.artifact_name }} 35 | - name: Unzip artifact to libs dir 36 | run: | 37 | mkdir -p build/libs 38 | unzip ${{ steps.vars.outputs.artifact_name }} -d build/libs/ 39 | 40 | - # Add support for more platforms with QEMU (optional) 41 | # https://github.com/docker/setup-qemu-action 42 | name: Set up QEMU 43 | uses: docker/setup-qemu-action@v2 44 | - name: Set up Docker Buildx 45 | id: buildx 46 | uses: docker/setup-buildx-action@v2 47 | with: 48 | platforms: linux/amd64,linux/arm/v7,linux/arm64 49 | - name: Log in to the Container registry 50 | uses: docker/login-action@v2 51 | with: 52 | registry: ${{ env.REGISTRY }} 53 | username: ${{ github.actor }} 54 | password: ${{ secrets.GITHUB_TOKEN }} 55 | - name: Extract metadata (tags, labels) for Docker 56 | id: meta 57 | uses: docker/metadata-action@v4 58 | with: 59 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 60 | tags: | 61 | type=raw,value=${{ github.event.release.tag_name }} 62 | type=raw,value=latest 63 | - name: Build and push Docker image 64 | uses: docker/build-push-action@v4 65 | with: 66 | context: . 67 | push: true 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | platforms: ${{ steps.buildx.outputs.platforms }} 71 | cache-from: type=gha 72 | cache-to: type=gha,mode=max 73 | -------------------------------------------------------------------------------- /.github/workflows/test-and-build.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish nightly build 11 | 12 | on: 13 | push: 14 | branches: [ 'master' ] 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | test-and-build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout project sources 25 | uses: actions/checkout@v3 26 | - name: Compute sha_short to suffix the built artifact 27 | id: vars 28 | run: echo "sha_short=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT 29 | - name: Setup Gradle 30 | uses: gradle/gradle-build-action@v2 31 | - name: Test and build 32 | run: ./gradlew build 33 | - name: Upload artifact 34 | uses: actions/upload-artifact@v3 35 | with: 36 | name: coco-package-${{ steps.vars.outputs.sha_short }} 37 | path: build/libs 38 | 39 | - # Add support for more platforms with QEMU (optional) 40 | # https://github.com/docker/setup-qemu-action 41 | name: Set up QEMU 42 | uses: docker/setup-qemu-action@v2 43 | - name: Set up Docker Buildx 44 | id: buildx 45 | uses: docker/setup-buildx-action@v2 46 | with: 47 | platforms: linux/amd64,linux/arm/v7,linux/arm64 48 | - name: Log in to the Container registry 49 | uses: docker/login-action@v2 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Extract metadata (tags, labels) for Docker 55 | id: meta 56 | uses: docker/metadata-action@v4 57 | with: 58 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 59 | tags: | 60 | type=raw,value=nightly 61 | - name: Build and push Docker image 62 | uses: docker/build-push-action@v4 63 | with: 64 | context: . 65 | push: true 66 | tags: ${{ steps.meta.outputs.tags }} 67 | labels: ${{ steps.meta.outputs.labels }} 68 | platforms: ${{ steps.buildx.outputs.platforms }} 69 | cache-from: type=gha 70 | cache-to: type=gha,mode=max 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/test-feature-branch.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Test feature branch 11 | 12 | on: 13 | push: 14 | branches-ignore: [ 'master' ] 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | test-and-build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout project sources 25 | uses: actions/checkout@v3 26 | - name: Setup Gradle 27 | uses: gradle/gradle-build-action@v2 28 | - name: Test and build 29 | run: ./gradlew test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle project 2 | .gradle/ 3 | **/build/ 4 | !src/**/build/ 5 | 6 | # Ignore Gradle GUI config 7 | gradle-app.setting 8 | 9 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 10 | !gradle-wrapper.jar 11 | 12 | # Cache of project 13 | .gradletasknamecache 14 | 15 | #eclipse files 16 | .classpath 17 | .project 18 | .settings/ 19 | src/main/webapp/META-INF/MANIFEST.MF 20 | *.iml 21 | 22 | #target folder 23 | target/ 24 | .idea/ 25 | *.log 26 | /bin/ 27 | 28 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 29 | # gradle/wrapper/gradle-wrapper.properties 30 | 31 | # App specific 32 | *secrets.yaml 33 | src/test/resources/build/* 34 | data/ 35 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | # Enable auto-env through the sdkman_auto_env config 2 | # Add key=value pairs of SDKs to use below 3 | java=17.0.2-tem 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17.0.2_8-jre 2 | 3 | WORKDIR /app 4 | VOLUME /app/data 5 | COPY ./build/libs/CocoBot-1.0-SNAPSHOT-all.jar ./coco.jar 6 | 7 | CMD ["java", "-jar", "./coco.jar"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 le-quentin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/6195955/222922200-45035f29-aaf9-4738-92e5-e9ce6313c687.png) 2 | 3 | 4 | #

CocoBot

5 | 6 |

Learns from your friends' messages, and impersonates them. Because nonsense is fun.

7 |

8 | 9 | 10 |

11 |

12 | 13 | 14 | 15 | 16 |

17 | 18 | ## I just wanna run it quick! 19 | 20 | Gotcha! Create [a bot application](https://discord.com/developers/docs/getting-started#creating-an-app) in your Discord server, and tick "Message content intent" in the bot settings. Then add the bot to your server with those permissions: 21 | - View channels 22 | - Send messages 23 | - Read messages history 24 | 25 | You're almost there! Run the bot with: 26 | 27 | ```shell 28 | > docker run -e COCOBOT_TOKEN= ghcr.io/le-quentin/cocobot:latest 29 | ``` 30 | 31 | ...give it a few minutes for the initial scraping of messages... 32 | 33 | And type `c/me` or `c/like ` to make Coco do funny impersonations! 34 | 35 | See below for more details. :) Have fun! 36 | 37 | ## What is this? 38 | A Discord bot watching people chat and then able to "impersonate" them. It will use all of the impersonated person 39 | messages in order to produce a random message based on them. The message won't always be gramatically correct, but it is 100% guaranteed to be wacky and somewhat funny. 40 | We've been using it with my friends for a while, and we had a few laughs here and there. 41 | 42 | I don't host a public version of this bot (yet?), so you have to host it yourself. That being said, I offer up-to-date docker images via `github packages`, so you don't have 43 | to build it, you just need a server with docker installed! 44 | 45 | Also, feel free to modify/extend the source code, depending on your needs and liking. The code makes heavy use of decorators and adapters, so it's a little messy, but very easy to extend (for example, porting the bot to another chat than Discord shouldn't be too much trouble). Feel free to fork and/or drop me a PR! 46 | 47 | **⚠️WARNING⚠️**: Currently, this bot is designed to handle only one server. You can invite it to more than one server, but be warned that everyone will be able to impersonate everyone with no server borders whatsoever... I recommend you stick to only one server per bot instance if you're afraid it might cause awkward situations. :) 48 | 49 | ## Commands 50 | 51 | From any channel where the bot is invited: 52 | ``` 53 | c/me - the bot impersonates you 54 | c/like - the bot impersonates that user 55 | c/help - show help about commands 56 | ``` 57 | 58 | `` should be the actual username (not the server local nickname), without the `#` part. So to impersonate `JohnDoe#1234`, do: 59 | ``` 60 | c/like JohnDoe 61 | c/like johndoe 62 | ``` 63 | 64 | Both work, it's not case sensitive. 65 | 66 | ## Host the bot 67 | 68 | ### Discord setup 69 | 70 | Go to https://discord.com/developers/applications and create an application for the bot. Go in the `Bot` section and create a bot for that application. 71 | 72 | The bot will have a secret token: note it down, you'll need it later. 73 | 74 | Scroll down and tick `Message content intent`, it needs to be on. 75 | 76 | Then, invite the bot on your server (either with an oauth link, or simply by using its username). The required permissions are: 77 | - View channels 78 | - Send messages 79 | - Read messages history 80 | 81 | ...and that's it! 82 | 83 | ### Run the bot with docker 84 | 85 | Running the bot is as easy as: 86 | ```shell 87 | > docker run -e COCOBOT_TOKEN= ghcr.io/le-quentin/cocobot:latest 88 | ``` 89 | 90 | ...with `` obviously being your bot secret token (I recommend using an env var set in your shell startup files, to avoid printing the secret in your shell's history). For a list of env vars for bot configuration, see [bot configuration section](#configuration) 91 | 92 | `latest` is the last stable release. If you want a specific version, checkout [releases](https://github.com/le-quentin/CocoBot/releases), every release has a matching docker image tag. 93 | 94 | At the first container's startup, the bot will parse all the server's messages, which will take a while (~5 minutes for my server, could be way longer on a huge community server). Currently, the bot does not update this file after the first start: if you want to get all the messages again, recreate the container. 95 | 96 | If you would like to be able to recreate the container (to get updated images, typically) without having to regenerate all messages every time, you can use a docker volume. Create a directory dedicated to storing the messages. Then: 97 | 98 | ```shell 99 | > docker run -e COCOBOT_TOKEN= -v /path/to/dedicated/dir:/app/data ghcr.io/le-quentin/cocobot:latest 100 | ``` 101 | 102 | This way, any version of the bot will start using the messages stored in `/path/to/dedicated/dir/messages.json` (and the bot will generate the file on first run, as usual). If you want to get all messages again, simpy delete the file and restart the container. 103 | 104 | ## Build it yourself 105 | 106 | If you don't want to use docker, or if you'd like to extend/modify the bot, you need to build it yourself. 107 | 108 | Thankfully, gradle wrapper makes it all too easy. Clone the repository, then from the root directory, simply run: 109 | 110 | ```shell 111 | > ./gradlew build 112 | ``` 113 | 114 | ...to build the service (it will also run tests), and: 115 | 116 | ```shell 117 | > COCOBOT_TOKEN= ./gradlew run 118 | ``` 119 | 120 | ...to run it. You will need to create a `data` folder under your current directory first. 121 | 122 | Gradle wrapper should take care of everything, including downloading the appropriate JDK. You literally should have nothing else to do. 123 | 124 | ## Configuration 125 | 126 | You can change the bot configuration with env vars. Here's the list of available vars: 127 | 128 | ``` 129 | COCOBOT_TOKEN (required) - your bot's secret token 130 | COCOBOT_LANGUAGE - the bot's language, using the 2 chars ISO code. Values: en,fr - Default: en 131 | COCOBOT_PREFIX - the bot's prefix, that should be used before all commands. Default: c/ 132 | It cannot be blank, but other than that there's no conditions on the format whatsoever. 133 | If you want the bot to work properly, pick something explicit and unambiguous, with special symbols. 134 | ``` 135 | 136 | ## TODO list - things I might change/add 137 | 138 | ### Core 139 | - [x] Exclude quotes from parsed messages* 140 | - [x] Sync at startup if messages file not found* 141 | - [ ] Move to MongoDB or ProtoBuf for better performance 142 | - [ ] Keep testing with existing impersonators to produce funnier outputs 143 | 144 | ### Ergonomy 145 | - [x] c/help 146 | - [x] rewrite conf from env properly 147 | - [x] prefix in conf 148 | - [ ] Proper logging with timestamp and levels 149 | 150 | ### Deploy | CI/CD 151 | - [x] messages.json outside of docker image (not necessary if using Mongo or Protobuf) 152 | - [x] use platform image param to publish amd64/armv7/arm64 153 | - [x] GitHub actions to automatically push image on commit push 154 | - [x] Test workflow on feature branch push 155 | - [x] Build `nightly` docker image on `master` push => protect master against push (work with merges from now on) 156 | 157 | ### Release as a public bot 158 | - [ ] Multiple servers setup => store message in separate documents/files for each server, with impersonators local to them 159 | - [x] Support english language 160 | - [ ] Ways to dynamically setup some stuff like prefix and language 161 | - [ ] Test a way to erase one user's data from all documents (GDPR) 162 | - [ ] Monitor/logging tools should be improved 163 | 164 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'application' 3 | id 'com.github.johnrengelman.shadow' version '7.1.2' 4 | } 5 | 6 | group 'org.example' 7 | version '1.0-SNAPSHOT' 8 | 9 | java { 10 | toolchain { 11 | languageVersion = JavaLanguageVersion.of(17) 12 | } 13 | } 14 | 15 | repositories { 16 | mavenCentral() 17 | gradlePluginPortal() 18 | } 19 | 20 | dependencies { 21 | implementation 'com.discord4j:discord4j-core:3.2.6' 22 | implementation 'ch.qos.logback:logback-classic:1.4.14' 23 | implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.13.1' 24 | implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1' 25 | implementation 'org.apache.commons:commons-lang3:3.0' 26 | 27 | testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' 28 | testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2' 29 | testImplementation 'org.assertj:assertj-core:3.22.0' 30 | testImplementation 'org.mockito:mockito-core:4.3.1' 31 | testImplementation 'org.mockito:mockito-junit-jupiter:4.3.1' 32 | testImplementation 'io.projectreactor:reactor-test:3.4.2' 33 | 34 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' 35 | } 36 | 37 | apply plugin: 'com.github.johnrengelman.shadow' 38 | 39 | test { 40 | useJUnitPlatform() 41 | } 42 | 43 | application { 44 | mainClass = 'lequentin.cocobot.CocoApplicationMain' 45 | } 46 | 47 | task('synchronise', dependsOn: 'classes', type: JavaExec) { 48 | mainClass = 'lequentin.cocobot.SynchroniseStorageApplicationMain' 49 | classpath = sourceSets.main.runtimeClasspath 50 | standardInput = System.in 51 | } 52 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/le-quentin/CocoBot/e2310263807b8a1a8515eb87ba1fa02624c876fb/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or 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 UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'CocoBot' 2 | 3 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/CocoApplicationMain.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot; 2 | 3 | import discord4j.core.DiscordClient; 4 | import discord4j.core.GatewayDiscordClient; 5 | import discord4j.core.event.domain.message.MessageCreateEvent; 6 | import lequentin.cocobot.application.CocoChatBotApplication; 7 | import lequentin.cocobot.application.CocoCommandParser; 8 | import lequentin.cocobot.application.ExcludeChatCommandsMessagesFilter; 9 | import lequentin.cocobot.application.RemoveQuotesAndBlocksStringSanitizer; 10 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 11 | import lequentin.cocobot.application.messages.InMemoryApplicationMessageProvider; 12 | import lequentin.cocobot.config.Config; 13 | import lequentin.cocobot.discord.DiscordConverter; 14 | import lequentin.cocobot.discord.DiscordMessageListener; 15 | import lequentin.cocobot.domain.Impersonator; 16 | import lequentin.cocobot.domain.MessagesRepository; 17 | import lequentin.cocobot.domain.StringSanitizer; 18 | import lequentin.cocobot.domain.StringTokenizer; 19 | import lequentin.cocobot.domain.impersonator.LongImpersonationImpersonatorDecorator; 20 | import lequentin.cocobot.domain.impersonator.MarkovImpersonator; 21 | import lequentin.cocobot.domain.impersonator.MessagesFilterImpersonatorDecorator; 22 | import lequentin.cocobot.domain.impersonator.MultipleSentencesImpersonatorDecorator; 23 | import lequentin.cocobot.domain.markov.FindMaxOverBatchOfPathWalkerDecorator; 24 | import lequentin.cocobot.domain.markov.MarkovChainsWalker; 25 | import lequentin.cocobot.domain.markov.MarkovTokenizer; 26 | import lequentin.cocobot.domain.markov.SimpleMarkovChainsWalker; 27 | import lequentin.cocobot.domain.markov.WordsTuple; 28 | import lequentin.cocobot.domain.sanitizer.SpacePunctuationSanitizer; 29 | import lequentin.cocobot.domain.tokenizer.SanitizerStringTokenizerDecorator; 30 | import lequentin.cocobot.domain.tokenizer.SentencesStringTokenizer; 31 | import lequentin.cocobot.domain.tokenizer.WordsStringTokenizer; 32 | import lequentin.cocobot.storage.JsonFileMessagesRepository; 33 | import lequentin.cocobot.storage.UserMessagesJsonConverter; 34 | import org.slf4j.Logger; 35 | import org.slf4j.LoggerFactory; 36 | 37 | import java.nio.file.Files; 38 | import java.nio.file.Path; 39 | import java.util.Comparator; 40 | import java.util.Random; 41 | 42 | public class CocoApplicationMain { 43 | 44 | private static Logger log = LoggerFactory.getLogger(CocoApplicationMain.class); 45 | 46 | public static final String MESSAGES_FILE = "./data/messages.json"; 47 | private final GatewayDiscordClient gatewayClient; 48 | private final DiscordMessageListener service; 49 | 50 | public CocoApplicationMain(GatewayDiscordClient gatewayClient, DiscordMessageListener service) { 51 | this.gatewayClient = gatewayClient; 52 | this.service = service; 53 | } 54 | 55 | public static void main(final String[] args) { 56 | final Config config = loadConfig(); 57 | 58 | // Discord API 59 | final DiscordClient discordClient = DiscordClient.create(config.getSecrets().getBotToken()); 60 | final GatewayDiscordClient gateway = discordClient.login().block(); 61 | 62 | // discord package 63 | final DiscordConverter discordConverter = new DiscordConverter(); 64 | 65 | // storage 66 | // If storage file does not exist, we synchronise first 67 | Path messagesFilePath = Path.of(MESSAGES_FILE); 68 | if (!Files.exists(messagesFilePath)) { 69 | log.info("messages.json file not found! Coco will generate it, and it will take a while"); 70 | SynchroniseStorageApplicationMain.main(new String[]{}); 71 | } 72 | 73 | // Build the storage objects 74 | final UserMessagesJsonConverter jsonConverter = new UserMessagesJsonConverter(); 75 | final MessagesRepository messagesRepository = new JsonFileMessagesRepository( 76 | messagesFilePath, 77 | JsonMapper.get(), 78 | jsonConverter 79 | ); 80 | 81 | // domain 82 | final StringSanitizer sanitizer = new RemoveQuotesAndBlocksStringSanitizer(); 83 | final StringTokenizer sentencesStringTokenizer = new SentencesStringTokenizer(sanitizer); 84 | final WordsStringTokenizer wordsTokenizer = new WordsStringTokenizer(); 85 | final StringTokenizer punctuationAwareWordsTokenizer = new SanitizerStringTokenizerDecorator(new SpacePunctuationSanitizer(), wordsTokenizer); 86 | final MarkovTokenizer markov3Tokenizer = new MarkovTokenizer(punctuationAwareWordsTokenizer, 3); 87 | // final MarkovChainsWalker walker = new FindMaxOverBatchOfPathWalkerDecorator<>( 88 | // new SimpleMarkovChainsWalker<>(new Random()), 89 | // Comparator.comparingInt(MarkovPath::getNonDeterministicScore), 90 | // 100, 91 | // 2 92 | // ); 93 | 94 | final MarkovChainsWalker leastDeterministicWalker = new FindMaxOverBatchOfPathWalkerDecorator<>( 95 | new SimpleMarkovChainsWalker<>(new Random()), 96 | Comparator.comparingInt(path -> (int)Math.round(path.getNonDeterministicScore() * (Math.log10(path.getLength())))), 97 | 50, 98 | 0 99 | ); 100 | 101 | 102 | // final MarkovChainsWalker walker = new SimpleMarkovChainsWalker<>(new Random()); 103 | final Impersonator markov3Impersonator = new MessagesFilterImpersonatorDecorator( 104 | new ExcludeChatCommandsMessagesFilter(), 105 | new MarkovImpersonator(sentencesStringTokenizer, markov3Tokenizer, leastDeterministicWalker) 106 | ); 107 | final Impersonator impersonator = new MultipleSentencesImpersonatorDecorator( 108 | new LongImpersonationImpersonatorDecorator(markov3Impersonator, 4, 5), 109 | 2 110 | ); 111 | 112 | // application 113 | final ApplicationMessageProvider applicationMessageProvider = new InMemoryApplicationMessageProvider(config); 114 | final CocoCommandParser cocoCommandParser = new CocoCommandParser(config, impersonator, applicationMessageProvider); 115 | final CocoChatBotApplication coco = new CocoChatBotApplication(cocoCommandParser); 116 | 117 | // service 118 | final DiscordMessageListener service = new DiscordMessageListener(discordConverter, coco); 119 | 120 | // app 121 | final CocoApplicationMain app = new CocoApplicationMain(gateway, service); 122 | 123 | log.info("Loading all messages from repository..."); 124 | impersonator.addAllMessagesFromSource(messagesRepository); 125 | log.info("All messages loaded!"); 126 | app.run(); 127 | } 128 | 129 | public void run() { 130 | service.subscribeToMessageCreateFlux(gatewayClient.on(MessageCreateEvent.class)); 131 | log.info("Listening to new messages..."); 132 | gatewayClient.onDisconnect().block(); 133 | } 134 | 135 | private static Config loadConfig() { 136 | try { 137 | return Config.readFromEnv(System::getenv); 138 | } catch(Exception ex) { 139 | log.error("There was an error reading config from env vars"); 140 | throw ex; 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/ImpersonationTestingApplicationMain.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot; 2 | 3 | import discord4j.core.DiscordClient; 4 | import discord4j.core.GatewayDiscordClient; 5 | import discord4j.core.event.domain.message.MessageCreateEvent; 6 | import lequentin.cocobot.application.ImpersonationTestingChatBotApplication; 7 | import lequentin.cocobot.config.Config; 8 | import lequentin.cocobot.discord.DiscordConverter; 9 | import lequentin.cocobot.discord.DiscordMessageListener; 10 | import lequentin.cocobot.domain.MessagesRepository; 11 | import lequentin.cocobot.storage.JsonFileMessagesRepository; 12 | import lequentin.cocobot.storage.UserMessagesJsonConverter; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.nio.file.Path; 17 | 18 | /** 19 | * Runs the `ImpersonationTesting` chat bot. 20 | * @see ImpersonationTestingChatBotApplication 21 | */ 22 | public class ImpersonationTestingApplicationMain { 23 | 24 | private static Logger log = LoggerFactory.getLogger(ImpersonationTestingApplicationMain.class); 25 | 26 | private final GatewayDiscordClient gatewayClient; 27 | private final DiscordMessageListener service; 28 | 29 | public ImpersonationTestingApplicationMain(GatewayDiscordClient gatewayClient, DiscordMessageListener service) { 30 | this.gatewayClient = gatewayClient; 31 | this.service = service; 32 | } 33 | 34 | public static void main(final String[] args) { 35 | final Config config = loadConfig(); 36 | 37 | // Discord API 38 | final DiscordClient discordClient = DiscordClient.create(config.getSecrets().getBotToken()); 39 | final GatewayDiscordClient gateway = discordClient.login().block(); 40 | 41 | 42 | // storage 43 | final UserMessagesJsonConverter jsonConverter = new UserMessagesJsonConverter(); 44 | final MessagesRepository messagesRepository = new JsonFileMessagesRepository( 45 | Path.of("messages.json"), 46 | JsonMapper.get(), 47 | jsonConverter 48 | ); 49 | 50 | // discord package 51 | final DiscordConverter discordConverter = new DiscordConverter(); 52 | 53 | // application 54 | final ImpersonationTestingChatBotApplication impersonationTestingApplication = new ImpersonationTestingChatBotApplication(messagesRepository); 55 | 56 | // service 57 | final DiscordMessageListener service = new DiscordMessageListener(discordConverter, impersonationTestingApplication); 58 | 59 | // app 60 | final ImpersonationTestingApplicationMain app = new ImpersonationTestingApplicationMain(gateway, service); 61 | 62 | app.run(); 63 | } 64 | 65 | public void run() { 66 | service.subscribeToMessageCreateFlux(gatewayClient.on(MessageCreateEvent.class)); 67 | log.info("Listening to new messages..."); 68 | gatewayClient.onDisconnect().block(); 69 | } 70 | 71 | private static Config loadConfig() { 72 | try { 73 | return Config.readFromEnv(System::getenv); 74 | } catch(Exception ex) { 75 | log.error("There was an error reading config files"); 76 | throw ex; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/JsonMapper.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.SerializationFeature; 5 | 6 | public class JsonMapper { 7 | 8 | public static ObjectMapper get() { 9 | return new ObjectMapper() 10 | .findAndRegisterModules() 11 | .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/SynchroniseStorageApplicationMain.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot; 2 | 3 | import discord4j.core.DiscordClient; 4 | import discord4j.core.GatewayDiscordClient; 5 | import lequentin.cocobot.config.Config; 6 | import lequentin.cocobot.discord.DiscordConverter; 7 | import lequentin.cocobot.discord.DiscordDirectAccessMessagesSource; 8 | import lequentin.cocobot.domain.MessagesRepository; 9 | import lequentin.cocobot.domain.MessagesSource; 10 | import lequentin.cocobot.storage.JsonFileMessagesRepository; 11 | import lequentin.cocobot.storage.UserMessagesJsonConverter; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | import java.nio.file.Path; 16 | 17 | public class SynchroniseStorageApplicationMain { 18 | 19 | private static Logger log = LoggerFactory.getLogger(SynchroniseStorageApplicationMain.class); 20 | 21 | private final MessagesSource externalSource; 22 | private final MessagesRepository storage; 23 | 24 | public SynchroniseStorageApplicationMain(MessagesSource externalSource, MessagesRepository storage) { 25 | this.externalSource = externalSource; 26 | this.storage = storage; 27 | } 28 | 29 | public static void main(final String[] args) { 30 | final Config config = loadConfig(); 31 | 32 | final DiscordClient discordClient = DiscordClient.create(config.getSecrets().getBotToken()); 33 | final GatewayDiscordClient gateway = discordClient.login().block(); 34 | 35 | final DiscordConverter discordConverter = new DiscordConverter(); 36 | final MessagesSource messagesSource = new DiscordDirectAccessMessagesSource(gateway, discordConverter); 37 | 38 | final UserMessagesJsonConverter jsonConverter = new UserMessagesJsonConverter(); 39 | final MessagesRepository jsonStorage = new JsonFileMessagesRepository( 40 | Path.of(CocoApplicationMain.MESSAGES_FILE), 41 | JsonMapper.get(), 42 | jsonConverter 43 | ); 44 | 45 | final SynchroniseStorageApplicationMain app = new SynchroniseStorageApplicationMain(messagesSource, jsonStorage); 46 | 47 | app.run(); 48 | } 49 | 50 | public void run() { 51 | storage.synchronise(externalSource); 52 | } 53 | 54 | private static Config loadConfig() { 55 | try { 56 | return Config.readFromEnv(System::getenv); 57 | } catch(Exception ex) { log.error("There was an error reading config files"); 58 | throw ex; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/BotMessage.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | 5 | public class BotMessage { 6 | private final String text; 7 | 8 | public BotMessage(String text) { 9 | if (StringUtils.isBlank(text)) { 10 | throw new IllegalArgumentException("Bot message text should not be blank"); 11 | } 12 | this.text = text; 13 | } 14 | 15 | public String getText() { 16 | return text; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/ChatBot.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | public interface ChatBot { 4 | void handleMessage(IncomingMessage incomingMessage); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/CocoChatBotApplication.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | 5 | import java.util.Optional; 6 | 7 | public class CocoChatBotApplication implements ChatBot { 8 | 9 | private final CocoCommandParser commandParser; 10 | 11 | public CocoChatBotApplication(CocoCommandParser commandParser) { 12 | this.commandParser = commandParser; 13 | } 14 | 15 | public void handleMessage(IncomingMessage incomingMessage) { 16 | Message message = incomingMessage.toDomain(); 17 | 18 | Optional command = commandParser 19 | .parse(message); 20 | 21 | Optional response = command.flatMap(Command::execute); 22 | response.ifPresent(incomingMessage::reply); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/CocoCommandParser.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.application.commands.HelpCommand; 4 | import lequentin.cocobot.application.commands.ImpersonateCommand; 5 | import lequentin.cocobot.application.commands.RegisterMessageCommand; 6 | import lequentin.cocobot.application.commands.UnknownCommand; 7 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 8 | import lequentin.cocobot.config.Config; 9 | import lequentin.cocobot.domain.Impersonator; 10 | import lequentin.cocobot.domain.Message; 11 | import lequentin.cocobot.domain.User; 12 | 13 | import java.util.Arrays; 14 | import java.util.Optional; 15 | import java.util.stream.Collectors; 16 | 17 | public class CocoCommandParser { 18 | 19 | private final String prefix; 20 | private final Impersonator impersonator; 21 | private final ApplicationMessageProvider applicationMessageProvider; 22 | 23 | public CocoCommandParser(Config config, Impersonator impersonator, ApplicationMessageProvider applicationMessageProvider) { 24 | this.prefix = config.getPrefix(); 25 | this.impersonator = impersonator; 26 | this.applicationMessageProvider = applicationMessageProvider; 27 | } 28 | 29 | public Optional parse(Message message) { 30 | String text = message.getText(); 31 | if (!text.startsWith(prefix)) return Optional.of(new RegisterMessageCommand(impersonator, message)); 32 | 33 | String[] args = text.substring(prefix.length()).split(" "); 34 | 35 | Command command = switch(args[0]) { 36 | case "me" -> new ImpersonateCommand(applicationMessageProvider, impersonator, message.getAuthor()); 37 | case "like" -> impersonateCommandFromArgs(args); 38 | case "help" -> new HelpCommand(applicationMessageProvider); 39 | default -> new UnknownCommand(applicationMessageProvider); 40 | }; 41 | 42 | return Optional.of(command); 43 | } 44 | 45 | private ImpersonateCommand impersonateCommandFromArgs(String[] args) { 46 | String username = Arrays.stream(args).skip(1).collect(Collectors.joining(" ")); 47 | User userToImpersonate = new User(username); 48 | return new ImpersonateCommand(applicationMessageProvider, impersonator, userToImpersonate); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/Command.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import java.util.Optional; 4 | 5 | @FunctionalInterface 6 | public interface Command { 7 | Optional execute(); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/ExcludeChatCommandsMessagesFilter.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import lequentin.cocobot.domain.MessagesFilter; 5 | 6 | public class ExcludeChatCommandsMessagesFilter implements MessagesFilter { 7 | @Override 8 | public boolean accepts(Message msg) { 9 | String text = msg.getText(); 10 | if (text.length() < 3) return false; 11 | 12 | return !text.matches("^.?[/!].*$"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/ImpersonationTestingChatBotApplication.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.MessagesSource; 6 | import lequentin.cocobot.domain.StringTokenizer; 7 | import lequentin.cocobot.domain.User; 8 | import lequentin.cocobot.domain.impersonator.LongImpersonationImpersonatorDecorator; 9 | import lequentin.cocobot.domain.impersonator.MarkovImpersonator; 10 | import lequentin.cocobot.domain.impersonator.MessagesFilterImpersonatorDecorator; 11 | import lequentin.cocobot.domain.impersonator.MultipleSentencesImpersonatorDecorator; 12 | import lequentin.cocobot.domain.markov.FindMaxOverBatchOfPathWalkerDecorator; 13 | import lequentin.cocobot.domain.markov.MarkovChains; 14 | import lequentin.cocobot.domain.markov.MarkovChainsWalker; 15 | import lequentin.cocobot.domain.markov.MarkovTokenizer; 16 | import lequentin.cocobot.domain.markov.MarkovWordsGenerator; 17 | import lequentin.cocobot.domain.markov.SimpleMarkovChainsWalker; 18 | import lequentin.cocobot.domain.markov.WordsTuple; 19 | import lequentin.cocobot.domain.sanitizer.SpacePunctuationSanitizer; 20 | import lequentin.cocobot.domain.tokenizer.SanitizerStringTokenizerDecorator; 21 | import lequentin.cocobot.domain.tokenizer.SentencesStringTokenizer; 22 | import lequentin.cocobot.domain.tokenizer.WordsStringTokenizer; 23 | import org.slf4j.Logger; 24 | import org.slf4j.LoggerFactory; 25 | 26 | import java.lang.reflect.Field; 27 | import java.util.Comparator; 28 | import java.util.List; 29 | import java.util.Map; 30 | import java.util.Map.Entry; 31 | import java.util.Random; 32 | import java.util.stream.IntStream; 33 | 34 | /** 35 | * This is my "prototyping" chat bot. I use it to try and compare different settings, and see what produces the funniest outputs! 36 | */ 37 | public class ImpersonationTestingChatBotApplication implements ChatBot { 38 | 39 | private static Logger log = LoggerFactory.getLogger(ImpersonationTestingChatBotApplication.class); 40 | 41 | private final Impersonator simpleSentencesImpersonator = null; 42 | 43 | private final MarkovImpersonator markov3Impersonator; 44 | private final MarkovImpersonator markov3PunctuationImpersonator; 45 | private final MarkovImpersonator markov2Impersonator = null; 46 | 47 | private Impersonator multi2Impersonator; 48 | private final Impersonator multi4Impersonator = null; 49 | 50 | private final Impersonator markov3BatchWalkerImpersonator; 51 | private final Impersonator markov3BatchWalkerPunctuationImpersonator; 52 | private final Impersonator markov2BatchWalkerImpersonator = null; 53 | 54 | public ImpersonationTestingChatBotApplication(MessagesSource source) { 55 | StringTokenizer sentencesStringTokenizer = new SentencesStringTokenizer(); 56 | StringTokenizer sentencesStringPunctuationTokenizer = new SanitizerStringTokenizerDecorator(new SpacePunctuationSanitizer(), new SentencesStringTokenizer()); 57 | 58 | WordsStringTokenizer wordsTokenizer = new WordsStringTokenizer(); 59 | StringTokenizer wordsAndPunctuationTokenizer = new SanitizerStringTokenizerDecorator(new SpacePunctuationSanitizer(), wordsTokenizer); 60 | MarkovTokenizer markov2Tokenizer = new MarkovTokenizer(wordsTokenizer, 2); 61 | MarkovTokenizer markov3Tokenizer = new MarkovTokenizer(wordsTokenizer, 3); 62 | MarkovTokenizer markov3PunctuationTokenizer = new MarkovTokenizer(wordsAndPunctuationTokenizer, 3); 63 | 64 | // Impersonator markov2Impersonator = new MessagesFilterImpersonatorDecorator( 65 | // new ExcludeChatCommandsMessagesFilter(), 66 | // new MarkovImpersonator(sentencesStringTokenizer, markov2Tokenizer, new SimpleMarkovChainsWalker<>(new Random())) 67 | // ); 68 | // markov2Impersonator = new LongImpersonationImpersonatorDecorator(markov2Impersonator, 30, 200); 69 | // markov2Impersonator.addAllMessagesFromSource(source); 70 | // this.markov2Impersonator = markov2Impersonator; 71 | // 72 | 73 | // Impersonator markov3Impersonator = new MessagesFilterImpersonatorDecorator( 74 | // new ExcludeChatCommandsMessagesFilter(), 75 | // new MarkovImpersonator(sentencesStringTokenizer, markov3Tokenizer, new SimpleMarkovChainsWalker<>(new Random())) 76 | // ); 77 | // markov3Impersonator.addAllMessagesFromSource(source); 78 | // this.markov3Impersonator = new LongImpersonationImpersonatorDecorator(markov3Impersonator, 30, 200); 79 | 80 | // this.multi2Impersonator = new MultipleSentencesImpersonatorDecorator( 81 | // new LongImpersonationImpersonatorDecorator(markov3Impersonator, 15, 200), 82 | // 2 83 | // ); 84 | 85 | // this.multi4Impersonator = new MultipleSentencesImpersonatorDecorator( 86 | // new LongImpersonationImpersonatorDecorator(markov3Impersonator, 15, 200), 87 | // 4 88 | // ); 89 | 90 | 91 | final MarkovChainsWalker leastDeterministicWalker = new FindMaxOverBatchOfPathWalkerDecorator<>( 92 | new SimpleMarkovChainsWalker<>(new Random()), 93 | Comparator.comparingInt(path -> (int)Math.round(path.getNonDeterministicScore() * (Math.log10(path.getLength())))), 94 | 50, 95 | 0 96 | ); 97 | 98 | final MarkovChainsWalker mostDeterministicWalker = new FindMaxOverBatchOfPathWalkerDecorator<>( 99 | new SimpleMarkovChainsWalker<>(new Random()), 100 | Comparator.comparingInt(path -> path.getLength()*100000/path.getNonDeterministicScore()), 101 | 400, 102 | 20 103 | ); 104 | 105 | // this.markov2Impersonator = new MarkovImpersonator(sentencesStringTokenizer, markov2Tokenizer, mostDeterministicWalker); 106 | // Impersonator markov2BatchImpersonator = new MessagesFilterImpersonatorDecorator( 107 | // new ExcludeChatCommandsMessagesFilter(), 108 | // markov2Impersonator 109 | // ); 110 | // markov2BatchImpersonator.addAllMessagesFromSource(source); 111 | // this.markov2BatchWalkerImpersonator = new LongImpersonationImpersonatorDecorator(markov2BatchImpersonator, 4, 200); 112 | // 113 | this.markov3Impersonator = new MarkovImpersonator(sentencesStringTokenizer, markov3Tokenizer, leastDeterministicWalker); 114 | this.markov3PunctuationImpersonator = new MarkovImpersonator(sentencesStringTokenizer, markov3PunctuationTokenizer, leastDeterministicWalker); 115 | Impersonator markov3BatchImpersonator = new MessagesFilterImpersonatorDecorator( 116 | new ExcludeChatCommandsMessagesFilter(), 117 | markov3Impersonator 118 | ); 119 | Impersonator markov3BatchPunctuationImpersonator = new MessagesFilterImpersonatorDecorator( 120 | new ExcludeChatCommandsMessagesFilter(), 121 | markov3PunctuationImpersonator 122 | ); 123 | this.markov3BatchWalkerImpersonator = new LongImpersonationImpersonatorDecorator(markov3BatchImpersonator, 4, 200); 124 | this.markov3BatchWalkerPunctuationImpersonator = new LongImpersonationImpersonatorDecorator(markov3BatchPunctuationImpersonator, 4, 200); 125 | 126 | // markov3Impersonator.addAllMessagesFromSource(source); 127 | markov3PunctuationImpersonator.addAllMessagesFromSource(source); 128 | 129 | this.multi2Impersonator = new MultipleSentencesImpersonatorDecorator( 130 | markov3PunctuationImpersonator, 131 | 2 132 | ); 133 | 134 | } 135 | 136 | @Override 137 | public void handleMessage(IncomingMessage incomingMessage) { 138 | Message message = incomingMessage.toDomain(); 139 | if (message.getText().startsWith("c/sentences")) { 140 | incomingMessage.reply(new BotMessage(simpleSentencesImpersonator.impersonate(message.getAuthor()))); 141 | } 142 | if (message.getText().startsWith("c/markov2")) { 143 | incomingMessage.reply(new BotMessage(markov2Impersonator.impersonate(message.getAuthor()))); 144 | } 145 | if (message.getText().startsWith("c/markov3")) { 146 | incomingMessage.reply(new BotMessage(markov3Impersonator.impersonate(message.getAuthor()))); 147 | } 148 | if (message.getText().startsWith("c/multi2")) { 149 | incomingMessage.reply(new BotMessage(multi2Impersonator.impersonate(message.getAuthor()))); 150 | } 151 | if (message.getText().startsWith("c/multi4")) { 152 | incomingMessage.reply(new BotMessage(multi4Impersonator.impersonate(message.getAuthor()))); 153 | } 154 | } 155 | 156 | void sample() { 157 | List authors = List.of( 158 | new User("DapperCloud"), 159 | new User("Hisatak"), 160 | new User("Nasvar"), 161 | new User("Zukajin"), 162 | new User("Monsieur Blu"), 163 | new User("Luo Sha") 164 | ); 165 | 166 | Map impersonators = Map.of( 167 | // "markov2", markov2Impersonator, 168 | // "markov3", markov3Impersonator, 169 | // "multi2", multi2Impersonator, 170 | // "markov2Batch", markov2BatchWalkerImpersonator, 171 | // "markov3Batch", markov3BatchWalkerImpersonator, 172 | // "markov3Batch (punctuation)", markov3BatchWalkerPunctuationImpersonator 173 | "multi2 (batch + punctuation)", multi2Impersonator 174 | ); 175 | 176 | impersonators.forEach((name, impersonator) -> { 177 | log.info(name + "\n------------------------------"); 178 | authors.forEach(user -> { 179 | log.info(user.getUsername()); 180 | IntStream.range(1, 6).forEach(i -> log.info(i + ": " + impersonator.impersonate(user))); 181 | }); 182 | }); 183 | } 184 | 185 | void printChainsMetadata() throws Exception { 186 | Map markovs = Map.of( 187 | // "markov2", markov2Impersonator, 188 | // "markov3", markov3Impersonator, 189 | // "multi2", multi2Impersonator, 190 | // "markov2", markov2Impersonator, 191 | // "markov3", markov3Impersonator 192 | "markov3 (punctuation)", markov3PunctuationImpersonator 193 | ); 194 | 195 | List authors = List.of( 196 | new User("DapperCloud"), 197 | new User("Hisatak"), 198 | new User("Nasvar"), 199 | new User("Zukajin"), 200 | new User("Monsieur Blu"), 201 | new User("Luo Sha") 202 | ); 203 | for (Entry entry : markovs.entrySet()) { 204 | String name = entry.getKey(); 205 | MarkovImpersonator markov = entry.getValue(); 206 | Map userMarkovGenerators = (Map) getPrivateField(markov 207 | .getClass() 208 | .getDeclaredField("userMarkovGenerators")) 209 | .get(markov); 210 | log.info(name + "\n------------------------------"); 211 | for (User user : authors) { 212 | MarkovChains chains = (MarkovChains) getPrivateField(userMarkovGenerators.get(user).getClass() 213 | .getDeclaredField("markovChains")) 214 | .get(userMarkovGenerators.get(user)); 215 | log.info(user.getUsername() + ": " + chains.getMetadata()); 216 | } 217 | } 218 | } 219 | 220 | private Field getPrivateField(Field field) { 221 | field.setAccessible(true); 222 | return field; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/IncomingMessage.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import reactor.core.publisher.Mono; 5 | 6 | public interface IncomingMessage { 7 | Message toDomain(); 8 | Mono reply(BotMessage message); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/RemoveQuotesAndBlocksStringSanitizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.StringSanitizer; 4 | 5 | import java.util.Arrays; 6 | import java.util.stream.Collectors; 7 | 8 | public class RemoveQuotesAndBlocksStringSanitizer implements StringSanitizer { 9 | @Override 10 | public String sanitize(String text) { 11 | String withoutBlocks = text.replaceAll("```(.*\\n?)*```", ""); 12 | String result = Arrays.stream(withoutBlocks.split("\n")) 13 | .filter(line -> !line.startsWith(">")) 14 | .collect(Collectors.joining("\n")); 15 | return result; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/commands/HelpCommand.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.Command; 5 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 6 | 7 | import java.util.Optional; 8 | 9 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.HELP; 10 | 11 | public class HelpCommand implements Command { 12 | 13 | private final ApplicationMessageProvider applicationMessageProvider; 14 | 15 | public HelpCommand(ApplicationMessageProvider applicationMessageProvider) { 16 | this.applicationMessageProvider = applicationMessageProvider; 17 | } 18 | 19 | @Override 20 | public Optional execute() { 21 | return Optional.of(new BotMessage(applicationMessageProvider.getMessage(HELP))); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/commands/ImpersonateCommand.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.Command; 5 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 6 | import lequentin.cocobot.domain.Impersonator; 7 | import lequentin.cocobot.domain.User; 8 | import lequentin.cocobot.domain.UserNotFoundException; 9 | 10 | import java.util.Optional; 11 | 12 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.USER_NOT_FOUND; 13 | 14 | public class ImpersonateCommand implements Command { 15 | 16 | private final ApplicationMessageProvider applicationMessageProvider; 17 | private final Impersonator impersonator; 18 | private final User author; 19 | 20 | public ImpersonateCommand(ApplicationMessageProvider applicationMessageProvider, Impersonator impersonator, User author) { 21 | this.applicationMessageProvider = applicationMessageProvider; 22 | this.impersonator = impersonator; 23 | this.author = author; 24 | } 25 | 26 | @Override 27 | public Optional execute() { 28 | try { 29 | return Optional.of(new BotMessage(impersonator.impersonate(author))); 30 | } catch (UserNotFoundException ex) { 31 | String reply = applicationMessageProvider.getMessage(USER_NOT_FOUND, ex.getUsername()); 32 | return Optional.of(new BotMessage(reply)); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/commands/RegisterMessageCommand.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.Command; 5 | import lequentin.cocobot.domain.Impersonator; 6 | import lequentin.cocobot.domain.Message; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | import java.util.Optional; 11 | 12 | public class RegisterMessageCommand implements Command { 13 | 14 | private static Logger log = LoggerFactory.getLogger(RegisterMessageCommand.class); 15 | 16 | private final Impersonator impersonator; 17 | private final Message message; 18 | 19 | public RegisterMessageCommand(Impersonator impersonator, Message message) { 20 | this.impersonator = impersonator; 21 | this.message = message; 22 | } 23 | 24 | @Override 25 | public Optional execute() { 26 | log.info("Adding message to model: " + message.getText()); 27 | impersonator.addMessage(message); 28 | return Optional.empty(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/commands/UnknownCommand.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.Command; 5 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 6 | 7 | import java.util.Optional; 8 | 9 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.COMMAND_UNKNOWN; 10 | 11 | public class UnknownCommand implements Command { 12 | 13 | private final ApplicationMessageProvider applicationMessageProvider; 14 | 15 | public UnknownCommand(ApplicationMessageProvider applicationMessageProvider) { 16 | this.applicationMessageProvider = applicationMessageProvider; 17 | } 18 | 19 | @Override 20 | public Optional execute() { 21 | return Optional.of(new BotMessage(applicationMessageProvider.getMessage(COMMAND_UNKNOWN))); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/messages/ApplicationMessageCode.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.messages; 2 | 3 | public enum ApplicationMessageCode { 4 | USER_NOT_FOUND, 5 | COMMAND_UNKNOWN, 6 | HELP, 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/messages/ApplicationMessageProvider.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.messages; 2 | 3 | public interface ApplicationMessageProvider { 4 | String getMessage(ApplicationMessageCode messageCode, String... templateVariables); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/application/messages/InMemoryApplicationMessageProvider.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.messages; 2 | 3 | import lequentin.cocobot.config.Config; 4 | 5 | import java.util.Map; 6 | 7 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.COMMAND_UNKNOWN; 8 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.HELP; 9 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.USER_NOT_FOUND; 10 | 11 | public class InMemoryApplicationMessageProvider implements ApplicationMessageProvider { 12 | 13 | private final Map messageTemplates; 14 | 15 | public InMemoryApplicationMessageProvider(Config config) { 16 | messageTemplates = switch (config.getLanguage()) { 17 | case EN -> Map.of( 18 | USER_NOT_FOUND, "I don't know the user {}", 19 | COMMAND_UNKNOWN, "I don't know this command!", 20 | HELP, 21 | """ 22 | *kwaa kwaaaa* Coco is happy! Here are my commands: 23 | ``` 24 | {prefix}me - I impersonate you 25 | {prefix}like John Doe - I impersonate user `John Doe#XXXX` 26 | {prefix}help - I show this help 27 | ``` 28 | """.replaceAll("\\{prefix}", config.getPrefix()) 29 | ); 30 | case FR -> Map.of( 31 | USER_NOT_FOUND, "Je ne connais pas l'utilisateur {}", 32 | COMMAND_UNKNOWN, "Je ne connais pas cette commande !", 33 | HELP, 34 | """ 35 | *cuii cuiiii* Coco est content ! Voici mes commandes : 36 | ``` 37 | {prefix}me - Je t'imite 38 | {prefix}like John Doe - J'imite l'utisateur `John Doe#XXXX` 39 | {prefix}help - Je montre cette aide 40 | ``` 41 | """.replaceAll("\\{prefix}", config.getPrefix()) 42 | ); 43 | }; 44 | } 45 | 46 | @Override 47 | public String getMessage(ApplicationMessageCode messageCode, String... templateVariables) { 48 | return replaceTemplatePlaceholders(messageTemplates.get(messageCode), templateVariables); 49 | } 50 | 51 | private String replaceTemplatePlaceholders(String template, String... templateVariables) { 52 | return String.format(template.replaceAll("\\{}", "%s"), (Object[]) templateVariables); 53 | } 54 | 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/config/Config.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.config; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.util.Scanner; 8 | 9 | public class Config { 10 | 11 | private static Logger log = LoggerFactory.getLogger(Config.class); 12 | 13 | private final Secrets secrets; 14 | private final Language language; 15 | private final String prefix; 16 | 17 | public static final Scanner INPUT_SCANNER = new Scanner(System.in); 18 | 19 | public Config(Secrets secrets, Language language, String prefix) { 20 | this.secrets = secrets; 21 | this.language = language; 22 | this.prefix = prefix; 23 | } 24 | 25 | public Secrets getSecrets() { 26 | return secrets; 27 | } 28 | 29 | public Language getLanguage() { 30 | return language; 31 | } 32 | 33 | public String getPrefix() { 34 | return prefix; 35 | } 36 | 37 | public static Config readFromEnv(PropertiesProvider propertiesProvider) { 38 | return readFromEnv(propertiesProvider, false); 39 | } 40 | 41 | public static Config readFromEnv(PropertiesProvider propertiesProvider, boolean promptFallback) { 42 | Builder builder = new Builder(); 43 | 44 | String botToken = propertiesProvider.getProperty("COCOBOT_TOKEN"); 45 | if (StringUtils.isBlank(botToken)) { 46 | if (!promptFallback) throw new RuntimeException("COCOBOT_TOKEN env var not set!"); 47 | log.info("Please provide COCOBOT_TOKEN"); 48 | botToken = INPUT_SCANNER.nextLine(); 49 | } 50 | builder.secrets(new Secrets(botToken)); 51 | 52 | String languageString = propertiesProvider.getProperty("COCOBOT_LANGUAGE"); 53 | if (StringUtils.isNotBlank(languageString)) { 54 | builder.language(Language.valueOf(languageString.toUpperCase())); 55 | } 56 | 57 | String prefixString = propertiesProvider.getProperty("COCOBOT_PREFIX"); 58 | if (StringUtils.isNotBlank(prefixString)) { 59 | builder.prefix(prefixString); 60 | } 61 | 62 | return builder.build(); 63 | } 64 | 65 | @FunctionalInterface 66 | public interface PropertiesProvider { 67 | String getProperty(String propertyName); 68 | } 69 | 70 | private static class Builder { 71 | 72 | private Secrets secrets; 73 | private Language language; 74 | private String prefix; 75 | 76 | private Builder() { 77 | language = Language.EN; 78 | prefix = "c/"; 79 | } 80 | 81 | public Builder secrets(Secrets secrets) { 82 | this.secrets = secrets; 83 | return this; 84 | } 85 | 86 | public Builder language(Language language) { 87 | this.language = language; 88 | return this; 89 | } 90 | 91 | public Builder prefix(String prefix) { 92 | this.prefix = prefix; 93 | return this; 94 | } 95 | 96 | public Config build() { 97 | return new Config(secrets, language, prefix); 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/config/Language.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.config; 2 | 3 | public enum Language { 4 | EN, FR 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/config/Secrets.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.config; 2 | 3 | public class Secrets { 4 | private final String botToken; 5 | 6 | public Secrets(String botToken) { 7 | this.botToken = botToken; 8 | } 9 | 10 | public String getBotToken() { 11 | return botToken; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/discord/DiscordConverter.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import lequentin.cocobot.domain.User; 5 | 6 | public class DiscordConverter { 7 | 8 | public Message toDomain(discord4j.core.object.entity.Message discordMessage) { 9 | discord4j.core.object.entity.User discordUser = discordMessage.getAuthor() 10 | .orElseThrow(() -> new RuntimeException("Message has no user")); 11 | User author = new User(discordUser.getUsername()); 12 | return new Message(author, discordMessage.getTimestamp(), discordMessage.getContent()); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/discord/DiscordDirectAccessMessagesSource.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.GatewayDiscordClient; 4 | import discord4j.core.object.entity.Guild; 5 | import discord4j.core.object.entity.channel.Channel.Type; 6 | import discord4j.core.object.entity.channel.GuildChannel; 7 | import discord4j.core.object.entity.channel.TextChannel; 8 | import discord4j.rest.util.Permission; 9 | import lequentin.cocobot.domain.Message; 10 | import lequentin.cocobot.domain.MessagesSource; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import reactor.core.publisher.Flux; 14 | 15 | import java.util.Set; 16 | 17 | public class DiscordDirectAccessMessagesSource implements MessagesSource { 18 | 19 | private static Logger log = LoggerFactory.getLogger(DiscordDirectAccessMessagesSource.class); 20 | 21 | private final GatewayDiscordClient discord; 22 | private final DiscordConverter converter; 23 | 24 | public DiscordDirectAccessMessagesSource(GatewayDiscordClient discord, DiscordConverter converter) { 25 | this.discord = discord; 26 | this.converter = converter; 27 | } 28 | 29 | @Override 30 | public Flux getAllMessages() { 31 | return discord.getGuilds() 32 | .flatMap(Guild::getChannels) 33 | .filter(this::canParseChannel) 34 | .map(channel -> (TextChannel) channel) 35 | .flatMap(this::allMessagesFlux); 36 | } 37 | 38 | private boolean canParseChannel(GuildChannel channel) { 39 | return channel.getType().equals(Type.GUILD_TEXT) && 40 | channel.getEffectivePermissions(discord.getSelfId()) 41 | .filter(perm -> perm.containsAll(Set.of(Permission.VIEW_CHANNEL, Permission.READ_MESSAGE_HISTORY))) 42 | .hasElement() 43 | .blockOptional() 44 | .orElse(false); 45 | } 46 | 47 | private Flux allMessagesFlux(TextChannel channel) { 48 | log.info("Fetching all messages for channel " + channel.getName()); 49 | return channel.getLastMessageId() 50 | .map(channel::getMessagesBefore) 51 | .orElseGet(() -> { 52 | log.error("Not parsing channel {} because cannot get last message id.", channel.getName()); 53 | return Flux.empty(); 54 | }) 55 | .filter(msg -> !msg.getContent().isBlank()) 56 | .map(converter::toDomain); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/discord/DiscordIncomingMessage.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.spec.MessageCreateSpec; 4 | import lequentin.cocobot.application.BotMessage; 5 | import lequentin.cocobot.application.IncomingMessage; 6 | import lequentin.cocobot.domain.Message; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import reactor.core.publisher.Mono; 10 | import reactor.core.scheduler.Schedulers; 11 | 12 | import java.util.logging.Level; 13 | 14 | public class DiscordIncomingMessage implements IncomingMessage { 15 | 16 | private static Logger log = LoggerFactory.getLogger(DiscordIncomingMessage.class); 17 | 18 | private final discord4j.core.object.entity.Message discordMessage; 19 | private final DiscordConverter converter; 20 | 21 | public DiscordIncomingMessage(discord4j.core.object.entity.Message discordMessage, DiscordConverter converter) { 22 | this.discordMessage = discordMessage; 23 | this.converter = converter; 24 | } 25 | 26 | @Override 27 | public Message toDomain() { 28 | return converter.toDomain(discordMessage); 29 | } 30 | 31 | @Override 32 | public Mono reply(BotMessage message) { 33 | log.debug("Replying with message: {}", message.getText()); 34 | Mono messageMono = discordMessage.getChannel() 35 | .log("lequentin.cocobot.discord.DiscordIncomingMessage-channel", Level.FINE) 36 | .subscribeOn(Schedulers.immediate()) 37 | .map(channel -> channel.createMessage(MessageCreateSpec.builder().content(message.getText()).build())) 38 | .log("lequentin.cocobot.discord.DiscordIncomingMessage-message", Level.FINE) 39 | .flatMap(messageCreateSpec -> messageCreateSpec.map(converter::toDomain)) 40 | .log("lequentin.cocobot.discord.DiscordIncomingMessage-converted", Level.FINE); 41 | messageMono.subscribe(); 42 | return messageMono; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/discord/DiscordMessageListener.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.event.domain.message.MessageCreateEvent; 4 | import discord4j.core.object.entity.Message; 5 | import lequentin.cocobot.application.ChatBot; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import reactor.core.publisher.Flux; 9 | 10 | public class DiscordMessageListener { 11 | 12 | private static Logger log = LoggerFactory.getLogger(DiscordMessageListener.class); 13 | 14 | private final DiscordConverter converter; 15 | private final ChatBot coco; 16 | 17 | public DiscordMessageListener(DiscordConverter converter, ChatBot coco) { 18 | this.converter = converter; 19 | this.coco = coco; 20 | } 21 | 22 | public void subscribeToMessageCreateFlux(Flux eventFlux) { 23 | eventFlux.subscribe(event -> { 24 | final Message message = event.getMessage(); 25 | try { 26 | DiscordIncomingMessage incomingMessage = new DiscordIncomingMessage(message, converter); 27 | coco.handleMessage(incomingMessage); 28 | } catch(Exception ex) { 29 | log.error("Exception while handling message: {}", message.getContent()); 30 | ex.printStackTrace(System.err); 31 | } 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/Impersonator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | 4 | public interface Impersonator { 5 | default void addAllMessagesFromSource(MessagesSource messagesSource) { 6 | messagesSource.getAllMessages().subscribe(this::addMessage); 7 | } 8 | void addMessage(Message message); 9 | String impersonate(User user); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/Message.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import java.time.Instant; 4 | 5 | public class Message { 6 | private final User author; 7 | private final Instant createdAt; 8 | private final String text; 9 | 10 | public Message(User author, Instant createdAt, String text) { 11 | this.author = author; 12 | this.createdAt = createdAt; 13 | this.text = text; 14 | } 15 | 16 | public User getAuthor() { 17 | return author; 18 | } 19 | 20 | public Instant getCreatedAt() { 21 | return createdAt; 22 | } 23 | 24 | public String getText() { 25 | return text; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/MessagesFilter.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | @FunctionalInterface 4 | public interface MessagesFilter { 5 | boolean accepts(Message msg); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/MessagesRepository.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import reactor.core.publisher.Flux; 4 | 5 | public interface MessagesRepository extends MessagesSource { 6 | Flux getAllMessages(); 7 | void synchronise(MessagesSource externalSource); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/MessagesSource.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import reactor.core.publisher.Flux; 4 | 5 | @FunctionalInterface 6 | public interface MessagesSource { 7 | Flux getAllMessages(); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/StringSanitizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | public interface StringSanitizer { 4 | String sanitize(String text); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/StringTokenizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import java.util.stream.Stream; 4 | 5 | public interface StringTokenizer { 6 | Stream tokenize(String str); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/User.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import java.util.Objects; 4 | 5 | public class User { 6 | private final String username; 7 | 8 | public User(String username) { 9 | this.username = username; 10 | } 11 | 12 | public String getUsername() { 13 | return username; 14 | } 15 | 16 | @Override 17 | public boolean equals(Object o) { 18 | if (this == o) return true; 19 | if (o == null || getClass() != o.getClass()) return false; 20 | User user = (User) o; 21 | return username.equalsIgnoreCase(user.username); 22 | } 23 | 24 | @Override 25 | public int hashCode() { 26 | return Objects.hash(username.toUpperCase()); 27 | } 28 | 29 | @Override 30 | public String toString() { 31 | return username; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/UserNotFoundException.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | public class UserNotFoundException extends RuntimeException { 4 | 5 | private final String username; 6 | 7 | public UserNotFoundException(String username) { 8 | super("User " + username + " not found"); 9 | this.username = username; 10 | } 11 | 12 | public String getUsername() { 13 | return username; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/impersonator/LongImpersonationImpersonatorDecorator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.impersonator; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.User; 6 | 7 | import java.util.Comparator; 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | import java.util.Map.Entry; 11 | 12 | public class LongImpersonationImpersonatorDecorator implements Impersonator { 13 | 14 | private final Impersonator impersonator; 15 | private final int minimumWordsCount; 16 | private final int maximumAttempts; 17 | 18 | public LongImpersonationImpersonatorDecorator(Impersonator impersonator, int minimumWordsCount, int maximumAttempts) { 19 | this.impersonator = impersonator; 20 | this.minimumWordsCount = minimumWordsCount; 21 | this.maximumAttempts = maximumAttempts; 22 | } 23 | 24 | @Override 25 | public void addMessage(Message message) { 26 | impersonator.addMessage(message); 27 | } 28 | 29 | @Override 30 | public String impersonate(User user) { 31 | Map impersonations = new HashMap<>(); 32 | for (int i = 0; i < maximumAttempts; i++) { 33 | String newImpersonation = impersonator.impersonate(user); 34 | int wordsCount = newImpersonation.split(" ").length; 35 | if (wordsCount >= minimumWordsCount) { 36 | return newImpersonation; 37 | } 38 | impersonations.put(newImpersonation, wordsCount); 39 | } 40 | 41 | // We never got a long enough impersonation, returning the longest one 42 | return impersonations.entrySet().stream() 43 | .sorted(Entry.comparingByValue(Comparator.comparingInt(a -> -a))) 44 | .max(Entry.comparingByValue()) 45 | .orElseThrow(() -> new RuntimeException("Should not happen")) 46 | .getKey(); 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/impersonator/MarkovImpersonator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.impersonator; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.StringTokenizer; 6 | import lequentin.cocobot.domain.User; 7 | import lequentin.cocobot.domain.UserNotFoundException; 8 | import lequentin.cocobot.domain.markov.MarkovChainsWalker; 9 | import lequentin.cocobot.domain.markov.MarkovTokenizer; 10 | import lequentin.cocobot.domain.markov.MarkovWordsGenerator; 11 | import lequentin.cocobot.domain.markov.WordsTuple; 12 | 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | public class MarkovImpersonator implements Impersonator { 17 | 18 | private final StringTokenizer sentencesStringTokenizer; 19 | private final MarkovTokenizer markovTokenizer; 20 | private final MarkovChainsWalker walker; 21 | 22 | private final Map userMarkovGenerators; 23 | 24 | public MarkovImpersonator(StringTokenizer sentencesStringTokenizer, MarkovTokenizer markovTokenizer, MarkovChainsWalker walker) { 25 | this.sentencesStringTokenizer = sentencesStringTokenizer; 26 | this.markovTokenizer = markovTokenizer; 27 | this.walker = walker; 28 | this.userMarkovGenerators = new HashMap<>(); 29 | } 30 | 31 | @Override 32 | public void addMessage(Message message) { 33 | getOrCreateUserGenerator(message.getAuthor()) 34 | .addText(message.getText()); 35 | } 36 | 37 | @Override 38 | public String impersonate(User user) { 39 | if (!userMarkovGenerators.containsKey(user)) { 40 | throw new UserNotFoundException(user.getUsername()); 41 | } 42 | return userMarkovGenerators.get(user) 43 | .generate(); 44 | } 45 | 46 | private MarkovWordsGenerator getOrCreateUserGenerator(User user) { 47 | if (!userMarkovGenerators.containsKey(user)) userMarkovGenerators.put(user, newMarkovGenerator()); 48 | return userMarkovGenerators.get(user); 49 | } 50 | 51 | private MarkovWordsGenerator newMarkovGenerator() { 52 | return new MarkovWordsGenerator(sentencesStringTokenizer, markovTokenizer, walker); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/impersonator/MessagesFilterImpersonatorDecorator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.impersonator; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.MessagesFilter; 6 | import lequentin.cocobot.domain.MessagesSource; 7 | import lequentin.cocobot.domain.User; 8 | 9 | public class MessagesFilterImpersonatorDecorator implements Impersonator { 10 | private final MessagesFilter filter; 11 | private final Impersonator impersonator; 12 | 13 | public MessagesFilterImpersonatorDecorator(MessagesFilter filter, Impersonator impersonator) { 14 | this.filter = filter; 15 | this.impersonator = impersonator; 16 | } 17 | 18 | @Override 19 | public void addAllMessagesFromSource(MessagesSource messagesSource) { 20 | MessagesSource filteredSource = () -> messagesSource.getAllMessages().filter(filter::accepts); 21 | impersonator.addAllMessagesFromSource(filteredSource); 22 | } 23 | 24 | @Override 25 | public void addMessage(Message message) { 26 | if (filter.accepts(message)) impersonator.addMessage(message); 27 | } 28 | 29 | @Override 30 | public String impersonate(User user) { 31 | return impersonator.impersonate(user); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/impersonator/MultipleSentencesImpersonatorDecorator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.impersonator; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.User; 6 | 7 | import java.util.stream.Collectors; 8 | import java.util.stream.IntStream; 9 | 10 | public class MultipleSentencesImpersonatorDecorator implements Impersonator { 11 | 12 | private final Impersonator impersonator; 13 | private final int numberOfSentences; 14 | 15 | public MultipleSentencesImpersonatorDecorator(Impersonator impersonator, int numberOfSentences) { 16 | this.impersonator = impersonator; 17 | this.numberOfSentences = numberOfSentences; 18 | } 19 | 20 | @Override 21 | public void addMessage(Message message) { 22 | impersonator.addMessage(message); 23 | } 24 | 25 | @Override 26 | public String impersonate(User user) { 27 | return IntStream.range(0, numberOfSentences) 28 | .mapToObj(i -> impersonator.impersonate(user)) 29 | .collect(Collectors.joining(". ")); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/impersonator/SimpleTokensRandomImpersonator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.impersonator; 2 | 3 | import lequentin.cocobot.domain.Impersonator; 4 | import lequentin.cocobot.domain.Message; 5 | import lequentin.cocobot.domain.StringTokenizer; 6 | import lequentin.cocobot.domain.User; 7 | import lequentin.cocobot.domain.UserNotFoundException; 8 | 9 | import java.util.ArrayList; 10 | import java.util.HashMap; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.Random; 14 | import java.util.stream.Collectors; 15 | import java.util.stream.IntStream; 16 | 17 | public class SimpleTokensRandomImpersonator implements Impersonator { 18 | 19 | private final StringTokenizer stringTokenizer; 20 | private final Random random; 21 | private final Map> usersTokens; 22 | 23 | public SimpleTokensRandomImpersonator(StringTokenizer stringTokenizer, Random random) { 24 | this.stringTokenizer = stringTokenizer; 25 | this.random = random; 26 | this.usersTokens = new HashMap<>(); 27 | } 28 | 29 | @Override 30 | public void addMessage(Message message) { 31 | User author = message.getAuthor(); 32 | List currentUserTokens = usersTokens.getOrDefault(author, new ArrayList<>()); 33 | currentUserTokens.addAll(stringTokenizer.tokenize(message.getText()).collect(Collectors.toList())); 34 | usersTokens.put(author, currentUserTokens); 35 | } 36 | 37 | @Override 38 | public String impersonate(User user) { 39 | if (!usersTokens.containsKey(user)) { 40 | throw new UserNotFoundException(user.getUsername()); 41 | } 42 | 43 | List sentences = usersTokens.get(user); 44 | if (sentences.isEmpty()) { 45 | throw new UserNotFoundException("User " + user.getUsername() + " has no messages."); 46 | } 47 | 48 | return IntStream.generate(() -> random.nextInt(sentences.size())) 49 | .limit(5) 50 | .mapToObj(sentences::get) 51 | .collect(Collectors.joining(". ")); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/FindMaxOverBatchOfPathWalkerDecorator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import lequentin.cocobot.domain.markov.MarkovPath.Builder; 4 | 5 | import java.util.Comparator; 6 | import java.util.function.Predicate; 7 | import java.util.stream.Collectors; 8 | import java.util.stream.IntStream; 9 | import java.util.stream.Stream; 10 | 11 | public class FindMaxOverBatchOfPathWalkerDecorator implements MarkovChainsWalker { 12 | 13 | private final MarkovChainsWalker walker; 14 | private final Comparator> pathsComparator; 15 | private final int numberOfPaths; 16 | private final int skip; 17 | 18 | public FindMaxOverBatchOfPathWalkerDecorator(MarkovChainsWalker walker, Comparator> pathsComparator, int numberOfPaths, int skip) { 19 | this.walker = walker; 20 | this.pathsComparator = pathsComparator; 21 | this.numberOfPaths = numberOfPaths; 22 | this.skip = skip; 23 | } 24 | 25 | @Override 26 | public MarkovPath walkFromUntil(MarkovChains markovChains, T startingPoint, Predicate> walkUntil) { 27 | return IntStream.range(0, numberOfPaths) 28 | .mapToObj(i -> walker.walkFromUntil(markovChains, startingPoint, walkUntil)) 29 | .sorted(pathsComparator) 30 | .skip(skip) 31 | .max(pathsComparator) 32 | .orElseThrow(() -> new RuntimeException("Should never happen!")); 33 | } 34 | 35 | private String getSentence(MarkovPath path) { 36 | return Stream.concat(Stream.of(), path.getPath()) 37 | .filter(tuple -> tuple != WordsTuple.EMPTY) 38 | .map(WordsTuple::lastWord).collect(Collectors.joining(" ")); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovChains.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import java.util.HashMap; 4 | import java.util.List; 5 | import java.util.Map; 6 | 7 | public class MarkovChains { 8 | private final Map> states; 9 | 10 | public MarkovChains() { 11 | this.states = new HashMap<>(); 12 | } 13 | 14 | public void addTransition(T from, T to) { 15 | MarkovState stateFrom = getOrCreateState(from); 16 | MarkovState stateTo = getOrCreateState(to); 17 | stateFrom.incrementTransitionTo(stateTo); 18 | } 19 | 20 | private MarkovState getOrCreateState(T value) { 21 | if (!states.containsKey(value)) states.put(value, new MarkovState<>(value)); 22 | return states.get(value); 23 | } 24 | 25 | public MarkovState getState(T value) { 26 | return states.get(value); 27 | } 28 | 29 | public Metadata getMetadata() { 30 | List nextStatesCounts = states.entrySet().stream() 31 | .filter(entry -> !entry.getKey().equals(WordsTuple.EMPTY)) 32 | .mapToInt(entry -> entry.getValue().nextStatesCount()) 33 | .boxed() 34 | .toList(); 35 | 36 | double avg = nextStatesCounts.stream().mapToInt(i -> i).average().orElse(0); 37 | return new Metadata( 38 | avg, 39 | nextStatesCounts.stream().mapToDouble(i -> Math.pow((double)i - avg, 2)).average().orElse(0), 40 | nextStatesCounts.stream().filter(i -> i == 1).count(), 41 | nextStatesCounts.stream().filter(i -> i == 2).count(), 42 | nextStatesCounts.stream().filter(i -> i >= 3).count() 43 | ); 44 | } 45 | 46 | record Metadata( 47 | double nextStatesAverage, 48 | double nextStatesVariance, 49 | long oneNextStateCount, 50 | long twoNextStatesCount, 51 | long threeOrMoreNextStatesCount) { 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovChainsWalker.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import lequentin.cocobot.domain.markov.MarkovPath.Builder; 4 | 5 | import java.util.function.Predicate; 6 | 7 | public interface MarkovChainsWalker { 8 | MarkovPath walkFromUntil(MarkovChains markovChains, T startingPoint, Predicate> walkUntil); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovPath.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import java.util.stream.Stream; 7 | 8 | public class MarkovPath { 9 | 10 | private final Stream path; 11 | private final int length; 12 | private final int nonDeterministicScore; 13 | 14 | private MarkovPath(Builder builder) { 15 | this.path = builder.states.stream() 16 | .map(MarkovState::getValue) ; 17 | this.length = builder.states.size(); 18 | this.nonDeterministicScore = builder.nonDeterministicScore; 19 | } 20 | 21 | public Stream getPath() { 22 | return path; 23 | } 24 | 25 | public int getNonDeterministicScore() { 26 | return nonDeterministicScore; 27 | } 28 | 29 | public int getLength() { 30 | return length; 31 | } 32 | 33 | public static Builder builder() { 34 | return new Builder(); 35 | } 36 | 37 | public static final class Builder { 38 | private List> states; 39 | private int nonDeterministicScore; 40 | private MarkovState lastAddedState; 41 | 42 | public Builder() { 43 | this.states = new ArrayList<>(); 44 | this.nonDeterministicScore = 0; 45 | } 46 | 47 | public Builder nextState(MarkovState state) { 48 | states.add(state); 49 | nonDeterministicScore += Optional.ofNullable(lastAddedState) 50 | .map(lastState -> lastState.nextStatesCount() - 1) 51 | .orElse(0); 52 | lastAddedState = state; 53 | return this; 54 | } 55 | 56 | public int getNonDeterministicScore() { 57 | return nonDeterministicScore; 58 | } 59 | 60 | public MarkovState getLastAddedState() { 61 | return lastAddedState; 62 | } 63 | 64 | public MarkovPath build() { 65 | return new MarkovPath(this); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovState.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import java.util.Comparator; 4 | import java.util.HashMap; 5 | import java.util.List; 6 | import java.util.Map; 7 | import java.util.Map.Entry; 8 | import java.util.Objects; 9 | import java.util.Random; 10 | import java.util.stream.Collectors; 11 | 12 | public class MarkovState { 13 | private final T value; 14 | 15 | private Map, Integer> transitions; 16 | 17 | private int totalCount; 18 | 19 | public MarkovState(T value) { 20 | this.value = value; 21 | this.transitions = new HashMap<>(); 22 | this.totalCount = 0; 23 | } 24 | 25 | public void incrementTransitionTo(MarkovState otherState) { 26 | int count = transitions.getOrDefault(otherState, 0); 27 | transitions.put(otherState, count+1); 28 | totalCount++; 29 | } 30 | 31 | public MarkovState electNext(Random random) { 32 | int randomInt = random.nextInt(totalCount); 33 | int cumul = 0; 34 | List, Integer>> sortedStates = transitions.entrySet().stream() 35 | .sorted(Entry.comparingByValue(Comparator.comparingInt(a -> -a))) 36 | .collect(Collectors.toList()); 37 | for(var entry : sortedStates) { 38 | cumul += entry.getValue(); 39 | if (randomInt < cumul) { 40 | return entry.getKey(); 41 | } 42 | } 43 | throw new RuntimeException("Should not have reached here!"); 44 | } 45 | 46 | public T getValue() { 47 | return value; 48 | } 49 | 50 | public int nextStatesCount() { 51 | return transitions.size(); 52 | } 53 | 54 | @Override 55 | public boolean equals(Object o) { 56 | if (this == o) return true; 57 | if (o == null || getClass() != o.getClass()) return false; 58 | MarkovState that = (MarkovState) o; 59 | return value.equals(that.value); 60 | } 61 | 62 | @Override 63 | public int hashCode() { 64 | return Objects.hash(value); 65 | } 66 | 67 | @Override 68 | public String toString() { 69 | return "MarkovState{" + 70 | value + transitions.entrySet().stream() 71 | .map(entry -> "-"+entry.getValue()+"->"+entry.getKey().getValue()) 72 | .collect(Collectors.joining(", ")) + 73 | '}'; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovTokenizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import lequentin.cocobot.domain.StringTokenizer; 4 | 5 | import java.util.ArrayDeque; 6 | import java.util.List; 7 | import java.util.Queue; 8 | import java.util.stream.Collectors; 9 | import java.util.stream.IntStream; 10 | import java.util.stream.Stream; 11 | 12 | public class MarkovTokenizer { 13 | 14 | private final StringTokenizer wordsStringTokenizer; 15 | private final int tokenWordsCount; 16 | 17 | public MarkovTokenizer(StringTokenizer wordsStringTokenizer, int tokenWordsCount) { 18 | this.wordsStringTokenizer = wordsStringTokenizer; 19 | this.tokenWordsCount = tokenWordsCount; 20 | } 21 | 22 | public Stream tokenize(String str) { 23 | List words = wordsStringTokenizer.tokenize(str) 24 | .collect(Collectors.toList()); 25 | if (words.size() < tokenWordsCount) return Stream.empty(); 26 | 27 | Queue lastWords = new ArrayDeque<>(); 28 | IntStream.range(0, tokenWordsCount).forEach(i -> lastWords.add("")); 29 | Stream tokens = words.stream() 30 | .map(word -> { 31 | lastWords.remove(); 32 | lastWords.add(word); 33 | return tokenFromQueue(lastWords); 34 | }); 35 | // Wrapping tuples with EMPTY values, to represent start/end of sentence 36 | return Stream.concat(Stream.of(WordsTuple.EMPTY), Stream.concat(tokens, Stream.of(WordsTuple.EMPTY))); 37 | } 38 | 39 | private WordsTuple tokenFromQueue(Queue queue) { 40 | return new WordsTuple(queue); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/MarkovWordsGenerator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import lequentin.cocobot.domain.StringTokenizer; 4 | 5 | import java.util.stream.Collectors; 6 | 7 | public class MarkovWordsGenerator { 8 | 9 | private final StringTokenizer sentencesStringTokenizer; 10 | private final MarkovTokenizer markovTokenizer; 11 | private final MarkovChainsWalker walker; 12 | 13 | private final MarkovChains markovChains; 14 | 15 | public MarkovWordsGenerator(StringTokenizer sentencesStringTokenizer, MarkovTokenizer markovTokenizer, MarkovChainsWalker walker) { 16 | this.sentencesStringTokenizer = sentencesStringTokenizer; 17 | this.markovTokenizer = markovTokenizer; 18 | this.walker = walker; 19 | this.markovChains = new MarkovChains<>(); 20 | } 21 | 22 | public void addText(String text) { 23 | sentencesStringTokenizer.tokenize(text) 24 | .map(markovTokenizer::tokenize) 25 | .map(tokens -> tokens.collect(Collectors.toList())) 26 | .forEach(markovTokens -> { 27 | for (int i=0; i path = walker.walkFromUntil( 35 | markovChains, 36 | WordsTuple.EMPTY, 37 | pathBuilder -> pathBuilder.getLastAddedState().getValue() == WordsTuple.EMPTY 38 | ); 39 | return path.getPath() 40 | .filter(wordsTuple -> wordsTuple != WordsTuple.EMPTY) 41 | .map(WordsTuple::lastWord) 42 | .collect(Collectors.joining(" ")) 43 | .replaceAll("<([a-zA-Z0-9-_]*) *: *([a-zA-Z0-9-_]*) *: *([a-zA-Z0-9-_]*) *>", "<$1:$2:$3>") // TODO extract those in post-treatments decorators 44 | .replaceAll(" (,|:|;|/|\\))", "$1") 45 | .replaceAll("(/|\\() ", "$1"); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/SimpleMarkovChainsWalker.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import java.util.Random; 4 | import java.util.function.Predicate; 5 | 6 | public class SimpleMarkovChainsWalker implements MarkovChainsWalker { 7 | 8 | private final Random random; 9 | 10 | public SimpleMarkovChainsWalker(Random random) { 11 | this.random = random; 12 | } 13 | 14 | //TODO add a security net `maxIterations` argument or something similar 15 | public MarkovPath walkFromUntil(MarkovChains markovChains, T startingPoint, Predicate> walkUntil) { 16 | MarkovPath.Builder builder = MarkovPath.builder(); 17 | MarkovState currentState = markovChains.getState(startingPoint); 18 | builder.nextState(currentState); 19 | 20 | do { 21 | currentState = currentState.electNext(random); 22 | builder.nextState(currentState); 23 | } while(!walkUntil.test(builder)); 24 | 25 | return builder.build(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/markov/WordsTuple.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import java.util.List; 4 | import java.util.Objects; 5 | import java.util.stream.Collectors; 6 | import java.util.stream.StreamSupport; 7 | 8 | public class WordsTuple { 9 | public static final WordsTuple EMPTY = new WordsTuple(List.of()); 10 | 11 | private final List words; 12 | 13 | public WordsTuple(String... words) { 14 | this.words = List.of(words); 15 | } 16 | 17 | public WordsTuple(Iterable words) { 18 | this(StreamSupport.stream(words.spliterator(), false).toArray(String[]::new)); 19 | } 20 | 21 | public String join(String separator) { 22 | return String.join(separator, words); 23 | } 24 | 25 | public String lastWord() { 26 | return words.get(words.size()-1); 27 | } 28 | 29 | @Override 30 | public boolean equals(Object o) { 31 | if (this == o) return true; 32 | if (o == null || getClass() != o.getClass()) return false; 33 | WordsTuple that = (WordsTuple) o; 34 | return toHashableString().equals(that.toHashableString()); 35 | } 36 | 37 | @Override 38 | public int hashCode() { 39 | return Objects.hash(toHashableString()); 40 | } 41 | 42 | @Override 43 | public String toString() { 44 | return "("+String.join(";", words)+")"; 45 | } 46 | 47 | private String toHashableString() { 48 | return words.stream() 49 | .map(String::toUpperCase) 50 | .collect(Collectors.joining(";")); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/sanitizer/SpacePunctuationSanitizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.sanitizer; 2 | 3 | import lequentin.cocobot.domain.StringSanitizer; 4 | 5 | public class SpacePunctuationSanitizer implements StringSanitizer { 6 | private final static String PUNCTUATION = "(,|;|:|\\/|\"|\\(|\\))"; 7 | @Override 8 | public String sanitize(String text) { 9 | return text.replaceAll(String.format("%s%s*", PUNCTUATION, PUNCTUATION), " $1 "); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/tokenizer/SanitizerStringTokenizerDecorator.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.tokenizer; 2 | 3 | import lequentin.cocobot.domain.StringSanitizer; 4 | import lequentin.cocobot.domain.StringTokenizer; 5 | 6 | import java.util.stream.Stream; 7 | 8 | public class SanitizerStringTokenizerDecorator implements StringTokenizer { 9 | 10 | private final StringSanitizer sanitizer; 11 | private final StringTokenizer tokenizer; 12 | 13 | public SanitizerStringTokenizerDecorator(StringSanitizer sanitizer, StringTokenizer tokenizer) { 14 | this.sanitizer = sanitizer; 15 | this.tokenizer = tokenizer; 16 | } 17 | 18 | @Override 19 | public Stream tokenize(String str) { 20 | if (str == null) return Stream.of(""); 21 | return tokenizer.tokenize(sanitizer.sanitize(str)); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/tokenizer/SentencesStringTokenizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.tokenizer; 2 | 3 | import lequentin.cocobot.domain.StringSanitizer; 4 | import lequentin.cocobot.domain.StringTokenizer; 5 | 6 | import java.util.Optional; 7 | import java.util.regex.Pattern; 8 | import java.util.stream.Stream; 9 | 10 | public class SentencesStringTokenizer implements StringTokenizer { 11 | 12 | private static final Pattern CONTAINS_FRENCH_WORD_REGEX = Pattern.compile("^.*[a-zàâçéèêëîïôûùüÿñæœ]{2,}.*$", Pattern.CASE_INSENSITIVE); 13 | 14 | private final Optional sanitizer; 15 | 16 | public SentencesStringTokenizer() { 17 | this(null); 18 | } 19 | 20 | public SentencesStringTokenizer(StringSanitizer sanitizer) { 21 | this.sanitizer = Optional.ofNullable(sanitizer); 22 | } 23 | 24 | @Override 25 | public Stream tokenize(String str) { 26 | final String message = Optional.ofNullable(str).orElse(""); 27 | final String sanitized = sanitizer.map(sanitizer -> sanitizer.sanitize(message)).orElse(message); 28 | return Stream.of(sanitized.split(" ?[.?!]+")) 29 | .filter(this::containsAtLeastOneFrenchWord) 30 | .map(String::trim) 31 | .filter(sentence -> !sentence.isEmpty()); 32 | } 33 | 34 | private boolean containsAtLeastOneFrenchWord(String token) { 35 | return CONTAINS_FRENCH_WORD_REGEX.matcher(token).matches(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/domain/tokenizer/WordsStringTokenizer.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.tokenizer; 2 | 3 | import lequentin.cocobot.domain.StringTokenizer; 4 | 5 | import java.util.Optional; 6 | import java.util.stream.Stream; 7 | 8 | public class WordsStringTokenizer implements StringTokenizer { 9 | @Override 10 | public Stream tokenize(String str) { 11 | String notNullStr = Optional.ofNullable(str).orElse(""); 12 | return Stream.of(notNullStr.split(" +")) 13 | .filter(word -> !word.isEmpty()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/storage/JsonFileMessagesRepository.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.storage; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import lequentin.cocobot.domain.Message; 6 | import lequentin.cocobot.domain.MessagesRepository; 7 | import lequentin.cocobot.domain.MessagesSource; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import reactor.core.publisher.Flux; 11 | 12 | import java.io.IOException; 13 | import java.nio.file.Files; 14 | import java.nio.file.Path; 15 | import java.nio.file.StandardOpenOption; 16 | import java.util.Arrays; 17 | import java.util.stream.Collectors; 18 | 19 | public class JsonFileMessagesRepository implements MessagesRepository { 20 | 21 | private static Logger log = LoggerFactory.getLogger(JsonFileMessagesRepository.class); 22 | 23 | private final Path filePath; 24 | private final ObjectMapper objectMapper; 25 | private final UserMessagesJsonConverter converter; 26 | 27 | public JsonFileMessagesRepository(Path filePath, ObjectMapper objectMapper, UserMessagesJsonConverter converter) { 28 | this.filePath = filePath; 29 | this.objectMapper = objectMapper; 30 | this.converter = converter; 31 | } 32 | 33 | @Override 34 | public Flux getAllMessages() { 35 | String content; 36 | try { 37 | content = Files.readString(filePath); 38 | } catch (IOException e) { 39 | throw new RuntimeException(e); 40 | } 41 | 42 | UserMessagesJson[] usersJson; 43 | try { 44 | usersJson = objectMapper.readValue(content, UserMessagesJson[].class); 45 | } catch (JsonProcessingException e) { 46 | throw new RuntimeException(e); 47 | } 48 | 49 | return Flux.create(emitter -> { 50 | Arrays.stream(usersJson).forEach(user -> { 51 | user.getMessages().forEach(messageJson -> emitter.next(converter.toDomainMessage(user, messageJson))); 52 | }); 53 | emitter.complete(); 54 | }); 55 | } 56 | 57 | @Override 58 | public void synchronise(MessagesSource externalSource) { 59 | UserMessagesJson[] usersMessagesJson = externalSource.getAllMessages() 60 | .collectMultimap(Message::getAuthor) 61 | .block() 62 | .entrySet().stream() 63 | .map(entry -> new UserMessagesJson( 64 | entry.getKey().getUsername(), 65 | entry.getValue().stream().map(converter::toJsonMessage).collect(Collectors.toList()))) 66 | .toArray(UserMessagesJson[]::new); 67 | 68 | String content; 69 | try { 70 | content = objectMapper.writeValueAsString(usersMessagesJson); 71 | } catch (JsonProcessingException e) { 72 | throw new RuntimeException(e); 73 | } 74 | 75 | try { 76 | log.info("Writing to file..."); 77 | Files.writeString(filePath, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 78 | } catch (IOException e) { 79 | throw new RuntimeException(e); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/storage/MessageJson.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.storage; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import java.time.Instant; 6 | 7 | public class MessageJson { 8 | private final Instant createdAt; 9 | private final String text; 10 | 11 | public MessageJson( 12 | @JsonProperty("createdAt") Instant createdAt, 13 | @JsonProperty("text") String text 14 | ) { 15 | this.createdAt = createdAt; 16 | this.text = text; 17 | } 18 | 19 | public String getText() { 20 | return text; 21 | } 22 | 23 | public Instant getCreatedAt() { 24 | return createdAt; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/storage/UserMessagesJson.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.storage; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import java.util.List; 6 | 7 | class UserMessagesJson { 8 | private final String username; 9 | private final List messages; 10 | 11 | public UserMessagesJson( 12 | @JsonProperty("username") String username, 13 | @JsonProperty("messages") List messages 14 | ) { 15 | this.username = username; 16 | this.messages = messages; 17 | } 18 | 19 | public String getUsername() { 20 | return username; 21 | } 22 | 23 | public List getMessages() { 24 | return messages; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/lequentin/cocobot/storage/UserMessagesJsonConverter.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.storage; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import lequentin.cocobot.domain.User; 5 | 6 | public class UserMessagesJsonConverter { 7 | public Message toDomainMessage(UserMessagesJson userMessagesJson, MessageJson messageJson) { 8 | return new Message(new User(userMessagesJson.getUsername()), messageJson.getCreatedAt(), messageJson.getText()); 9 | } 10 | 11 | public MessageJson toJsonMessage(Message message) { 12 | return new MessageJson(message.getCreatedAt(), message.getText()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/CocoApplicationMainUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot; 2 | 3 | import lequentin.cocobot.discord.DiscordMessageListener; 4 | import discord4j.core.GatewayDiscordClient; 5 | import discord4j.core.event.domain.message.MessageCreateEvent; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | import reactor.core.publisher.Flux; 12 | import reactor.core.publisher.Mono; 13 | 14 | import static org.mockito.Mockito.*; 15 | 16 | @ExtendWith(MockitoExtension.class) 17 | class CocoApplicationMainUnitTest { 18 | 19 | @Mock 20 | private GatewayDiscordClient gatewayDiscordClient; 21 | 22 | @Mock 23 | private DiscordMessageListener service; 24 | 25 | @InjectMocks 26 | private CocoApplicationMain app; 27 | 28 | @SuppressWarnings("unchecked") 29 | @Test 30 | void shouldRun() { 31 | Mono monoDisconnect = (Mono)mock(Mono.class); 32 | Flux eventFlux = (Flux)mock(Flux.class); 33 | when(gatewayDiscordClient.on(MessageCreateEvent.class)).thenReturn(eventFlux); 34 | when(gatewayDiscordClient.onDisconnect()).thenReturn(monoDisconnect); 35 | 36 | app.run(); 37 | 38 | verify(service).subscribeToMessageCreateFlux(eventFlux); 39 | verify(monoDisconnect).block(); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/BotMessageTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 6 | 7 | class BotMessageTest { 8 | 9 | @Test 10 | void shouldNotCreateWithBlankText() { 11 | assertThatThrownBy(() -> new BotMessage(" ")) 12 | .isExactlyInstanceOf(IllegalArgumentException.class) 13 | .hasMessageContaining("not be blank"); 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/CocoChatBotApplicationUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.InjectMocks; 7 | import org.mockito.Mock; 8 | import org.mockito.junit.jupiter.MockitoExtension; 9 | 10 | import java.io.ByteArrayOutputStream; 11 | import java.util.Optional; 12 | 13 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 14 | import static org.mockito.Mockito.mock; 15 | import static org.mockito.Mockito.verify; 16 | import static org.mockito.Mockito.when; 17 | 18 | @ExtendWith(MockitoExtension.class) 19 | class CocoChatBotApplicationUnitTest { 20 | 21 | private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); 22 | 23 | @Mock 24 | private CocoCommandParser commandParser; 25 | 26 | @InjectMocks 27 | private CocoChatBotApplication coco; 28 | 29 | @Test 30 | void shouldNotHandleMessageWithoutMessage() { 31 | assertThatThrownBy(() -> coco.handleMessage(null)) 32 | .isExactlyInstanceOf(NullPointerException.class); 33 | } 34 | 35 | @Test 36 | void shouldHandleMessage() { 37 | IncomingMessage incomingMessage = mock(IncomingMessage.class); 38 | Message message = mock(Message.class); 39 | when(incomingMessage.toDomain()).thenReturn(message); 40 | Command command = mock(Command.class); 41 | BotMessage reply = mock(BotMessage.class); 42 | when(commandParser.parse(message)).thenReturn(Optional.of(command)); 43 | when(command.execute()).thenReturn(Optional.of(reply)); 44 | 45 | coco.handleMessage(incomingMessage); 46 | 47 | verify(incomingMessage).reply(reply); 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/CocoCommandParserUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.application.commands.ImpersonateCommand; 4 | import lequentin.cocobot.application.commands.RegisterMessageCommand; 5 | import lequentin.cocobot.application.commands.UnknownCommand; 6 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 7 | import lequentin.cocobot.config.Config; 8 | import lequentin.cocobot.domain.Impersonator; 9 | import lequentin.cocobot.domain.Message; 10 | import lequentin.cocobot.domain.User; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.mockito.InjectMocks; 15 | import org.mockito.Mock; 16 | import org.mockito.junit.jupiter.MockitoExtension; 17 | 18 | import java.util.Optional; 19 | 20 | import static org.assertj.core.api.Assertions.*; 21 | import static org.mockito.Mockito.*; 22 | 23 | @ExtendWith(MockitoExtension.class) 24 | class CocoCommandParserUnitTest { 25 | 26 | @Mock 27 | private ApplicationMessageProvider applicationMessageProvider; 28 | 29 | @Mock 30 | private Config config; 31 | 32 | @Mock 33 | private Impersonator impersonator; 34 | 35 | @Mock 36 | private User user; 37 | 38 | @Mock 39 | private Message message; 40 | 41 | @InjectMocks 42 | private CocoCommandParser commandParser; 43 | 44 | @BeforeEach 45 | void setUp() { 46 | when(config.getPrefix()).thenReturn("c/"); 47 | commandParser = new CocoCommandParser(config, impersonator, applicationMessageProvider); 48 | } 49 | 50 | @Test 51 | void shouldParseRegisterMessageCommand() { 52 | when(message.getText()).thenReturn("Just a random message"); 53 | 54 | Optional command = commandParser.parse(message); 55 | 56 | assertThat(command) 57 | .usingRecursiveComparison() 58 | .isEqualTo(Optional.of(new RegisterMessageCommand(impersonator, message))); 59 | } 60 | 61 | @Test 62 | void shouldParseMeCommand() { 63 | when(message.getAuthor()).thenReturn(user); 64 | when(message.getText()).thenReturn("c/me"); 65 | 66 | Optional command = commandParser.parse(message); 67 | 68 | assertThat(command) 69 | .usingRecursiveComparison() 70 | .isEqualTo(Optional.of(new ImpersonateCommand(applicationMessageProvider, impersonator, user))); 71 | } 72 | 73 | @Test 74 | void shouldParseLikeCommand() { 75 | when(message.getText()).thenReturn("c/like nick name"); 76 | 77 | Optional command = commandParser.parse(message); 78 | 79 | assertThat(command) 80 | .usingFieldByFieldValueComparator() 81 | .contains(new ImpersonateCommand(applicationMessageProvider, impersonator, new User("nick name"))); 82 | } 83 | 84 | @Test 85 | void ShouldParseUnknownCommand() { 86 | when(message.getText()).thenReturn("c/unknowncommand"); 87 | 88 | Optional command = commandParser.parse(message); 89 | 90 | assertThat(command) 91 | .usingFieldByFieldValueComparator() 92 | .contains(new UnknownCommand(applicationMessageProvider)); 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/ExcludeChatCommandsMessagesFilterUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.domain.Message; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.ValueSource; 6 | 7 | import static org.assertj.core.api.Assertions.*; 8 | import static org.mockito.Mockito.*; 9 | 10 | class ExcludeChatCommandsMessagesFilterUnitTest { 11 | 12 | private final ExcludeChatCommandsMessagesFilter filter = new ExcludeChatCommandsMessagesFilter(); 13 | 14 | @ValueSource(strings = { 15 | "A perfectly fine message", 16 | "cc/dd", "Yes sure!" 17 | }) 18 | @ParameterizedTest 19 | void shouldFilter(String msg) { 20 | assertThat(filter.accepts(fromString(msg))).isTrue(); 21 | } 22 | 23 | @ValueSource(strings = { 24 | "t/a command", "s/s/s////", "/ whatever command", "///", 25 | "f!a command", "f!/! s", "! whatever command", "!!!" 26 | }) 27 | @ParameterizedTest 28 | void shouldFilterOut(String msg) { 29 | assertThat(filter.accepts(fromString(msg))).isFalse(); 30 | } 31 | 32 | private Message fromString(String str) { 33 | Message msg = mock(Message.class); 34 | when(msg.getText()).thenReturn(str); 35 | return msg; 36 | } 37 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/ImpersonationTestingChatBotApplicationUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application; 2 | 3 | import lequentin.cocobot.JsonMapper; 4 | import lequentin.cocobot.domain.MessagesRepository; 5 | import lequentin.cocobot.storage.JsonFileMessagesRepository; 6 | import lequentin.cocobot.storage.UserMessagesJsonConverter; 7 | import org.junit.jupiter.api.Disabled; 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.nio.file.Path; 11 | 12 | @Disabled("This is used only for exploratory testing on a give messages file") 13 | class ImpersonationTestingChatBotApplicationUnitTest { 14 | 15 | @Test 16 | void shouldSample() { 17 | final UserMessagesJsonConverter jsonConverter = new UserMessagesJsonConverter(); 18 | final MessagesRepository messagesRepository = new JsonFileMessagesRepository( 19 | Path.of("messages.json"), 20 | JsonMapper.get(), 21 | jsonConverter 22 | ); 23 | final ImpersonationTestingChatBotApplication app = new ImpersonationTestingChatBotApplication(messagesRepository); 24 | 25 | app.sample(); 26 | } 27 | 28 | @Test 29 | void shouldCompareChainsMetadata() throws Exception { 30 | final UserMessagesJsonConverter jsonConverter = new UserMessagesJsonConverter(); 31 | final MessagesRepository messagesRepository = new JsonFileMessagesRepository( 32 | Path.of("messages.json"), 33 | JsonMapper.get(), 34 | jsonConverter 35 | ); 36 | final ImpersonationTestingChatBotApplication app = new ImpersonationTestingChatBotApplication(messagesRepository); 37 | 38 | app.printChainsMetadata(); 39 | } 40 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/commands/HelpCommandTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.messages.ApplicationMessageCode; 5 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.util.Optional; 9 | 10 | import static org.assertj.core.api.Assertions.assertThat; 11 | import static org.mockito.Mockito.mock; 12 | import static org.mockito.Mockito.when; 13 | 14 | class HelpCommandTest { 15 | @Test 16 | void shouldExecute() { 17 | ApplicationMessageProvider applicationMessageProvider = mock(ApplicationMessageProvider.class); 18 | when(applicationMessageProvider.getMessage(ApplicationMessageCode.HELP)).thenReturn("help message"); 19 | HelpCommand command = new HelpCommand(applicationMessageProvider); 20 | 21 | Optional result = command.execute(); 22 | 23 | assertThat(result) 24 | .usingRecursiveComparison() 25 | .isEqualTo(Optional.of(new BotMessage("help message"))); 26 | } 27 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/commands/ImpersonateCommandTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 5 | import lequentin.cocobot.domain.Impersonator; 6 | import lequentin.cocobot.domain.User; 7 | import lequentin.cocobot.domain.UserNotFoundException; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | 14 | import java.util.Optional; 15 | 16 | import static lequentin.cocobot.application.messages.ApplicationMessageCode.USER_NOT_FOUND; 17 | import static org.assertj.core.api.Assertions.assertThat; 18 | import static org.mockito.Mockito.when; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | class ImpersonateCommandTest { 22 | 23 | @Mock 24 | private ApplicationMessageProvider applicationMessageProvider; 25 | 26 | @Mock 27 | private Impersonator impersonator; 28 | 29 | @Mock 30 | private User user; 31 | 32 | @InjectMocks 33 | private ImpersonateCommand command; 34 | 35 | @Test 36 | void shouldExecute() { 37 | when(impersonator.impersonate(user)).thenReturn("impersonation"); 38 | 39 | Optional reply = command.execute(); 40 | 41 | assertThat(reply) 42 | .usingRecursiveComparison() 43 | .isEqualTo(Optional.of(new BotMessage("impersonation"))); 44 | } 45 | 46 | @Test 47 | void shouldExecuteWhenUserNotFound() { 48 | when(impersonator.impersonate(user)).thenThrow(new UserNotFoundException("username")); 49 | when(applicationMessageProvider.getMessage(USER_NOT_FOUND, "username")).thenReturn("user not found"); 50 | 51 | Optional reply = command.execute(); 52 | 53 | assertThat(reply) 54 | .usingRecursiveComparison() 55 | .isEqualTo(Optional.of(new BotMessage("user not found"))); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/commands/RegisterMessageCommandTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.domain.Impersonator; 5 | import lequentin.cocobot.domain.Message; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | 9 | import java.io.ByteArrayOutputStream; 10 | import java.io.PrintStream; 11 | import java.util.Optional; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.mockito.Mockito.mock; 15 | import static org.mockito.Mockito.verify; 16 | import static org.mockito.Mockito.when; 17 | 18 | class RegisterMessageCommandTest { 19 | 20 | private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); 21 | 22 | @BeforeEach 23 | public void setUp() { 24 | System.setOut(new PrintStream(outputStreamCaptor)); 25 | } 26 | 27 | @Test 28 | void shouldExecute() { 29 | Message message = mock(Message.class); 30 | Impersonator impersonator = mock(Impersonator.class); 31 | when(message.getText()).thenReturn("Random message"); 32 | RegisterMessageCommand command = new RegisterMessageCommand(impersonator, message); 33 | 34 | Optional reply = command.execute(); 35 | 36 | verify(impersonator).addMessage(message); 37 | assertThat(outputStreamCaptor.toString()).contains("Adding message to model: Random message"); 38 | assertThat(reply).isEmpty(); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/commands/RemoveQuotesAndBlocksStringSanitizerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.RemoveQuotesAndBlocksStringSanitizer; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.Arguments; 6 | import org.junit.jupiter.params.provider.MethodSource; 7 | 8 | import java.util.stream.Stream; 9 | 10 | import static org.assertj.core.api.Assertions.*; 11 | 12 | class RemoveQuotesAndBlocksStringSanitizerUnitTest { 13 | 14 | private final RemoveQuotesAndBlocksStringSanitizer sanitizer = new RemoveQuotesAndBlocksStringSanitizer(); 15 | 16 | private static Stream sanitizeData() { 17 | return Stream.of( 18 | Arguments.of("not a > quote", "not a > quote"), 19 | Arguments.of(" > not a quote", " > not a quote"), 20 | Arguments.of(">single line quote", ""), 21 | Arguments.of(">first quote \n> second quote\n", ""), 22 | Arguments.of(">first quote \n> second quote\noutside of quotes\n>last quote", "outside of quotes"), 23 | Arguments.of("```single line block```", ""), 24 | Arguments.of("```two lines\nblock```", ""), 25 | Arguments.of("before```two lines\n```after", "beforeafter"), 26 | Arguments.of("before\n>quote before\n```single line block```after", "before\nafter"), 27 | Arguments.of("should `preserve` this", "should `preserve` this") 28 | ); 29 | } 30 | 31 | @MethodSource("sanitizeData") 32 | @ParameterizedTest 33 | void shouldSanitize(String text, String expected) { 34 | assertThat(sanitizer.sanitize(text)).isEqualTo(expected); 35 | } 36 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/commands/UnknownCommandTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.commands; 2 | 3 | import lequentin.cocobot.application.BotMessage; 4 | import lequentin.cocobot.application.messages.ApplicationMessageCode; 5 | import lequentin.cocobot.application.messages.ApplicationMessageProvider; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.util.Optional; 9 | 10 | import static org.assertj.core.api.Assertions.assertThat; 11 | import static org.mockito.Mockito.mock; 12 | import static org.mockito.Mockito.when; 13 | 14 | class UnknownCommandTest { 15 | @Test 16 | void shouldExecute() { 17 | ApplicationMessageProvider applicationMessageProvider = mock(ApplicationMessageProvider.class); 18 | when(applicationMessageProvider.getMessage(ApplicationMessageCode.COMMAND_UNKNOWN)).thenReturn("unknown command"); 19 | UnknownCommand command = new UnknownCommand(applicationMessageProvider); 20 | 21 | Optional result = command.execute(); 22 | 23 | assertThat(result) 24 | .usingRecursiveComparison() 25 | .isEqualTo(Optional.of(new BotMessage("unknown command"))); 26 | } 27 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/application/messages/InMemoryApplicationMessageProviderTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.application.messages; 2 | 3 | import lequentin.cocobot.config.Config; 4 | import lequentin.cocobot.config.Language; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.mockito.Mockito.mock; 10 | import static org.mockito.Mockito.when; 11 | 12 | class InMemoryApplicationMessageProviderTest { 13 | 14 | private Config config; 15 | private InMemoryApplicationMessageProvider provider; 16 | 17 | @BeforeEach 18 | void setUp() { 19 | config = mock(Config.class); 20 | when(config.getPrefix()).thenReturn("c/"); 21 | } 22 | 23 | @Test 24 | void shouldGetMessagesInEnglish() { 25 | when(config.getLanguage()).thenReturn(Language.EN); 26 | InMemoryApplicationMessageProvider provider = new InMemoryApplicationMessageProvider(config); 27 | 28 | String message = provider.getMessage(ApplicationMessageCode.USER_NOT_FOUND, "username"); 29 | 30 | assertThat(message).contains("I don't know the user username"); 31 | } 32 | 33 | @Test 34 | void shouldGetMessagesInFrench() { 35 | when(config.getLanguage()).thenReturn(Language.FR); 36 | InMemoryApplicationMessageProvider provider = new InMemoryApplicationMessageProvider(config); 37 | 38 | String message = provider.getMessage(ApplicationMessageCode.USER_NOT_FOUND, "username"); 39 | 40 | assertThat(message).contains("Je ne connais pas l'utilisateur username"); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/config/ConfigTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.config; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.params.ParameterizedTest; 5 | import org.junit.jupiter.params.provider.EnumSource; 6 | 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 9 | import static org.mockito.Mockito.mock; 10 | import static org.mockito.Mockito.when; 11 | 12 | class ConfigTest { 13 | 14 | @Test 15 | void shouldNotReadConfigFromEnvWhenBotTokenNotSet() { 16 | assertThatThrownBy(() -> Config.readFromEnv(propertyName -> "")) 17 | .hasMessageContaining("COCOBOT_TOKEN").hasMessageContaining("not set"); 18 | } 19 | 20 | @Test 21 | void shouldReadConfigFromEnvWithOnlyRequiredVars() { 22 | Config.PropertiesProvider propertiesProvider = mock(Config.PropertiesProvider.class); 23 | when(propertiesProvider.getProperty("COCOBOT_TOKEN")).thenReturn("adummytoken"); 24 | 25 | Config config = Config.readFromEnv(propertiesProvider); 26 | 27 | assertThat(config.getSecrets().getBotToken()).isEqualTo("adummytoken"); 28 | assertThat(config.getLanguage()).isEqualTo(Language.EN); 29 | assertThat(config.getPrefix()).isEqualTo("c/"); 30 | } 31 | 32 | @EnumSource(value = Language.class) 33 | @ParameterizedTest 34 | void shouldReadConfigFromEnvWithAllVars(Language language) { 35 | Config.PropertiesProvider propertiesProvider = mock(Config.PropertiesProvider.class); 36 | when(propertiesProvider.getProperty("COCOBOT_TOKEN")).thenReturn("adummytoken"); 37 | when(propertiesProvider.getProperty("COCOBOT_LANGUAGE")).thenReturn(language.name().toLowerCase()); 38 | when(propertiesProvider.getProperty("COCOBOT_PREFIX")).thenReturn("c!"); 39 | 40 | Config config = Config.readFromEnv(propertiesProvider); 41 | 42 | assertThat(config.getSecrets().getBotToken()).isEqualTo("adummytoken"); 43 | assertThat(config.getLanguage()).isEqualTo(language); 44 | assertThat(config.getPrefix()).isEqualTo("c!"); 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/discord/DiscordConverterTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.object.entity.Message; 4 | import discord4j.core.object.entity.User; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.time.Instant; 8 | import java.util.Optional; 9 | 10 | import static org.assertj.core.api.Assertions.assertThat; 11 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 12 | import static org.mockito.Mockito.mock; 13 | import static org.mockito.Mockito.when; 14 | 15 | class DiscordConverterTest { 16 | 17 | private final DiscordConverter converter = new DiscordConverter(); 18 | 19 | @Test 20 | void shouldNotConvertMessageToDomainWhenMessageHasNoAuthor() { 21 | Message discordMessage = mock(Message.class); 22 | 23 | assertThatThrownBy(() -> converter.toDomain(discordMessage)) 24 | .isExactlyInstanceOf(RuntimeException.class) 25 | .hasMessageContaining("has no user"); 26 | } 27 | 28 | @Test 29 | void shouldConvertMessageToDomain() { 30 | User discordUser = mock(User.class); 31 | when(discordUser.getUsername()).thenReturn("messageAuthor"); 32 | Message discordMessage = mock(Message.class); 33 | when(discordMessage.getAuthor()).thenReturn(Optional.of(discordUser)); 34 | when(discordMessage.getTimestamp()).thenReturn(Instant.MIN); 35 | when(discordMessage.getContent()).thenReturn("message content"); 36 | 37 | lequentin.cocobot.domain.Message result = converter.toDomain(discordMessage); 38 | 39 | assertThat(result) 40 | .usingRecursiveComparison() 41 | .isEqualTo(new lequentin.cocobot.domain.Message( 42 | new lequentin.cocobot.domain.User("messageAuthor"), 43 | Instant.MIN, 44 | "message content" 45 | )); 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/discord/DiscordDirectAccessMessagesSourceUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.common.util.Snowflake; 4 | import discord4j.core.GatewayDiscordClient; 5 | import discord4j.core.object.entity.Guild; 6 | import discord4j.core.object.entity.channel.Channel.Type; 7 | import discord4j.core.object.entity.channel.TextChannel; 8 | import discord4j.rest.util.Permission; 9 | import discord4j.rest.util.PermissionSet; 10 | import lequentin.cocobot.domain.Message; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.junit.jupiter.params.ParameterizedTest; 15 | import org.junit.jupiter.params.provider.EnumSource; 16 | import org.junit.jupiter.params.provider.EnumSource.Mode; 17 | import org.mockito.InjectMocks; 18 | import org.mockito.Mock; 19 | import org.mockito.junit.jupiter.MockitoExtension; 20 | import reactor.core.publisher.Flux; 21 | import reactor.core.publisher.Mono; 22 | import reactor.test.StepVerifier; 23 | 24 | import java.util.Optional; 25 | 26 | import static org.mockito.Mockito.lenient; 27 | import static org.mockito.Mockito.mock; 28 | import static org.mockito.Mockito.verify; 29 | import static org.mockito.Mockito.verifyNoMoreInteractions; 30 | import static org.mockito.Mockito.when; 31 | 32 | @ExtendWith(MockitoExtension.class) 33 | class DiscordDirectAccessMessagesSourceUnitTest { 34 | 35 | public static final Snowflake BOT_ID = mock(Snowflake.class); 36 | 37 | @Mock 38 | private GatewayDiscordClient discord; 39 | 40 | @Mock 41 | private DiscordConverter converter; 42 | 43 | @Mock 44 | private Guild guild; 45 | 46 | @InjectMocks 47 | private DiscordDirectAccessMessagesSource messagesSource; 48 | 49 | @BeforeEach 50 | void setUp() { 51 | when(discord.getGuilds()).thenReturn(Flux.just(guild)); 52 | lenient().when(discord.getSelfId()).thenReturn(BOT_ID); 53 | } 54 | 55 | @Test 56 | void shouldGetAllMessagesFromOneChannel() { 57 | TextChannel channel = mockTextChannel(Type.GUILD_TEXT, Permission.VIEW_CHANNEL, Permission.READ_MESSAGE_HISTORY); 58 | when(guild.getChannels()).thenReturn(Flux.just(channel)); 59 | discord4j.core.object.entity.Message discordMessage1 = mockMessage("content"); 60 | discord4j.core.object.entity.Message discordMessage2 = mockMessage("content"); 61 | discord4j.core.object.entity.Message discordMessage3 = mockMessage(" "); 62 | Snowflake lastMessageId = Snowflake.of(1); 63 | when(channel.getLastMessageId()).thenReturn(Optional.of(lastMessageId)); 64 | when(channel.getMessagesBefore(lastMessageId)).thenReturn(Flux.just(discordMessage1, discordMessage2, discordMessage3)); 65 | Message message1 = mock(Message.class); 66 | Message message2 = mock(Message.class); 67 | when(converter.toDomain(discordMessage1)).thenReturn(message1); 68 | when(converter.toDomain(discordMessage2)).thenReturn(message2); 69 | 70 | Flux messages = messagesSource.getAllMessages(); 71 | 72 | StepVerifier.create(messages) 73 | .expectNext(message1, message2) 74 | .expectComplete() 75 | .verify(); 76 | } 77 | 78 | @ParameterizedTest 79 | @EnumSource(value = Type.class, mode = Mode.EXCLUDE, names = "GUILD_TEXT") 80 | void shouldIgnoreNonTextChannels() { 81 | TextChannel otherChannel = mock(TextChannel.class); 82 | when(otherChannel.getType()).thenReturn(Type.DM); 83 | when(guild.getChannels()).thenReturn(Flux.just(otherChannel)); 84 | 85 | Flux messages = messagesSource.getAllMessages(); 86 | 87 | StepVerifier.create(messages) 88 | .expectComplete() 89 | .verify(); 90 | verifyNoMoreInteractions(otherChannel); 91 | } 92 | 93 | @Test 94 | void shouldIgnoreChannelWithoutEnoughPermissions() { 95 | TextChannel otherChannel = mockTextChannel(Type.GUILD_TEXT, Permission.VIEW_CHANNEL); 96 | when(guild.getChannels()).thenReturn(Flux.just(otherChannel)); 97 | 98 | Flux messages = messagesSource.getAllMessages(); 99 | 100 | StepVerifier.create(messages) 101 | .expectComplete() 102 | .verify(); 103 | verify(otherChannel).getType(); 104 | verify(otherChannel).getEffectivePermissions(BOT_ID); 105 | verifyNoMoreInteractions(otherChannel); 106 | } 107 | 108 | @Test 109 | void shouldIgnoreChannelWhenCantFetchLastMessageId() { 110 | TextChannel channel = mockTextChannel(Type.GUILD_TEXT, Permission.VIEW_CHANNEL, Permission.READ_MESSAGE_HISTORY); 111 | when(guild.getChannels()).thenReturn(Flux.just(channel)); 112 | when(channel.getLastMessageId()).thenReturn(Optional.empty()); 113 | when(channel.getName()).thenReturn("channelName"); 114 | 115 | Flux messages = messagesSource.getAllMessages(); 116 | 117 | StepVerifier.create(messages) 118 | .expectComplete() 119 | .verify(); 120 | verify(channel).getLastMessageId(); 121 | verify(channel).getEffectivePermissions(BOT_ID); 122 | verifyNoMoreInteractions(channel); 123 | } 124 | 125 | private TextChannel mockTextChannel(Type type, Permission... permissions) { 126 | TextChannel channel = mock(TextChannel.class); 127 | when(channel.getType()).thenReturn(type); 128 | lenient().when(channel.getEffectivePermissions(BOT_ID)).thenReturn(Mono.just(PermissionSet.of(permissions))); 129 | return channel; 130 | } 131 | 132 | private discord4j.core.object.entity.Message mockMessage(String content) { 133 | discord4j.core.object.entity.Message message = mock(discord4j.core.object.entity.Message.class); 134 | when(message.getContent()).thenReturn(content); 135 | return message; 136 | } 137 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/discord/DiscordIncomingMessageTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.object.entity.Message; 4 | import discord4j.core.object.entity.channel.MessageChannel; 5 | import discord4j.core.spec.MessageCreateSpec; 6 | import lequentin.cocobot.application.BotMessage; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import reactor.core.publisher.Mono; 10 | import reactor.test.StepVerifier; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | import static org.mockito.ArgumentMatchers.any; 14 | import static org.mockito.Mockito.mock; 15 | import static org.mockito.Mockito.when; 16 | 17 | class DiscordIncomingMessageTest { 18 | 19 | private DiscordConverter discordConverter; 20 | 21 | @BeforeEach 22 | void setUp() { 23 | discordConverter = mock(DiscordConverter.class); 24 | } 25 | 26 | @Test 27 | void shouldConvertToDomain() { 28 | Message discordMessage = mock(Message.class); 29 | lequentin.cocobot.domain.Message convertedMessage = mock(lequentin.cocobot.domain.Message.class); 30 | when(discordConverter.toDomain(discordMessage)).thenReturn(convertedMessage); 31 | DiscordIncomingMessage incomingMessage = new DiscordIncomingMessage(discordMessage, discordConverter); 32 | 33 | lequentin.cocobot.domain.Message result = incomingMessage.toDomain(); 34 | 35 | assertThat(result).isSameAs(convertedMessage); 36 | } 37 | 38 | @Test 39 | void shouldReplyToMessage() { 40 | MessageChannel channel = mock(MessageChannel.class); 41 | Message message = mockMessageInChannel(channel); 42 | Message createdMessage = mock(Message.class); 43 | lequentin.cocobot.domain.Message convertedMessage = mock(lequentin.cocobot.domain.Message.class); 44 | when(channel.createMessage(MessageCreateSpec.builder().content("reply").build())).thenReturn(Mono.just(createdMessage)); 45 | when(discordConverter.toDomain(any())).thenReturn(convertedMessage); 46 | DiscordIncomingMessage incomingMessage = new DiscordIncomingMessage(message, discordConverter); 47 | 48 | StepVerifier.create(incomingMessage.reply(new BotMessage("reply"))) 49 | .expectNext(convertedMessage) 50 | .verifyComplete(); 51 | //TODO find a way to make sure the mono was subscribed here 52 | } 53 | 54 | @Test 55 | void shouldThrowExceptionWhenReplyingThrows() { 56 | MessageChannel channel = mock(MessageChannel.class); 57 | Message message = mockMessageInChannel(channel); 58 | DiscordIncomingMessage incomingMessage = new DiscordIncomingMessage(message, discordConverter); 59 | RuntimeException exception = new RuntimeException("error"); 60 | when(channel.createMessage(any(MessageCreateSpec.class))).thenThrow(exception); 61 | 62 | StepVerifier.create(incomingMessage.reply(new BotMessage("reply"))) 63 | .expectErrorMatches(e -> e == exception) 64 | .verify(); 65 | } 66 | 67 | private Message mockMessageInChannel(MessageChannel channel) { 68 | Message message = mock(Message.class); 69 | when(message.getChannel()).thenReturn(Mono.just(channel)); 70 | return message; 71 | } 72 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/discord/DiscordMessageListenerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.discord; 2 | 3 | import discord4j.core.event.domain.message.MessageCreateEvent; 4 | import lequentin.cocobot.application.CocoChatBotApplication; 5 | import lequentin.cocobot.application.IncomingMessage; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.ArgumentCaptor; 9 | import org.mockito.ArgumentMatcher; 10 | import org.mockito.Captor; 11 | import org.mockito.InjectMocks; 12 | import org.mockito.Mock; 13 | import org.mockito.junit.jupiter.MockitoExtension; 14 | import reactor.core.publisher.Flux; 15 | 16 | import java.lang.reflect.Field; 17 | import java.util.function.Consumer; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | import static org.mockito.ArgumentMatchers.argThat; 21 | import static org.mockito.Mockito.doThrow; 22 | import static org.mockito.Mockito.mock; 23 | import static org.mockito.Mockito.verify; 24 | import static org.mockito.Mockito.when; 25 | 26 | @ExtendWith(MockitoExtension.class) 27 | class DiscordMessageListenerUnitTest { 28 | 29 | @Captor 30 | private ArgumentCaptor incomingMessageArgumentCaptor; 31 | 32 | @Mock 33 | private DiscordConverter converter; 34 | 35 | @Mock 36 | private CocoChatBotApplication coco; 37 | 38 | @InjectMocks 39 | private DiscordMessageListener service; 40 | 41 | @Test 42 | void shouldSubscribeToFluxAndHandleMessages() { 43 | Consumer subscribedConsumer = getSubscribedConsumer(); 44 | MessageCreateEvent testEvent = mock(MessageCreateEvent.class); 45 | discord4j.core.object.entity.Message discordMessage = mock(discord4j.core.object.entity.Message.class); 46 | when(testEvent.getMessage()).thenReturn(discordMessage); 47 | 48 | subscribedConsumer.accept(testEvent); 49 | 50 | verify(coco).handleMessage(incomingMessageArgumentCaptor.capture()); 51 | IncomingMessage handledMessage = incomingMessageArgumentCaptor.getValue(); 52 | assertThat(handledMessage) 53 | .usingRecursiveComparison() 54 | .isEqualTo(new DiscordIncomingMessage(discordMessage, converter)); 55 | } 56 | 57 | @SuppressWarnings("unchecked") 58 | private Consumer getSubscribedConsumer() { 59 | Flux eventFlux = (Flux) mock(Flux.class); 60 | service.subscribeToMessageCreateFlux(eventFlux); 61 | ArgumentCaptor> eventConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); 62 | verify(eventFlux).subscribe(eventConsumerCaptor.capture()); 63 | return eventConsumerCaptor.getValue(); 64 | } 65 | 66 | @Test 67 | void shouldHandleExceptionAndKeepRunning() throws NoSuchFieldException { 68 | MessageCreateEvent event1 = mock(MessageCreateEvent.class); 69 | MessageCreateEvent event2 = mock(MessageCreateEvent.class); 70 | discord4j.core.object.entity.Message discordMessage1 = mock(discord4j.core.object.entity.Message.class); 71 | when(discordMessage1.getContent()).thenReturn("erroneous message"); 72 | discord4j.core.object.entity.Message discordMessage2 = mock(discord4j.core.object.entity.Message.class); 73 | when(event1.getMessage()).thenReturn(discordMessage1); 74 | when(event2.getMessage()).thenReturn(discordMessage2); 75 | RuntimeException thrown = mock(RuntimeException.class); 76 | doThrow(thrown).when(coco).handleMessage(argThat(matchDiscordMessage(discordMessage1))); 77 | Flux fluxWithException = Flux.just(event1, event2); 78 | 79 | service.subscribeToMessageCreateFlux(fluxWithException); 80 | 81 | verify(coco).handleMessage(argThat(matchDiscordMessage(discordMessage1))); 82 | verify(thrown).printStackTrace(System.err); 83 | verify(coco).handleMessage(argThat(matchDiscordMessage(discordMessage2))); 84 | } 85 | 86 | private ArgumentMatcher matchDiscordMessage(discord4j.core.object.entity.Message discordMessage) throws NoSuchFieldException { 87 | Field field = DiscordIncomingMessage.class.getDeclaredField("discordMessage"); 88 | field.setAccessible(true); 89 | return m -> { 90 | try { 91 | return field.get(m) == discordMessage; 92 | } catch (IllegalAccessException e) { 93 | throw new RuntimeException(e); 94 | } 95 | }; 96 | } 97 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/SentencesStringTokenizerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import lequentin.cocobot.domain.tokenizer.SentencesStringTokenizer; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.ValueSource; 8 | 9 | import java.util.stream.Stream; 10 | 11 | import static org.assertj.core.api.Assertions.*; 12 | import static org.mockito.Mockito.*; 13 | 14 | class SentencesStringTokenizerUnitTest { 15 | 16 | private SentencesStringTokenizer tokenizer; 17 | 18 | @BeforeEach 19 | void setUp() { 20 | tokenizer = new SentencesStringTokenizer(); 21 | } 22 | 23 | @Test 24 | void shouldGetEmptyTokensOnNullString() { 25 | assertThat(tokenizer.tokenize(null)).isEmpty(); 26 | } 27 | 28 | @ParameterizedTest 29 | @ValueSource(strings = { 30 | "", 31 | " ", 32 | "-_,;:@£$%^&*()][{}", 33 | "a b c d e f A B C D E F", 34 | }) 35 | void shouldGetEmptyTokensForInvalidSentences(String invalidSentence) { 36 | assertThat(tokenizer.tokenize(invalidSentence)).isEmpty(); 37 | } 38 | 39 | @ParameterizedTest 40 | @ValueSource(strings = { 41 | "HM OK", 42 | "A simple capitalized sentence", 43 | "a simple not capitalized sentence", 44 | "a longer sentence, with a comma inside" 45 | }) 46 | void shouldGetOneTokenForSingleSentenceWithNoEndingPunctuation(String sentence) { 47 | assertThat(tokenizer.tokenize(sentence)).containsExactly(sentence); 48 | } 49 | 50 | @Test 51 | void shouldTokenizeSimpleTwoSentencesMessage() { 52 | assertThat(tokenizer.tokenize("First sentence. Second sentence")).containsExactly("First sentence", "Second sentence"); 53 | } 54 | 55 | @Test 56 | void shouldUseSanitizerWhenSet() { 57 | StringSanitizer sanitizer = mock(StringSanitizer.class); 58 | when(sanitizer.sanitize("text")).thenReturn("sanitized"); 59 | tokenizer = new SentencesStringTokenizer(sanitizer); 60 | 61 | Stream result = tokenizer.tokenize("text"); 62 | 63 | assertThat(result).containsExactly("sanitized"); 64 | } 65 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/SimpleTokensRandomImpersonatorUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import lequentin.cocobot.domain.impersonator.SimpleTokensRandomImpersonator; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.InjectMocks; 7 | import org.mockito.Mock; 8 | import org.mockito.junit.jupiter.MockitoExtension; 9 | import reactor.core.publisher.Flux; 10 | 11 | import java.util.Random; 12 | import java.util.stream.Stream; 13 | 14 | import static org.assertj.core.api.Assertions.*; 15 | import static org.mockito.Mockito.*; 16 | 17 | @ExtendWith(MockitoExtension.class) 18 | class SimpleTokensRandomImpersonatorUnitTest { 19 | 20 | @Mock 21 | private StringTokenizer stringTokenizer; 22 | 23 | @Mock 24 | private Random random; 25 | 26 | @InjectMocks 27 | private SimpleTokensRandomImpersonator impersonator; 28 | 29 | @Mock 30 | private MessagesSource messages; 31 | 32 | @Test 33 | void shouldNotImpersonateNotFoundUser() { 34 | User user = mock(User.class); 35 | when(user.getUsername()).thenReturn("john_doe"); 36 | 37 | assertThatThrownBy(() -> impersonator.impersonate(user)) 38 | .isExactlyInstanceOf(UserNotFoundException.class) 39 | .hasMessageContaining("User john_doe not found"); 40 | verifyNoInteractions(messages); 41 | } 42 | 43 | @Test 44 | void shouldNotImpersonateUserWithNoTokens() { 45 | User user = mock(User.class); 46 | when(user.getUsername()).thenReturn("john_doe"); 47 | Message message = mock(Message.class); 48 | when(message.getAuthor()).thenReturn(user); 49 | when(message.getText()).thenReturn("content"); 50 | when(stringTokenizer.tokenize("content")).thenReturn(Stream.empty()); 51 | when(messages.getAllMessages()).thenReturn(Flux.just(message)); 52 | 53 | impersonator.addAllMessagesFromSource(messages); 54 | assertThatThrownBy(() -> impersonator.impersonate(user)) 55 | .isExactlyInstanceOf(UserNotFoundException.class) 56 | .hasMessageContaining("User john_doe has no messages"); 57 | } 58 | 59 | @Test 60 | void shouldImpersonateUserWithTwoTokensInOneMessage() { 61 | User user = mock(User.class); 62 | Message message = mock(Message.class); 63 | when(message.getAuthor()).thenReturn(user); 64 | when(message.getText()).thenReturn("content"); 65 | when(stringTokenizer.tokenize("content")).thenReturn(Stream.of("0", "1")); 66 | when(messages.getAllMessages()).thenReturn(Flux.just(message)); 67 | when(random.nextInt(2)).thenReturn(1, 0, 0, 1, 1); 68 | 69 | impersonator.addAllMessagesFromSource(messages); 70 | String impersonation = impersonator.impersonate(user); 71 | 72 | assertThat(impersonation).isEqualTo("1. 0. 0. 1. 1"); 73 | } 74 | 75 | @Test 76 | void shouldImpersonateWithThreeTokensInTwoMessages() { 77 | User user = mock(User.class); 78 | Message message1 = mock(Message.class); 79 | Message message2 = mock(Message.class); 80 | when(message1.getAuthor()).thenReturn(user); 81 | when(message1.getText()).thenReturn("content1"); 82 | when(stringTokenizer.tokenize("content1")).thenReturn(Stream.of("0", "1")); 83 | when(message2.getAuthor()).thenReturn(user); 84 | when(message2.getText()).thenReturn("content2"); 85 | when(stringTokenizer.tokenize("content2")).thenReturn(Stream.of("2")); 86 | when(messages.getAllMessages()).thenReturn(Flux.just(message1, message2)); 87 | when(random.nextInt(3)).thenReturn(1, 2, 0, 1, 2); 88 | 89 | impersonator.addAllMessagesFromSource(messages); 90 | String impersonation = impersonator.impersonate(user); 91 | 92 | assertThat(impersonation).isEqualTo("1. 2. 0. 1. 2"); 93 | } 94 | 95 | @Test 96 | void shouldImpersonateAfterAddingMessageToModel() { 97 | User user = mock(User.class); 98 | Message message = mock(Message.class); 99 | when(message.getAuthor()).thenReturn(user); 100 | when(message.getText()).thenReturn("content"); 101 | when(stringTokenizer.tokenize("content")).thenReturn(Stream.of("0", "1")); 102 | when(random.nextInt(2)).thenReturn(1, 0, 0, 1, 1); 103 | 104 | impersonator.addMessage(message); 105 | String impersonation = impersonator.impersonate(user); 106 | 107 | assertThat(impersonation).isEqualTo("1. 0. 0. 1. 1"); 108 | } 109 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/SpacePunctuationSanitizerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import lequentin.cocobot.domain.sanitizer.SpacePunctuationSanitizer; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.params.ParameterizedTest; 6 | import org.junit.jupiter.params.provider.Arguments; 7 | import org.junit.jupiter.params.provider.MethodSource; 8 | 9 | import java.util.stream.Stream; 10 | 11 | import static org.assertj.core.api.Assertions.*; 12 | 13 | class SpacePunctuationSanitizerUnitTest { 14 | 15 | private SpacePunctuationSanitizer sanitizer; 16 | 17 | @BeforeEach 18 | void setUp() { 19 | sanitizer = new SpacePunctuationSanitizer(); 20 | } 21 | 22 | 23 | @ParameterizedTest 24 | @MethodSource("simplePunctuationArguments") 25 | void shouldSpaceSimplePunctuationOnlyForRelevantChars(String input, String output) { 26 | assertThat(sanitizer.sanitize(input)).isEqualTo(output); 27 | } 28 | 29 | private static Stream simplePunctuationArguments() { 30 | return Stream.of( 31 | Arguments.of(",", " , "), 32 | Arguments.of(";", " ; "), 33 | Arguments.of(":", " : "), 34 | Arguments.of("/", " / "), 35 | Arguments.of("\"", " \" "), 36 | Arguments.of("(", " ( "), 37 | Arguments.of(")", " ) "), 38 | Arguments.of(",,,", " , "), 39 | Arguments.of(";:,/", " ; "), 40 | Arguments.of("mot,autremot/,encoreautre", "mot , autremot / encoreautre") 41 | ); 42 | } 43 | 44 | @ParameterizedTest 45 | @MethodSource("multiplePunctuationsArguments") 46 | void shouldKeepOnlyFirstPunctuationInPunctuationsBlocks(String input, String output) { 47 | assertThat(sanitizer.sanitize(input)).isEqualTo(output); 48 | } 49 | 50 | private static Stream multiplePunctuationsArguments() { 51 | return Stream.of( 52 | Arguments.of(",,,", " , "), 53 | Arguments.of(",;/", " , "), 54 | Arguments.of(":/(", " : "), 55 | Arguments.of("()", " ( "), 56 | Arguments.of("( )", " ( ) ") 57 | ); 58 | } 59 | 60 | @ParameterizedTest 61 | @MethodSource("completeSentencesArguments") 62 | void shouldSanitizeExampleSentences(String input, String output) { 63 | assertThat(sanitizer.sanitize(input)).isEqualTo(output); 64 | } 65 | 66 | private static Stream completeSentencesArguments() { 67 | return Stream.of( 68 | Arguments.of("mot,autremot/,encoreautre", "mot , autremot / encoreautre"), 69 | Arguments.of("Une phrase, écrite normalement (porte-feuille) c'est \"cool\"", 70 | "Une phrase , écrite normalement ( porte-feuille ) c'est \" cool \" " 71 | ) 72 | ); 73 | } 74 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/WordsStringTokenizerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain; 2 | 3 | import lequentin.cocobot.domain.tokenizer.WordsStringTokenizer; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.Arguments; 8 | import org.junit.jupiter.params.provider.MethodSource; 9 | import org.junit.jupiter.params.provider.ValueSource; 10 | 11 | import java.util.List; 12 | import java.util.stream.Stream; 13 | 14 | import static org.assertj.core.api.Assertions.*; 15 | 16 | class WordsStringTokenizerUnitTest { 17 | 18 | private WordsStringTokenizer tokenizer; 19 | 20 | @BeforeEach 21 | void setUp() { 22 | tokenizer = new WordsStringTokenizer(); 23 | } 24 | 25 | @Test 26 | void shouldGetEmptyTokensOnNullString() { 27 | assertThat(tokenizer.tokenize(null)).isEmpty(); 28 | } 29 | 30 | @ParameterizedTest 31 | @ValueSource(strings = { 32 | "", 33 | " ", 34 | }) 35 | void shouldGetEmptyTokensForInvalidSentences(String invalidSentence) { 36 | assertThat(tokenizer.tokenize(invalidSentence)).isEmpty(); 37 | } 38 | 39 | @ParameterizedTest 40 | @ValueSource(strings = { 41 | "HM", 42 | "word", 43 | "anoThErWORd", 44 | " andAnotherOne, " 45 | }) 46 | void shouldGetOneTokenForSingleWord(String sentence) { 47 | assertThat(tokenizer.tokenize(sentence)).containsExactly(sentence.trim()); 48 | } 49 | 50 | @ParameterizedTest 51 | @MethodSource("arguments") 52 | void shouldTokenize(String input, Iterable output) { 53 | assertThat(tokenizer.tokenize(input)).containsExactlyElementsOf(output); 54 | } 55 | 56 | private static Stream arguments() { 57 | return Stream.of( 58 | Arguments.of("two words", List.of("two", "words")), 59 | Arguments.of("two, words", List.of("two,", "words")), 60 | Arguments.of("we,ir-d words", List.of("we,ir-d", "words")), 61 | Arguments.of("and a: whole, lot; of: words", List.of("and", "a:", "whole,", "lot;", "of:", "words")), 62 | Arguments.of("words , with : punctuation ; in-between them", List.of("words", ",", "with", ":", "punctuation", ";", "in-between", "them")) 63 | ); 64 | } 65 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/markov/MarkovStateUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.ValueSource; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | 11 | import java.util.Random; 12 | import java.util.stream.IntStream; 13 | 14 | import static org.assertj.core.api.Assertions.*; 15 | import static org.mockito.Mockito.*; 16 | 17 | @ExtendWith(MockitoExtension.class) 18 | class MarkovStateUnitTest { 19 | 20 | private MarkovState from; 21 | private MarkovState to1; 22 | private MarkovState to2; 23 | private MarkovState to3; 24 | 25 | @Mock 26 | private Random random; 27 | 28 | @BeforeEach 29 | void setUpStates() { 30 | from = new MarkovState<>("from"); 31 | to1 = new MarkovState<>("to1"); 32 | to2 = new MarkovState<>("to2"); 33 | to3 = new MarkovState<>("to3"); 34 | increment(from, to1, 3); 35 | increment(from, to2, 5); 36 | increment(from, to3, 2); 37 | } 38 | 39 | @DisplayName("Should elect to2 as next state when random yields an int between 0 and 4") 40 | @ParameterizedTest 41 | @ValueSource(ints = { 0, 1, 2, 3, 4 }) 42 | void shouldElectNextReturnTo2WhenRandomBetween0And4(int randomIntResult) { 43 | when(random.nextInt(10)).thenReturn(randomIntResult); 44 | 45 | MarkovState next = from.electNext(random); 46 | 47 | assertThat(next).isSameAs(to2); 48 | } 49 | 50 | @DisplayName("Should elect to1 as next state when random yields an int between 5 and 7") 51 | @ValueSource(ints = { 5, 6, 7 }) 52 | @ParameterizedTest 53 | void shouldElectNextReturnTo1WhenRandomBetween5And7(int randomIntResult) { 54 | when(random.nextInt(10)).thenReturn(randomIntResult); 55 | 56 | MarkovState next = from.electNext(random); 57 | 58 | assertThat(next).isSameAs(to1); 59 | } 60 | 61 | @DisplayName("Should elect to3 as next state when random yields an int between 8 and 9") 62 | @ValueSource(ints = { 8, 9 }) 63 | @ParameterizedTest 64 | void shouldElectNextReturnTo3WhenRandomBetween8And9(int randomIntResult) { 65 | when(random.nextInt(10)).thenReturn(randomIntResult); 66 | 67 | MarkovState next = from.electNext(random); 68 | 69 | assertThat(next).isSameAs(to3); 70 | } 71 | 72 | private void increment(MarkovState from, MarkovState to, int n) { 73 | IntStream.range(0, n).forEach(i -> from.incrementTransitionTo(to)); 74 | } 75 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/domain/markov/MarkovTokenizerUnitTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.domain.markov; 2 | 3 | import lequentin.cocobot.domain.StringTokenizer; 4 | import org.assertj.core.api.Assertions; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.mockito.Mock; 8 | import org.mockito.junit.jupiter.MockitoExtension; 9 | 10 | import java.util.stream.Stream; 11 | 12 | import static org.mockito.Mockito.*; 13 | 14 | @ExtendWith(MockitoExtension.class) 15 | class MarkovTokenizerUnitTest { 16 | 17 | @Mock 18 | private StringTokenizer wordsStringTokenizer; 19 | 20 | @Test 21 | void shouldTokenizeIntoBigrams() { 22 | when(wordsStringTokenizer.tokenize("str")).thenReturn(Stream.of("Just", "a", "random", "sentence")); 23 | MarkovTokenizer tokenizer = new MarkovTokenizer(wordsStringTokenizer, 2); 24 | 25 | Stream tokens = tokenizer.tokenize("str"); 26 | 27 | Assertions.assertThat(tokens).containsExactly( 28 | WordsTuple.EMPTY, 29 | new WordsTuple("","Just"), 30 | new WordsTuple("Just","a"), 31 | new WordsTuple("a","random"), 32 | new WordsTuple("random","sentence"), 33 | WordsTuple.EMPTY 34 | ); 35 | } 36 | 37 | @Test 38 | void shouldTokenizeIntoTrigrams() { 39 | when(wordsStringTokenizer.tokenize("str")).thenReturn(Stream.of("Just", "a", "random", "sentence")); 40 | MarkovTokenizer tokenizer = new MarkovTokenizer(wordsStringTokenizer, 3); 41 | 42 | Stream tokens = tokenizer.tokenize("str"); 43 | 44 | Assertions.assertThat(tokens).containsExactly( 45 | WordsTuple.EMPTY, 46 | new WordsTuple("","","Just"), 47 | new WordsTuple("","Just","a"), 48 | new WordsTuple("Just","a","random"), 49 | new WordsTuple("a","random","sentence"), 50 | WordsTuple.EMPTY 51 | ); 52 | } 53 | } -------------------------------------------------------------------------------- /src/test/java/lequentin/cocobot/storage/JsonFileMessagesRepositoryIntTest.java: -------------------------------------------------------------------------------- 1 | package lequentin.cocobot.storage; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import lequentin.cocobot.JsonMapper; 5 | import lequentin.cocobot.domain.Message; 6 | import lequentin.cocobot.domain.User; 7 | import org.junit.jupiter.api.Disabled; 8 | import org.junit.jupiter.api.Test; 9 | import reactor.core.publisher.Flux; 10 | import reactor.test.StepVerifier; 11 | 12 | import java.io.File; 13 | import java.io.IOException; 14 | import java.nio.file.Files; 15 | import java.nio.file.Path; 16 | import java.time.Instant; 17 | 18 | import static org.assertj.core.api.Assertions.*; 19 | 20 | class JsonFileMessagesRepositoryIntTest { 21 | 22 | private final Instant CREATED_AT_1 = Instant.parse("2022-01-04T19:30:42Z"); 23 | private final Instant CREATED_AT_2 = Instant.parse("2022-02-01T02:01:11Z"); 24 | 25 | private final ObjectMapper objectMapper = JsonMapper.get(); 26 | 27 | @Test 28 | void shouldGetAllMessages() { 29 | JsonFileMessagesRepository repository = new JsonFileMessagesRepository( 30 | getResourcePath("storage.json"), 31 | objectMapper, 32 | new UserMessagesJsonConverter() 33 | ); 34 | Flux allMessages = repository.getAllMessages(); 35 | 36 | User author1 = new User("toto"); 37 | User author2 = new User("tata"); 38 | StepVerifier.create(allMessages) 39 | .assertNext(msg -> assertMessage(msg, author1, CREATED_AT_1, "Hi guys")) 40 | .assertNext(msg -> assertMessage(msg, author1, CREATED_AT_2, "How are you?")) 41 | .assertNext(msg -> assertMessage(msg, author2, CREATED_AT_1, "Hi girls")) 42 | .assertNext(msg -> assertMessage(msg, author2, CREATED_AT_2, "How are you not?")) 43 | .verifyComplete(); 44 | } 45 | 46 | @Test 47 | void shouldSynchronise() throws IOException { 48 | File tempFile = File.createTempFile("int-test-output_jsonFileRepo_synchronise", "json"); 49 | Path filePath = Path.of(tempFile.getAbsolutePath()); 50 | JsonFileMessagesRepository repository = new JsonFileMessagesRepository( 51 | filePath, 52 | objectMapper, 53 | new UserMessagesJsonConverter() 54 | ); 55 | User author1 = new User("titi"); 56 | User author2 = new User("tutu"); 57 | Flux source = Flux.just( 58 | new Message(author1, CREATED_AT_1, "Hello there"), 59 | new Message(author1, CREATED_AT_2, "How are you?"), 60 | new Message(author2, CREATED_AT_2,"I'm doing fine") 61 | ); 62 | 63 | repository.synchronise(() -> source); 64 | 65 | String content = Files.readString(filePath); 66 | String user1Json = "{" + 67 | "\"username\":\"titi\"," + 68 | "\"messages\":[" + 69 | "{\"createdAt\":\"2022-01-04T19:30:42Z\",\"text\":\"Hello there\"}," + 70 | "{\"createdAt\":\"2022-02-01T02:01:11Z\",\"text\":\"How are you?\"}" + 71 | "]" + 72 | "}"; 73 | String user2Json = "{" + 74 | "\"username\":\"tutu\"," + 75 | "\"messages\":[" + 76 | "{\"createdAt\":\"2022-02-01T02:01:11Z\",\"text\":\"I'm doing fine\"}" + 77 | "]" + 78 | "}"; 79 | assertThat(content).isIn( 80 | "["+user1Json+","+user2Json+"]", 81 | "["+user2Json+","+user1Json+"]" 82 | ); 83 | } 84 | 85 | private void assertMessage(Message message, User author, Instant createdAt, String text) { 86 | assertThat(message).usingRecursiveComparison().isEqualTo(new Message(author, createdAt, text)); 87 | } 88 | 89 | private Path getResourcePath(String relativePath) { 90 | ClassLoader classLoader = getClass().getClassLoader(); 91 | return Path.of(classLoader.getResource(relativePath).getPath()); 92 | } 93 | 94 | private Path getResourceFolderPath() { 95 | return getResourcePath("storage.json").getParent(); 96 | } 97 | } -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /src/test/resources/storage.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "toto", 4 | "messages": [ 5 | { "text": "Hi guys", "createdAt": "2022-01-04T19:30:42Z" }, 6 | { "text": "How are you?", "createdAt": "2022-02-01T02:01:11Z" } 7 | ] 8 | }, 9 | { 10 | "username": "tata", 11 | "messages": [ 12 | { "text": "Hi girls", "createdAt": "2022-01-04T19:30:42Z" }, 13 | { "text": "How are you not?", "createdAt": "2022-02-01T02:01:11Z" } 14 | ] 15 | } 16 | ] --------------------------------------------------------------------------------