├── .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 | 
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 | ]
--------------------------------------------------------------------------------