├── .github └── workflows │ └── build.yml ├── .gitignore ├── README.md ├── docs ├── d3.min.js ├── img │ ├── notification_disconnected.png │ └── notification_reconnected.png ├── index.html ├── processing.min.js ├── reconnecting-websocket.min.js ├── send-vs-receive.pde └── swagger │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── index.html │ ├── oauth2-redirect.html │ ├── swagger-ui-bundle.js │ ├── swagger-ui-bundle.js.map │ ├── swagger-ui-standalone-preset.js │ ├── swagger-ui-standalone-preset.js.map │ ├── swagger-ui.css │ ├── swagger-ui.css.map │ ├── swagger-ui.js │ ├── swagger-ui.js.map │ └── swagger.yml ├── justfile ├── lombok.config ├── pom.xml ├── tillerinobot-irc ├── .dockerignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── irc-checksum.sh ├── pom.xml └── src │ ├── main │ ├── resources │ │ └── log4j2.xml │ └── rust │ │ ├── ircc.rs │ │ ├── main.rs │ │ ├── probes.rs │ │ └── rabbit.rs │ └── test │ ├── java │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ └── chat │ │ └── irc │ │ ├── BotIT.java │ │ ├── IrcContainer.java │ │ ├── KittehForNgircd.java │ │ └── NgircdContainer.java │ └── resources │ ├── irc │ ├── ngircd.conf │ └── ngircd.motd │ └── log4j2.xml ├── tillerinobot-live ├── .dockerignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── README.md ├── live-checksum.sh ├── pom.xml └── src │ ├── main │ ├── resources │ │ └── log4j2.xml │ └── rust │ │ ├── main.rs │ │ ├── rabbit.rs │ │ └── websocket.rs │ └── test │ ├── java │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ └── live │ │ ├── AbstractLiveActivityEndpointTest.java │ │ ├── LiveActivityIT.java │ │ ├── LiveContainer.java │ │ └── MemoryIT.java │ └── resources │ └── log4j2.xml ├── tillerinobot-model ├── pom.xml └── src │ ├── main │ └── java │ │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ ├── chat │ │ ├── GameChatClient.java │ │ ├── GameChatClientMetrics.java │ │ ├── GameChatEvent.java │ │ ├── GameChatEventConsumer.java │ │ ├── GameChatEventMeta.java │ │ ├── GameChatEventQueue.java │ │ ├── GameChatResponse.java │ │ ├── GameChatResponseConsumer.java │ │ ├── GameChatResponseQueue.java │ │ ├── GameChatWriter.java │ │ ├── IRCName.java │ │ ├── Joined.java │ │ ├── LiveActivity.java │ │ ├── PrivateAction.java │ │ ├── PrivateMessage.java │ │ └── Sighted.java │ │ ├── config │ │ └── ConfigService.java │ │ ├── rabbit │ │ └── Rpc.java │ │ └── util │ │ ├── Clock.java │ │ ├── LoggingUtils.java │ │ ├── MaintenanceException.java │ │ ├── MdcUtils.java │ │ ├── PhaseTimer.java │ │ ├── QuietCloseable.java │ │ ├── Result.java │ │ └── RetryableException.java │ └── test │ ├── java │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ ├── chat │ │ ├── GameChatEventTest.java │ │ ├── GameChatResponseTest.java │ │ ├── JoinedTest.java │ │ ├── PrivateActionTest.java │ │ ├── PrivateMessageTest.java │ │ └── SightedTest.java │ │ └── util │ │ ├── Bind.java │ │ ├── CustomTestContainer.java │ │ ├── DockerNetwork.java │ │ ├── ExecutorServiceRule.java │ │ ├── InjectionRunner.java │ │ ├── InjectionRunnerTest.java │ │ ├── LoggingUtilsTest.java │ │ ├── MdcUtilsTest.java │ │ ├── PhaseTimerTest.java │ │ ├── ResettableModule.java │ │ ├── ResultTest.java │ │ ├── ReusableContainerInitializer.java │ │ ├── TestAppender.java │ │ ├── TestClock.java │ │ └── TestModule.java │ └── resources │ └── log4j2.xml ├── tillerinobot-rabbit ├── pom.xml └── src │ ├── main │ └── java │ │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ └── rabbit │ │ ├── AbstractRemoteQueue.java │ │ ├── RabbitMqConfiguration.java │ │ ├── RabbitRpc.java │ │ ├── RemoteEventQueue.java │ │ ├── RemoteLiveActivity.java │ │ └── RemoteResponseQueue.java │ └── test │ ├── java │ └── org │ │ └── tillerino │ │ └── ppaddict │ │ └── rabbit │ │ ├── RabbitMqContainer.java │ │ ├── RabbitMqContainerConnection.java │ │ ├── RabbitRpcTest.java │ │ ├── RemoteEventQueueTest.java │ │ └── RemoteResponseQueueTest.java │ └── resources │ └── log4j2.xml ├── tillerinobot-tests ├── pom.xml └── src │ └── test │ ├── java │ ├── org │ │ └── tillerino │ │ │ └── ppaddict │ │ │ ├── FullBotTest.java │ │ │ └── MessageQueueTest.java │ └── tillerino │ │ └── tillerinobot │ │ ├── LocalConsoleTillerinobot.java │ │ └── LoggingTest.java │ └── resources │ └── log4j2.xml └── tillerinobot ├── pom.xml └── src ├── main ├── java │ ├── org │ │ └── tillerino │ │ │ ├── mormon │ │ │ ├── Column.java │ │ │ ├── Database.java │ │ │ ├── DatabaseManager.java │ │ │ ├── KeyColumn.java │ │ │ ├── Loader.java │ │ │ ├── Mapping.java │ │ │ ├── Persister.java │ │ │ ├── ResultSetIterator.java │ │ │ ├── Table.java │ │ │ └── package-info.java │ │ │ └── ppaddict │ │ │ ├── chat │ │ │ ├── impl │ │ │ │ ├── Bouncer.java │ │ │ │ ├── MessageHandlerScheduler.java │ │ │ │ ├── MessagePreprocessor.java │ │ │ │ ├── ProcessorsModule.java │ │ │ │ ├── RabbitQueuesModule.java │ │ │ │ └── ResponsePostprocessor.java │ │ │ └── local │ │ │ │ └── LocalGameChatMetrics.java │ │ │ ├── config │ │ │ ├── CachedDatabaseConfigServiceModule.java │ │ │ ├── CachingConfigService.java │ │ │ └── DatabaseConfigService.java │ │ │ ├── rest │ │ │ ├── AuthenticationService.java │ │ │ └── AuthenticationServiceImpl.java │ │ │ ├── util │ │ │ └── LoopingRunnable.java │ │ │ └── web │ │ │ ├── data │ │ │ ├── HasLinkedOsuId.java │ │ │ ├── PpaddictLinkKey.java │ │ │ └── PpaddictUser.java │ │ │ └── types │ │ │ └── PpaddictId.java │ └── tillerino │ │ └── tillerinobot │ │ ├── BeatmapMeta.java │ │ ├── BotBackend.java │ │ ├── CommandHandler.java │ │ ├── IRCBot.java │ │ ├── IrcNameResolver.java │ │ ├── RateLimiter.java │ │ ├── RateLimitingOsuApiDownloader.java │ │ ├── UserDataManager.java │ │ ├── UserException.java │ │ ├── data │ │ ├── ActualBeatmap.java │ │ ├── ApiBeatmap.java │ │ ├── BotConfig.java │ │ ├── BotUserData.java │ │ ├── DiffEstimate.java │ │ ├── GivenRecommendation.java │ │ └── UserNameMapping.java │ │ ├── diff │ │ ├── .gitignore │ │ ├── AccuracyDistribution.java │ │ ├── Beatmap.java │ │ ├── BeatmapImpl.java │ │ ├── DiffEstimateProvider.java │ │ ├── MathHelper.java │ │ ├── OsuScore.java │ │ ├── PercentageEstimates.java │ │ ├── PercentageEstimatesImpl.java │ │ └── sandoku │ │ │ ├── SanDoku.java │ │ │ ├── SanDokuError.java │ │ │ └── SanDokuResponse.java │ │ ├── handlers │ │ ├── AccHandler.java │ │ ├── ComplaintHandler.java │ │ ├── DebugHandler.java │ │ ├── FixIDHandler.java │ │ ├── HelpHandler.java │ │ ├── LinkPpaddictHandler.java │ │ ├── NPHandler.java │ │ ├── OptionsHandler.java │ │ ├── OsuTrackHandler.java │ │ ├── RecentHandler.java │ │ ├── RecommendHandler.java │ │ ├── ResetHandler.java │ │ ├── WithHandler.java │ │ └── options │ │ │ ├── BooleanOptionHandler.java │ │ │ ├── DefaultOptionHandler.java │ │ │ ├── LangOptionHandler.java │ │ │ ├── MapMetaDataOptionHandler.java │ │ │ ├── OptionHandler.java │ │ │ ├── OsutrackWelcomeOptionHandler.java │ │ │ └── WelcomeOptionHandler.java │ │ ├── lang │ │ ├── AbstractMutableLanguage.java │ │ ├── Bulgarian.java │ │ ├── Catalan.java │ │ ├── ChineseSimple.java │ │ ├── ChineseTraditional.java │ │ ├── Czech.java │ │ ├── Dansk.java │ │ ├── Default.java │ │ ├── Deutsch.java │ │ ├── Farsi.java │ │ ├── Francais.java │ │ ├── Greek.java │ │ ├── Hebrew.java │ │ ├── Hungarian.java │ │ ├── Indonesian.java │ │ ├── Italiano.java │ │ ├── Japanese.java │ │ ├── Korean.java │ │ ├── Language.java │ │ ├── LanguageIdentifier.java │ │ ├── Lithuanian.java │ │ ├── Nederlands.java │ │ ├── Norwegian.java │ │ ├── Polish.java │ │ ├── Portuguese.java │ │ ├── PortuguesePortugal.java │ │ ├── Romana.java │ │ ├── Russian.java │ │ ├── Slovak.java │ │ ├── Slovenian.java │ │ ├── Spanish.java │ │ ├── StringShuffler.java │ │ ├── Svenska.java │ │ ├── Swissgerman.java │ │ ├── TsundereBase.java │ │ ├── TsundereEnglish.java │ │ ├── TsundereGerman.java │ │ ├── Turkish.java │ │ └── Vietnamese.java │ │ ├── osutrack │ │ ├── Highscore.java │ │ ├── OsutrackApi.java │ │ ├── OsutrackDownloader.java │ │ └── UpdateResult.java │ │ ├── predicates │ │ ├── ApproachRate.java │ │ ├── BeatsPerMinute.java │ │ ├── CircleSize.java │ │ ├── ExcludeMod.java │ │ ├── MapLength.java │ │ ├── NumericBeatmapProperty.java │ │ ├── NumericPredicateBuilder.java │ │ ├── NumericPropertyPredicate.java │ │ ├── OverallDifficulty.java │ │ ├── PredicateParser.java │ │ ├── RecommendationPredicate.java │ │ └── StarDiff.java │ │ ├── recommendations │ │ ├── AllRecommenders.java │ │ ├── BareRecommendation.java │ │ ├── Model.java │ │ ├── NamePendingApprovalRecommender.java │ │ ├── Recommendation.java │ │ ├── RecommendationRequest.java │ │ ├── RecommendationRequestParser.java │ │ ├── RecommendationsManager.java │ │ ├── Recommender.java │ │ ├── Sampler.java │ │ └── TopPlay.java │ │ ├── rest │ │ ├── AbstractBeatmapResource.java │ │ ├── ApiLoggingFeature.java │ │ ├── AuthenticationFilter.java │ │ ├── BeatmapDifficulties.java │ │ ├── BeatmapInfoService.java │ │ ├── BeatmapResource.java │ │ ├── BeatmapsService.java │ │ ├── BeatmapsServiceImpl.java │ │ ├── BotApiDefinition.java │ │ ├── BotInfoService.java │ │ ├── BotStatus.java │ │ ├── DelegatingBeatmapsService.java │ │ ├── KeyRequired.java │ │ ├── PrintMessageExceptionMapper.java │ │ ├── RestUtils.java │ │ └── UserByIdService.java │ │ └── util │ │ └── IsMutable.java └── resources │ └── .gitignore └── test ├── java ├── org │ └── tillerino │ │ ├── MockServerRule.java │ │ ├── mormon │ │ ├── DatabaseTest.java │ │ ├── LoaderTest.java │ │ ├── LoaderTestManual.java │ │ └── PersisterTest.java │ │ └── ppaddict │ │ ├── chat │ │ ├── impl │ │ │ ├── BouncerTest.java │ │ │ ├── MessagePreprocessorTest.java │ │ │ └── ResponsePostprocessorTest.java │ │ └── local │ │ │ ├── InMemoryQueuesModule.java │ │ │ ├── LocalGameChatEventQueue.java │ │ │ ├── LocalGameChatEventQueueTest.java │ │ │ ├── LocalGameChatResponseQueue.java │ │ │ └── LocalGameChatResponseQueueTest.java │ │ ├── config │ │ └── DatabaseConfigServiceTest.java │ │ └── rest │ │ └── AuthenticationServiceImplIT.java └── tillerino │ └── tillerinobot │ ├── AbstractDatabaseTest.java │ ├── BeatmapMetaTest.java │ ├── CommandHandlerTest.java │ ├── FakeAuthenticationService.java │ ├── IRCBotTest.java │ ├── IrcNameResolverTest.java │ ├── MysqlContainer.java │ ├── RateLimiterTest.java │ ├── TestBackend.java │ ├── UserDataManagerTest.java │ ├── data │ └── ApiBeatmapTest.java │ ├── diff │ ├── AccuracyDistributionTest.java │ ├── DiffEstimateProviderTest.java │ └── sandoku │ │ └── SanDokuTestManual.java │ ├── handlers │ ├── AccHandlerTest.java │ ├── DebugHandlerTest.java │ ├── LinkPpaddictHandlerTest.java │ ├── NPHandlerTest.java │ ├── OptionsHandlerTest.java │ └── RecommendHandlerTest.java │ ├── lang │ ├── AllLanguagesTest.java │ ├── HebrewTest.java │ ├── StringShufflerTest.java │ └── TsundereTest.java │ ├── osutrack │ └── TestOsutrackDownloader.java │ ├── predicates │ ├── NumericPredicateBuilderTest.java │ ├── NumericPropertyPredicateTest.java │ ├── PredicateParserTest.java │ └── TitleLength.java │ ├── recommendations │ ├── NamePendingApprovalRecommenderTest.java │ ├── RecommendationRequestParserTest.java │ └── RecommendationsManagerTest.java │ ├── rest │ ├── AbstractBeatmapResourceTest.java │ ├── ApiTest.java │ ├── BeatmapInfoServiceIT.java │ ├── BotApiRule.java │ └── JdkServerResource.java │ └── testutil │ └── SynchronousExecutorServiceRule.java └── resources ├── Fujijo Seitokai Shikkou-bu - Best FriendS -TV Size- (Flask) [Fycho's Insane].osu ├── MOSAIC.WAV - Magical Pants (Short Ver.) (Imaginative) [look at bg].osu ├── beatmapIds.txt ├── log4j2.xml ├── osutrack ├── 2345.json ├── 2756335.json └── 56917.json ├── osuv1api └── api │ └── get_beatmaps%3Fb%3D53%26mods%3D0 ├── structure.sql └── xi - FREEDOM DiVE (Nakagawa-Kanon) [FOUR DIMENSIONS].osu /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/cache@v2 12 | name: "Cache" 13 | with: 14 | path: | 15 | ${{ github.workspace }}/.cargo-home 16 | ${{ github.workspace }}/tillerinobot-live/target/release 17 | ~/.m2 18 | key: cache-${{ github.sha }} 19 | restore-keys: | 20 | cache- 21 | - name: Check cache 22 | run: | 23 | ls -la 24 | ls -la ~ 25 | - uses: addnab/docker-run-action@v3 26 | name: "Build live" 27 | with: 28 | image: rust:1.78-alpine 29 | options: -v ${{ github.workspace }}:/work 30 | run: | 31 | apk add --no-cache musl-dev 32 | addgroup -g 121 docker 33 | adduser -u 1001 --disabled-password runner docker 34 | 35 | cd /work 36 | export CARGO_HOME=$(pwd)/.cargo-home 37 | cd tillerinobot-live 38 | cargo build --release 39 | 40 | chown -R runner:docker target 41 | chown -R runner:docker ../.cargo-home 42 | - uses: actions/setup-java@v2 43 | name: "Install Java/Maven" 44 | with: 45 | java-version: '22' 46 | distribution: 'adopt' 47 | - name: Build with Maven 48 | run: mvn -B -V -C verify -Dmaven.javadoc.skip=true 49 | - uses: codecov/codecov-action@v1 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */target 2 | */.project 3 | */.settings 4 | */.classpath 5 | */.factorypath 6 | **/tillerinobot-db.json 7 | 8 | # IntelliJ IDEA 9 | **/.idea 10 | **/*.iml 11 | /.settings/ 12 | /target/ 13 | /.project 14 | **/.DS_Store 15 | -------------------------------------------------------------------------------- /docs/img/notification_disconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tillerino/Tillerinobot/7c59b2557fe9be2a227ca5fb7b476d06c5c181d3/docs/img/notification_disconnected.png -------------------------------------------------------------------------------- /docs/img/notification_reconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tillerino/Tillerinobot/7c59b2557fe9be2a227ca5fb7b476d06c5c181d3/docs/img/notification_reconnected.png -------------------------------------------------------------------------------- /docs/swagger/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tillerino/Tillerinobot/7c59b2557fe9be2a227ca5fb7b476d06c5c181d3/docs/swagger/favicon-16x16.png -------------------------------------------------------------------------------- /docs/swagger/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tillerino/Tillerinobot/7c59b2557fe9be2a227ca5fb7b476d06c5c181d3/docs/swagger/favicon-32x32.png -------------------------------------------------------------------------------- /docs/swagger/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 10 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | updates-flags := "-q '-Dmaven.version.ignore=.*\\.Beta\\d*,.*\\.BETA\\d*,.*-beta-\\d*,.*\\.android\\d*,.*-M\\d' -Dversions.outputFile=updates.txt -Dversions.outputLineWidth=1000 -P release" 2 | mvn := "mvn" 3 | 4 | # Prints a help text and exits to catch a bare "just" invocation 5 | help: 6 | @just --list 7 | 8 | # Clean and verify while building the Rust modules explicitly 9 | clean-verify: 10 | mvn clean verify -P rust 11 | 12 | # Do more stupid things, faster :sunglasses: 13 | clean-verify-fast: 14 | mvn clean verify -T 3 -P rust 15 | 16 | # Install the JARs into the Maven repository without testing anything. 17 | install: 18 | mvn clean install -DskipTests -Dspotbugs.skip=true -T 2 19 | 20 | upgrade-rust module: 21 | cargo update --manifest-path tillerinobot-{{module}}/Cargo.toml 22 | 23 | upgrade-rust-all: 24 | just upgrade-rust live 25 | just upgrade-rust irc 26 | 27 | outdated-rust module: 28 | # install with cargo install --locked cargo-outdated 29 | cargo outdated --manifest-path tillerinobot-{{module}}/Cargo.toml 30 | 31 | outdated-rust-all: 32 | just outdated-rust live 33 | just outdated-rust irc 34 | 35 | outdated-java: 36 | {{mvn}} versions:display-plugin-updates {{updates-flags}} && { grep -- "->" updates.txt */updates.txt */*/updates.txt | sed 's/\.\+/./g'; } 37 | {{mvn}} versions:display-property-updates {{updates-flags}} && { grep -- "->" updates.txt */updates.txt */*/updates.txt | sed 's/\.\+/./g'; } 38 | {{mvn}} versions:display-dependency-updates {{updates-flags}} && { grep -- "->" updates.txt */updates.txt */*/updates.txt | sed 's/\.\+/./g'; } 39 | rm updates.txt */updates.txt */*/updates.txt 40 | -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | config.stopBubbling = true 2 | lombok.addLombokGeneratedAnnotation = true 3 | lombok.extern.findbugs.addSuppressFBWarnings = true 4 | lombok.copyableAnnotations += org.tillerino.ppaddict.chat.IRCName 5 | lombok.copyableAnnotations += org.tillerino.osuApiModel.types.UserId 6 | lombok.copyableAnnotations += org.tillerino.osuApiModel.types.BeatmapId 7 | lombok.copyableAnnotations += org.tillerino.osuApiModel.types.BitwiseMods 8 | lombok.copyableAnnotations += org.tillerino.osuApiModel.types.GameMode 9 | lombok.copyableAnnotations += org.tillerino.osuApiModel.types.MillisSinceEpoch 10 | lombok.copyableAnnotations += org.tillerino.ppaddict.web.types.PpaddictId 11 | lombok.copyableAnnotations += javax.inject.Named 12 | lombok.copyableAnnotations += jakarta.inject.Named 13 | -------------------------------------------------------------------------------- /tillerinobot-irc/.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | !target/release/main -------------------------------------------------------------------------------- /tillerinobot-irc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tillerinobot-irc" 3 | # Version is irrelevant. We're hashing everything and tagging the Docker image with the hash. 4 | version = "0.1.0" 5 | authors = ["Tillerino "] 6 | edition = '2021' 7 | 8 | [dependencies] 9 | tokio-amqp = { version = "2", default-features = false } 10 | lapin = { version = "2", default-features = false } 11 | tokio = { version = "1.2", features = [ "macros", "signal" ] } 12 | tokio-stream = { version = "0.1", default-features = false } 13 | serde_derive = { version = "1.0" } 14 | serde_json = { version = "1.0" } 15 | serde = { version = "1.0" } 16 | lazy_static = "1.4" 17 | irc = { version = "1", default-features = false, features = [ "ctcp" ] } 18 | thiserror = "1" 19 | warp = { version = "0.3", default-features = false } 20 | uuid = { version = "1", features = [ "v4" ] } 21 | urlencoding = "2" 22 | 23 | [[bin]] 24 | name = "main" 25 | path = "src/main/rust/main.rs" -------------------------------------------------------------------------------- /tillerinobot-irc/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD 2 | FROM rust:1.78-alpine 3 | 4 | # we need to install one library on alpine 5 | RUN apk add --no-cache musl-dev 6 | 7 | WORKDIR /build/ 8 | 9 | # selection in .dockerignore 10 | ADD . . 11 | 12 | RUN (ldd target/release/main && echo "already built") || cargo build --release 13 | 14 | # RUN 15 | # curl image is based on alpine, so we're compatible 16 | FROM curlimages/curl 17 | 18 | USER root 19 | RUN apk add --no-cache musl-dev 20 | USER curl_user 21 | 22 | COPY --from=0 /build/target/release/main . 23 | 24 | CMD [ "./main" ] 25 | 26 | EXPOSE 8080 27 | -------------------------------------------------------------------------------- /tillerinobot-irc/irc-checksum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd $(dirname "$0") 3 | sha256sum Dockerfile target/release/main | sha256sum | head -c 64 4 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/main/rust/probes.rs: -------------------------------------------------------------------------------- 1 | use lapin::ConnectionStatus; 2 | use std::sync::{Arc, Mutex}; 3 | use warp::http::StatusCode; 4 | use warp::{reply, Filter}; 5 | use crate::rabbit::game_chat_client::GameChatClientMetrics; 6 | 7 | pub(crate) struct HttpConfig { 8 | port: u16, 9 | } 10 | 11 | impl Default for HttpConfig { 12 | fn default() -> Self { 13 | Self { 14 | port: option_env!("HTTP_PORT").unwrap_or("8080").parse().expect("unable to parse TILLERINOBOT_HTTP_PORT"), 15 | } 16 | } 17 | } 18 | 19 | impl HttpConfig { 20 | pub(crate) async fn start(&self, rabbit_status: Arc>>, metrics: Arc>) { 21 | warp::serve(warp::path("live").map(move || { 22 | let lock = rabbit_status.lock().unwrap(); 23 | if !lock.clone().map(|s| s.connected()).unwrap_or(false) { 24 | return reply::with_status("RabbitMQ not connected", StatusCode::SERVICE_UNAVAILABLE); 25 | } 26 | if !metrics.lock().unwrap().is_connected { 27 | return reply::with_status("IRC not connected", StatusCode::SERVICE_UNAVAILABLE); 28 | } 29 | return reply::with_status("OK", StatusCode::OK); 30 | })) 31 | .run(([0, 0, 0, 0], self.port)) 32 | .await; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/java/org/tillerino/ppaddict/chat/irc/IrcContainer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.irc; 2 | 3 | import static org.tillerino.ppaddict.util.DockerNetwork.NETWORK; 4 | 5 | import java.io.File; 6 | 7 | import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; 8 | import org.testcontainers.images.builder.ImageFromDockerfile; 9 | import org.tillerino.ppaddict.rabbit.RabbitMqContainer; 10 | import org.tillerino.ppaddict.util.CustomTestContainer; 11 | 12 | public class IrcContainer { 13 | private static final ImageFromDockerfile base = new ImageFromDockerfile() 14 | .withFileFromFile("Dockerfile", new File("../tillerinobot-irc/Dockerfile")) 15 | .withFileFromFile("Cargo.toml", new File("../tillerinobot-irc/Cargo.toml")) 16 | .withFileFromFile("Cargo.lock", new File("../tillerinobot-irc/Cargo.lock")) 17 | .withFileFromFile("src/main/rust", new File("../tillerinobot-irc/src/main/rust")); 18 | 19 | public static final CustomTestContainer TILLERINOBOT_IRC = new CustomTestContainer(new File("../tillerinobot-irc/target/release/main").exists() 20 | ? base.withFileFromFile("target/release/main", new File("../tillerinobot-irc/target/release/main")) 21 | : base) 22 | .withNetwork(NETWORK) 23 | .withExposedPorts(8080) 24 | .waitingFor(new HttpWaitStrategy().forPort(8080).forPath("/live")) 25 | .withEnv("TILLERINOBOT_IRC_SERVER", "irc") 26 | .withEnv("TILLERINOBOT_IRC_PORT", "6667") 27 | .withEnv("TILLERINOBOT_IRC_NICKNAME", "tillerinobot") 28 | .withEnv("TILLERINOBOT_IRC_PASSWORD", "") 29 | .withEnv("TILLERINOBOT_IRC_AUTOJOIN", "#osu") 30 | .withEnv("TILLERINOBOT_IGNORE", "false") 31 | .withEnv("RABBIT_VHOST", RabbitMqContainer.getVirtualHost()) 32 | .logging("IRC"); 33 | 34 | static { 35 | NgircdContainer.NGIRCD.start(); 36 | RabbitMqContainer.start(); 37 | TILLERINOBOT_IRC.start(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/java/org/tillerino/ppaddict/chat/irc/KittehForNgircd.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.irc; 2 | 3 | import org.kitteh.irc.client.library.Client; 4 | import org.kitteh.irc.client.library.Client.Builder.Server.SecurityType; 5 | import org.kitteh.irc.client.library.exception.KittehNagException; 6 | import org.kitteh.irc.client.library.feature.sending.QueueProcessingThreadSender; 7 | 8 | public class KittehForNgircd { 9 | 10 | public static Client buildKittehClient(String nick) { 11 | return Client.builder() 12 | .nick(nick) 13 | .server().host(NgircdContainer.NGIRCD.getHost()).port(NgircdContainer.NGIRCD.getMappedPort(6667), SecurityType.INSECURE) 14 | .then().listeners().exception(e -> { 15 | if (!(e instanceof KittehNagException)) { 16 | e.printStackTrace(); 17 | } 18 | }) 19 | .then().management().messageSendingQueueSupplier(m -> new QueueProcessingThreadSender(m.getClient(), "no pause")) 20 | .then().build(); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/java/org/tillerino/ppaddict/chat/irc/NgircdContainer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.irc; 2 | 3 | import static org.tillerino.ppaddict.util.DockerNetwork.NETWORK; 4 | 5 | import org.testcontainers.containers.BindMode; 6 | import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; 7 | import org.tillerino.ppaddict.util.CustomTestContainer; 8 | 9 | public class NgircdContainer { 10 | public static final CustomTestContainer NGIRCD = new CustomTestContainer("linuxserver/ngircd:60428df3-ls19") 11 | .withNetwork(NETWORK) 12 | .withNetworkAliases("irc") 13 | .withClasspathResourceMapping("/irc/ngircd.conf", "/config/ngircd.conf", BindMode.READ_ONLY) 14 | .withClasspathResourceMapping("/irc/ngircd.motd", "/etc/ngircd/ngircd.motd", BindMode.READ_ONLY) 15 | .withExposedPorts(6667) 16 | .logging("NGIRCD") 17 | .waitingFor(new LogMessageWaitStrategy() 18 | .withRegEx(".*Now listening on.*")); 19 | 20 | static { 21 | NGIRCD.start(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/resources/irc/ngircd.conf: -------------------------------------------------------------------------------- 1 | [Global] 2 | Name = ppaddict.tillerino.org 3 | [Limits] 4 | MaxPenaltyTime = 0 5 | MaxNickLength = 30 6 | [Options] 7 | PAMIsOptional = yes 8 | -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/resources/irc/ngircd.motd: -------------------------------------------------------------------------------- 1 | Welcome to the test server -------------------------------------------------------------------------------- /tillerinobot-irc/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tillerinobot-live/.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | 3 | !/src/main/rust 4 | !/target/release/main 5 | !/Cargo.* 6 | !/rust-toolchain -------------------------------------------------------------------------------- /tillerinobot-live/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ppaddict_live" 3 | # We're getting the version from the first line of CHANGELOG.md to keep the Cargo.toml file constant. 4 | # This allows us to cache the building of dependencies in our Docker images. 5 | # i.e. don't change this version 6 | version = "0.1.0" 7 | authors = ["Tillerino "] 8 | edition = '2021' 9 | 10 | [dependencies] 11 | tokio-amqp = { version = "2", default-features = false } 12 | lapin = { version = "2", default-features = false } 13 | tokio = { version = "1.2", features = [ "rt-multi-thread", "macros" ] } 14 | tokio-stream = { version = "0.1", default-features = false } 15 | serde_derive = { version = "1.0" } 16 | serde_json = { version = "1.0" } 17 | serde = { version = "1.0" } 18 | sha2 = "0.10" 19 | rand_chacha = "0.3" 20 | rand_core = "0.6" 21 | lazy_static = "1.4" 22 | futures-util = "0.3" 23 | warp = { version = "0.3", default-features = false, features = [ "websocket" ] } 24 | futures-macro = { version = "0.3" } 25 | urlencoding = "2" 26 | 27 | [[bin]] 28 | name = "main" 29 | path = "src/main/rust/main.rs" 30 | -------------------------------------------------------------------------------- /tillerinobot-live/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD 2 | FROM rust:1.78-alpine 3 | 4 | # we need to install one library on alpine 5 | RUN apk add --no-cache musl-dev 6 | 7 | WORKDIR /build/ 8 | 9 | # selection in .dockerignore 10 | ADD . . 11 | 12 | RUN (ldd target/release/main && echo "already built") || cargo build --release 13 | 14 | # RUN 15 | # curl image is based on alpine, so we're compatible 16 | FROM curlimages/curl 17 | 18 | USER root 19 | RUN apk add --no-cache musl-dev 20 | USER curl_user 21 | 22 | COPY --from=0 /build/target/release/main . 23 | 24 | CMD [ "./main" ] 25 | 26 | EXPOSE 8080 27 | -------------------------------------------------------------------------------- /tillerinobot-live/README.md: -------------------------------------------------------------------------------- 1 | This is the backend of the GUI running at https://tillerino.github.io/Tillerinobot/. 2 | 3 | The frontend code is in [../docs](../docs) 4 | 5 | This module runs in its own Docker container consuming updates through RabbitMQ 6 | while pushing updates to the connected Websockets. 7 | 8 | Messages that are passed through RabbitMQ are serialized in JSON. 9 | The schema definition is in [RemoteLiveActivity.java](../tillerinobot-rabbit/src/main/java/org/tillerino/ppaddict/rabbit/RemoteLiveActivity.java). 10 | -------------------------------------------------------------------------------- /tillerinobot-live/live-checksum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd $(dirname "$0") 3 | sha256sum Dockerfile target/release/main | sha256sum | head -c 64 4 | -------------------------------------------------------------------------------- /tillerinobot-live/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tillerinobot-live/src/main/rust/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate lazy_static; 3 | 4 | mod rabbit; 5 | mod websocket; 6 | 7 | use rabbit::run_rabbit; 8 | use websocket::run_http; 9 | 10 | #[tokio::main(worker_threads = 1)] // run everything in a single thread 11 | async fn main() { 12 | // quit if one of these exits, although they really shouldn't 13 | tokio::select!( 14 | _ = run_rabbit() => (), 15 | _ = run_http() => ()) 16 | } 17 | -------------------------------------------------------------------------------- /tillerinobot-live/src/test/java/org/tillerino/ppaddict/live/LiveActivityIT.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.live; 2 | 3 | import org.junit.ClassRule; 4 | import org.tillerino.ppaddict.chat.LiveActivity; 5 | import org.tillerino.ppaddict.rabbit.RabbitMqConfiguration; 6 | import org.tillerino.ppaddict.rabbit.RabbitMqContainerConnection; 7 | import org.tillerino.ppaddict.rabbit.RemoteLiveActivity; 8 | 9 | public class LiveActivityIT extends AbstractLiveActivityEndpointTest { 10 | 11 | @ClassRule 12 | public static RabbitMqContainerConnection rabbit = new RabbitMqContainerConnection(null); 13 | 14 | private RemoteLiveActivity push; 15 | 16 | @Override 17 | public void setUp() throws Exception { 18 | push = RabbitMqConfiguration.liveActivity(rabbit.getConnection()); 19 | super.setUp(); 20 | } 21 | @Override 22 | protected int port() { 23 | return LiveContainer.getLive().getMappedPort(8080); 24 | } 25 | 26 | @Override 27 | protected String host() { 28 | return LiveContainer.getLive().getHost(); 29 | } 30 | 31 | @Override 32 | protected LiveActivity push() { 33 | return push; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tillerinobot-live/src/test/java/org/tillerino/ppaddict/live/LiveContainer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.live; 2 | 3 | import static org.tillerino.ppaddict.util.DockerNetwork.NETWORK; 4 | 5 | import java.nio.file.Files; 6 | import java.nio.file.Paths; 7 | 8 | import org.testcontainers.containers.wait.strategy.Wait; 9 | import org.testcontainers.images.builder.ImageFromDockerfile; 10 | import org.tillerino.ppaddict.rabbit.RabbitMqContainer; 11 | import org.tillerino.ppaddict.util.CustomTestContainer; 12 | 13 | public class LiveContainer { 14 | private static final ImageFromDockerfile POTENTIALLY_PREBUILT = Files 15 | .isExecutable(Paths.get("../tillerinobot-live/target/release/main")) 16 | ? new ImageFromDockerfile().withFileFromPath("target/release/main", 17 | Paths.get("../tillerinobot-live/target/release/main")) 18 | : new ImageFromDockerfile(); 19 | 20 | private static final CustomTestContainer LIVE = new CustomTestContainer(POTENTIALLY_PREBUILT 21 | // move to parent so we can use this in multiple modules 22 | // make sure to keep this aligned with dockerignore 23 | .withFileFromPath("Dockerfile", Paths.get("../tillerinobot-live/Dockerfile")) 24 | .withFileFromPath("src/main/rust", Paths.get("../tillerinobot-live/src/main/rust")) 25 | .withFileFromPath("Cargo.toml", Paths.get("../tillerinobot-live/Cargo.toml")) 26 | .withFileFromPath("Cargo.lock", Paths.get("../tillerinobot-live/Cargo.lock"))) 27 | // accesing this static variable will make sure that RabbitMQ is started 28 | .withEnv("RABBIT_VHOST", RabbitMqContainer.getVirtualHost()) 29 | .withNetwork(NETWORK) 30 | .withExposedPorts(8080) 31 | .waitingFor(Wait.forHttp("/ready").forStatusCode(200)) 32 | .withCreateContainerCmdModifier(cmd -> cmd.withMemory(16 * 1024 * 1024L).withMemorySwap(16 * 1024 * 1024L)) 33 | .logging("LIVE"); 34 | 35 | static { 36 | LIVE.start(); 37 | } 38 | 39 | public static CustomTestContainer getLive() { 40 | return LIVE; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tillerinobot-live/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tillerinobot-model/pom.xml: -------------------------------------------------------------------------------- 1 | 4 | 4.0.0 5 | 6 | org.tillerino.osu 7 | tillerinobot-parent 8 | 0.20.0 9 | .. 10 | 11 | tillerinobot-model 12 | Model 13 | 14 | Contains model and other common classes that are used in multiple Tillerinobot modules. 15 | We do this to avoid direct dependencies between modules. 16 | 17 | 18 | 19 | 20 | com.github.tillerino 21 | osu-api-connector 22 | 23 | 24 | com.google.code.gson 25 | gson-parent 26 | 27 | 28 | 29 | 30 | jakarta.ws.rs 31 | jakarta.ws.rs-api 32 | 33 | 34 | org.apache.commons 35 | commons-lang3 36 | 37 | 38 | 39 | com.fasterxml.jackson.core 40 | jackson-databind 41 | test 42 | 43 | 44 | com.fasterxml.jackson.module 45 | jackson-module-parameter-names 46 | test 47 | 48 | 49 | com.google.inject 50 | guice 51 | test 52 | 53 | 54 | org.testcontainers 55 | testcontainers 56 | test 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatClient.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.ppaddict.rabbit.Rpc; 4 | import org.tillerino.ppaddict.util.Result; 5 | 6 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 7 | import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; 8 | 9 | /** 10 | * This is the "main function" of the chat bot, i.e. connects to the server, 11 | * starts accepting messages... 12 | */ 13 | public interface GameChatClient { 14 | @Rpc(queue = "game_chat_client", timeout = 1000) 15 | Result getMetrics(); 16 | 17 | @JsonTypeInfo(use = Id.MINIMAL_CLASS) 18 | sealed interface Error { 19 | public record Timeout() implements Error { } 20 | 21 | public record Unknown() implements Error { } 22 | } 23 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatClientMetrics.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import javax.inject.Singleton; 4 | 5 | import lombok.Data; 6 | 7 | @Singleton 8 | @Data 9 | public class GameChatClientMetrics { 10 | private boolean isConnected; 11 | private long runningSince; 12 | private long lastPingDeath; 13 | private long lastInteraction; 14 | private long lastReceivedMessage; 15 | } 16 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatEvent.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.osuApiModel.types.MillisSinceEpoch; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnore; 6 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 7 | import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; 8 | 9 | import lombok.AccessLevel; 10 | import lombok.Data; 11 | import lombok.RequiredArgsConstructor; 12 | 13 | @Data 14 | @RequiredArgsConstructor(access = AccessLevel.PROTECTED) 15 | @JsonTypeInfo(use = Id.MINIMAL_CLASS) 16 | public abstract class GameChatEvent { 17 | private final long eventId; 18 | 19 | private final @IRCName String nick; 20 | 21 | @MillisSinceEpoch 22 | private final long timestamp; 23 | 24 | /** 25 | * This is meta information which is collected throughout the phases. Since this 26 | * information is mutable, we tuck it away in this field. This doesn't make the 27 | * entire object immutable, but at least we have a clear overview of there the 28 | * mutability lies. 29 | */ 30 | private GameChatEventMeta meta = new GameChatEventMeta(); 31 | 32 | @JsonIgnore 33 | public abstract boolean isInteractive(); 34 | 35 | @JsonIgnore 36 | public int getPriority() { 37 | if (isInteractive()) { 38 | return 3; 39 | } else if (this instanceof Joined) { 40 | return 2; 41 | } 42 | return 1; 43 | } 44 | 45 | public void completePhase(String name) { 46 | meta.getTimer().completePhase(name); 47 | } 48 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatEventConsumer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | /** 4 | * There are three consumers of chat events: the synchronous consumer, the queue, and the asynchronous 5 | * consumer: 6 | * 7 | * - The synchronous consumer is responsible for enqueueing the received 8 | * messages, skipping messages from over-eager users and propagating to the live UI. 9 | * - The queue enqueues chat events for asynchronous consumption. 10 | * - The asynchronous consumer is then fed by the queue and doing the actual processing. 11 | */ 12 | public interface GameChatEventConsumer { 13 | void onEvent(GameChatEvent event) throws InterruptedException; 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatEventMeta.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.ppaddict.util.MdcUtils.MdcSnapshot; 4 | 5 | import lombok.Data; 6 | import org.tillerino.ppaddict.util.PhaseTimer; 7 | 8 | /** 9 | * This is non-critical meta information of a {@link GameChatEvent} which is implemented as mutable. 10 | */ 11 | @Data 12 | public class GameChatEventMeta { 13 | private long rateLimiterBlockedTime; 14 | 15 | private MdcSnapshot mdc; 16 | 17 | private PhaseTimer timer; 18 | } 19 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatEventQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | public interface GameChatEventQueue extends GameChatEventConsumer { 4 | int size(); 5 | } 6 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatResponseConsumer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | public interface GameChatResponseConsumer { 4 | /** 5 | * Handles a response to a game chat event. 6 | * 7 | * @param response the response to the event 8 | * @param event the triggering event 9 | */ 10 | void onResponse(GameChatResponse response, GameChatEvent event) throws InterruptedException; 11 | } 12 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatResponseQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | public interface GameChatResponseQueue extends GameChatResponseConsumer { 4 | int size(); 5 | } 6 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/GameChatWriter.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import java.io.IOException; 4 | import java.util.Optional; 5 | 6 | import org.tillerino.ppaddict.rabbit.Rpc; 7 | import org.tillerino.ppaddict.util.Result; 8 | 9 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 10 | import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; 11 | 12 | /** 13 | * Writes a response to an event to the game chat. 14 | */ 15 | public interface GameChatWriter { 16 | /** 17 | * Responds with a direct message. 18 | * 19 | * @param response the message to send 20 | * @param recipient the recipient of the message 21 | */ 22 | @Rpc(queue = "irc_writer", timeout = 12000) 23 | Result message(String response, @IRCName String recipient) throws InterruptedException, IOException; 24 | 25 | /** 26 | * Responds with an "action", a special kind of direct message. 27 | * 28 | * @param response the action to send 29 | * @param recipient the recipient of the action 30 | */ 31 | @Rpc(queue = "irc_writer", timeout = 12000) 32 | Result action(String response, @IRCName String recipient) throws InterruptedException, IOException; 33 | 34 | @JsonTypeInfo(use = Id.MINIMAL_CLASS) 35 | sealed interface Error { 36 | public record Timeout() implements Error { } 37 | 38 | public record Unknown() implements Error { } 39 | 40 | public record Retry(int millis) implements Error { } 41 | 42 | public record PingDeath(long millis) implements Error { } 43 | } 44 | 45 | public record Response(Long ping) { } 46 | } 47 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/IRCName.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | 6 | import javax.annotation.meta.TypeQualifier; 7 | 8 | @TypeQualifier(applicableTo = String.class) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface IRCName { 11 | 12 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/Joined.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.osuApiModel.types.MillisSinceEpoch; 4 | 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | @Getter 10 | @ToString(callSuper = true) 11 | @EqualsAndHashCode(callSuper = true) 12 | public class Joined extends GameChatEvent { 13 | public Joined(long eventId, @IRCName String nick, @MillisSinceEpoch long timestamp) { 14 | super(eventId, nick, timestamp); 15 | } 16 | 17 | @Override 18 | public boolean isInteractive() { 19 | return false; 20 | } 21 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/LiveActivity.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | /** 4 | * Sends messages to the live preview. 5 | */ 6 | public interface LiveActivity { 7 | void propagateReceivedMessage(@IRCName String ircUserName, long eventId); 8 | 9 | void propagateSentMessage(@IRCName String ircUserName, long eventId, Long ping); 10 | 11 | void propagateMessageDetails(long eventId, String text); 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/PrivateAction.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.osuApiModel.types.MillisSinceEpoch; 4 | 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | @Getter 10 | @ToString(callSuper = true) 11 | @EqualsAndHashCode(callSuper = true) 12 | public class PrivateAction extends GameChatEvent { 13 | private final String action; 14 | 15 | public PrivateAction(long eventId, @IRCName String nick, @MillisSinceEpoch long timestamp, String action) { 16 | super(eventId, nick, timestamp); 17 | this.action = action; 18 | } 19 | 20 | @Override 21 | public boolean isInteractive() { 22 | return true; 23 | } 24 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/PrivateMessage.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.osuApiModel.types.MillisSinceEpoch; 4 | 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | @Getter 10 | @ToString(callSuper = true) 11 | @EqualsAndHashCode(callSuper = true) 12 | public class PrivateMessage extends GameChatEvent { 13 | private final String message; 14 | 15 | public PrivateMessage(long eventId, @IRCName String nick, @MillisSinceEpoch long timestamp, String message) { 16 | super(eventId, nick, timestamp); 17 | this.message = message; 18 | } 19 | 20 | @Override 21 | public boolean isInteractive() { 22 | return true; 23 | } 24 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/chat/Sighted.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import org.tillerino.osuApiModel.types.MillisSinceEpoch; 4 | 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | @Getter 10 | @ToString(callSuper = true) 11 | @EqualsAndHashCode(callSuper = true) 12 | public class Sighted extends GameChatEvent { 13 | public Sighted(long eventId, @IRCName String nick, @MillisSinceEpoch long timestamp) { 14 | super(eventId, nick, timestamp); 15 | } 16 | 17 | @Override 18 | public boolean isInteractive() { 19 | return false; 20 | } 21 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/config/ConfigService.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.config; 2 | 3 | import java.util.Optional; 4 | 5 | public interface ConfigService { 6 | Optional config(String key); 7 | 8 | default boolean scoresMaintenance() { 9 | return config("api-scores-maintenance").orElse("false").equals("true"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/rabbit/Rpc.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | import java.lang.annotation.Target; 8 | 9 | import org.tillerino.ppaddict.util.Result; 10 | 11 | /** 12 | * Marks a method as an RPC. This method can then be called across processes 13 | * connected by RabbitMQ. The annotated method must return a {@link Result} and 14 | * must not throw any exceptions. Any thrown exception will end the server. The 15 | * RPC transmits the MDC back and forth: the caller's MDC is set as the callee's 16 | * MDC and the callee's MDC is added to the caller's MDC. 17 | * 18 | * See also {@link RabbitRpc}. 19 | */ 20 | @Documented 21 | @Retention(RetentionPolicy.RUNTIME) 22 | @Target(ElementType.METHOD) 23 | public @interface Rpc { 24 | /** 25 | * @return unique name of the queue that calls are sent to. 26 | */ 27 | String queue(); 28 | 29 | /** 30 | * @return milliseconds. 31 | */ 32 | int timeout(); 33 | } 34 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/util/Clock.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | /** 4 | * Hides {@link System#currentTimeMillis()} for unit tests. 5 | */ 6 | public interface Clock { 7 | /** 8 | * Returns the current time in milliseconds. 9 | * 10 | * @return the difference, measured in milliseconds, between 11 | * the current time and midnight, January 1, 1970 UTC. 12 | */ 13 | long currentTimeMillis(); 14 | 15 | static Clock system() { 16 | return System::currentTimeMillis; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/util/LoggingUtils.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.util.concurrent.ThreadLocalRandom; 4 | 5 | public class LoggingUtils { 6 | public static String getRandomString(int length) { 7 | char[] chars = new char[length]; 8 | for (int j = 0; j < chars.length; j++) { 9 | chars[j] = (char) ('A' + ThreadLocalRandom.current().nextInt(26)); 10 | } 11 | return new String(chars); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/util/MaintenanceException.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import jakarta.ws.rs.ServiceUnavailableException; 4 | 5 | /** 6 | * This can be thrown from anywhere in the code to indicate that something does 7 | * not work currently because of maintenance. 8 | * When encountering this exception, either use a fallback or display a message about maintenance. 9 | */ 10 | public class MaintenanceException extends ServiceUnavailableException { 11 | private static final long serialVersionUID = 1L; 12 | 13 | public MaintenanceException(String message) { 14 | super(message); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/util/QuietCloseable.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | /** 4 | * Like {@link AutoCloseable}, but without checked exceptions. 5 | */ 6 | public interface QuietCloseable extends AutoCloseable { 7 | @Override 8 | void close(); 9 | } 10 | -------------------------------------------------------------------------------- /tillerinobot-model/src/main/java/org/tillerino/ppaddict/util/RetryableException.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | /** 4 | * An exception indicating that an action failed but can be retried. 5 | * Call {@link #waitBeforeRetry()} before retrying. 6 | */ 7 | public class RetryableException extends RuntimeException { 8 | private final int retryInMillis; 9 | 10 | public RetryableException(int retryInMillis) { 11 | this.retryInMillis = retryInMillis; 12 | } 13 | 14 | public void waitBeforeRetry() throws InterruptedException { 15 | if (retryInMillis > 0) { 16 | Thread.sleep(retryInMillis); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/chat/GameChatEventTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; 5 | import org.junit.Test; 6 | 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | 9 | public class GameChatEventTest { 10 | @Test 11 | public void joinedSerialization() throws Exception { 12 | String expected = """ 13 | {"@c":".Joined","eventId":123,"nick":"nick","timestamp":456,"meta":{"rateLimiterBlockedTime":0,"mdc":null,"timer":null}} 14 | """; 15 | Joined joined = new Joined(123, "nick", 456); 16 | String actual = new ObjectMapper().writeValueAsString(joined); 17 | assertThat(actual).isEqualToIgnoringWhitespace(expected); 18 | assertThat(new ObjectMapper().registerModule(new ParameterNamesModule()).readValue(actual, GameChatEvent.class)) 19 | .isEqualTo(joined); 20 | } 21 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/chat/JoinedTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | public class JoinedTest { 8 | @Test 9 | public void notInteractive() throws Exception { 10 | assertThat(new Joined(123, "", 456)).hasFieldOrPropertyWithValue("interactive", false); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/chat/PrivateActionTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | public class PrivateActionTest { 8 | @Test 9 | public void interactive() throws Exception { 10 | assertThat(new PrivateAction(123, "", 456, "")).hasFieldOrPropertyWithValue("interactive", true); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/chat/PrivateMessageTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | public class PrivateMessageTest { 8 | @Test 9 | public void interactive() throws Exception { 10 | assertThat(new PrivateMessage(123, "", 456, "")).hasFieldOrPropertyWithValue("interactive", true); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/chat/SightedTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | public class SightedTest { 8 | @Test 9 | public void notInteractive() throws Exception { 10 | assertThat(new Sighted(123, "", 456)).hasFieldOrPropertyWithValue("interactive", false); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/Bind.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | public @interface Bind { 4 | Class api(); 5 | Class impl(); 6 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/CustomTestContainer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.concurrent.Future; 6 | 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.testcontainers.containers.GenericContainer; 10 | 11 | public class CustomTestContainer extends GenericContainer { 12 | public final List logs = new ArrayList<>(); 13 | 14 | public CustomTestContainer(String image) { 15 | super(image); 16 | setUpLogging(); 17 | } 18 | 19 | public CustomTestContainer(final Future image) { 20 | super(image); 21 | setUpLogging(); 22 | } 23 | 24 | public CustomTestContainer logging(String loggerName) { 25 | Logger logger = LoggerFactory.getLogger(loggerName); 26 | return withLogConsumer(frame -> logger.info(frame.getUtf8StringWithoutLineEnding())); 27 | } 28 | 29 | private void setUpLogging() { 30 | withLogConsumer(frame -> logs.add(frame.getUtf8StringWithoutLineEnding())); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/DockerNetwork.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import org.junit.runner.Description; 4 | import org.junit.runners.model.Statement; 5 | import org.testcontainers.DockerClientFactory; 6 | import org.testcontainers.containers.Network; 7 | 8 | public class DockerNetwork { 9 | public static final Network NETWORK = createReusableNetwork("ppaddict-test-containers"); 10 | 11 | public static Network createReusableNetwork(String name) { 12 | String id = DockerClientFactory.instance().client().listNetworksCmd().exec().stream() 13 | .filter(network -> network.getName().equals(name) 14 | && network.getLabels().equals(DockerClientFactory.DEFAULT_LABELS)) 15 | .map(com.github.dockerjava.api.model.Network::getId) 16 | .findFirst() 17 | .orElseGet(() -> DockerClientFactory.instance().client().createNetworkCmd() 18 | .withName(name) 19 | .withCheckDuplicate(true) 20 | .withLabels(DockerClientFactory.DEFAULT_LABELS) 21 | .exec().getId()); 22 | 23 | return new Network() { 24 | @Override 25 | public Statement apply(Statement base, Description description) { 26 | return base; 27 | } 28 | 29 | @Override 30 | public String getId() { 31 | return id; 32 | } 33 | 34 | @Override 35 | public void close() { 36 | // never close 37 | } 38 | }; 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/ExecutorServiceRule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.util.concurrent.ExecutorService; 4 | import java.util.concurrent.Executors; 5 | import java.util.function.Supplier; 6 | 7 | import org.awaitility.Awaitility; 8 | import org.junit.rules.ExternalResource; 9 | 10 | import lombok.Getter; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.experimental.Delegate; 13 | 14 | /** 15 | * Wraps {@link ExecutorService} in an {@link ExternalResource}. 16 | */ 17 | @RequiredArgsConstructor 18 | public class ExecutorServiceRule extends ExternalResource implements ExecutorService { 19 | @Getter 20 | @Delegate(types = ExecutorService.class) 21 | private ExecutorService exec; 22 | 23 | private final Supplier supplier; 24 | 25 | private boolean interruptOnShutdown = false; 26 | 27 | @Override 28 | protected void before() throws Throwable { 29 | exec = supplier.get(); 30 | } 31 | 32 | @Override 33 | protected void after() { 34 | if (interruptOnShutdown) { 35 | exec.shutdownNow(); 36 | } else { 37 | exec.shutdown(); 38 | } 39 | Awaitility.await("Executor service shut down").until(exec::isTerminated); 40 | } 41 | 42 | public ExecutorServiceRule interruptOnShutdown() { 43 | interruptOnShutdown = true; 44 | return this; 45 | } 46 | 47 | public static ExecutorServiceRule singleThread(String name) { 48 | return new ExecutorServiceRule(() -> Executors.newSingleThreadExecutor(r -> new Thread(r, name))); 49 | } 50 | 51 | public static ExecutorServiceRule fixedThreadPool(String name, int nThreads) { 52 | return new ExecutorServiceRule(() -> Executors.newFixedThreadPool(nThreads, r -> new Thread(r, name))); 53 | } 54 | 55 | public static ExecutorServiceRule cachedThreadPool(String name) { 56 | return new ExecutorServiceRule(() -> Executors.newCachedThreadPool(r -> new Thread(r, name))); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/InjectionRunnerTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.io.Closeable; 6 | import java.io.Reader; 7 | 8 | import javax.inject.Inject; 9 | 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.mockito.internal.util.MockUtil; 13 | import org.testcontainers.shaded.org.apache.commons.io.input.NullReader; 14 | 15 | import com.google.inject.AbstractModule; 16 | 17 | @TestModule( 18 | value = InjectionRunnerTest.SomeModule.class, 19 | mocks = Closeable.class, 20 | binds = @Bind(api = Reader.class, impl = InjectionRunnerTest.DummyReader.class)) 21 | @RunWith(InjectionRunner.class) 22 | public class InjectionRunnerTest { 23 | @Inject 24 | private String s; 25 | @Inject 26 | private Closeable c; 27 | @Inject 28 | private Reader a; 29 | 30 | @Test 31 | public void wasInjected() throws Exception { 32 | assertThat(s).isEqualTo("Hello"); 33 | assertThat(MockUtil.isMock(c)); 34 | assertThat(a).isInstanceOf(DummyReader.class); 35 | } 36 | 37 | public static final class SomeModule extends AbstractModule { 38 | @Override 39 | protected void configure() { 40 | bind(String.class).toInstance("Hello"); 41 | } 42 | } 43 | 44 | public static class DummyReader extends NullReader { 45 | public DummyReader() { 46 | super(0); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/LoggingUtilsTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | public class LoggingUtilsTest { 8 | 9 | @Test 10 | public void testGetRandomString() throws Exception { 11 | assertThat(LoggingUtils.getRandomString(5)).matches("[A-Z]{5}"); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/PhaseTimerTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import org.junit.Test; 4 | 5 | /** 6 | * It is super hard to write assertions here, so just check the output manually. 7 | * Nothing much should change in the code anyway. 8 | */ 9 | public class PhaseTimerTest { 10 | @Test 11 | public void testPhaseTimer() throws Exception { 12 | PhaseTimer phaseTimer = new PhaseTimer(); 13 | Thread.sleep(10); 14 | phaseTimer.completePhase("Phase should be 10"); 15 | Thread.sleep(20); 16 | phaseTimer.completePhase("Phase should be 20"); 17 | phaseTimer.print(); 18 | } 19 | 20 | @Test 21 | public void testTasks() throws Exception { 22 | PhaseTimer phaseTimer = new PhaseTimer(); 23 | try (var _ = phaseTimer.pinToThread()) { 24 | try (var _ = PhaseTimer.timeTask("Task should be 10")) { 25 | Thread.sleep(10); 26 | } 27 | try (var _ = PhaseTimer.timeTask("Task should be 20")) { 28 | Thread.sleep(20); 29 | } 30 | phaseTimer.completePhase("Phase"); 31 | } 32 | phaseTimer.print(); 33 | } 34 | 35 | @Test 36 | public void repeatedTasks() throws Exception { 37 | PhaseTimer phaseTimer = new PhaseTimer(); 38 | try (var _ = phaseTimer.pinToThread()) { 39 | try (var _ = PhaseTimer.timeTask("Task should be 2x10")) { 40 | Thread.sleep(10); 41 | } 42 | try (var _ = PhaseTimer.timeTask("Task should be 2x20")) { 43 | Thread.sleep(20); 44 | } 45 | try (var _ = PhaseTimer.timeTask("Task should be 2x10")) { 46 | Thread.sleep(10); 47 | } 48 | try (var _ = PhaseTimer.timeTask("Task should be 2x20")) { 49 | Thread.sleep(20); 50 | } 51 | phaseTimer.completePhase("Phase"); 52 | } 53 | phaseTimer.print(); 54 | } 55 | 56 | @Test 57 | public void nestedTasks() throws Exception { 58 | PhaseTimer phaseTimer = new PhaseTimer(); 59 | try (var _ = phaseTimer.pinToThread()) { 60 | try (var _ = PhaseTimer.timeTask("Task should be 25 > 10")) { 61 | Thread.sleep(10); 62 | try (var _ = PhaseTimer.timeTask("Task should be 5")) { 63 | Thread.sleep(5); 64 | } 65 | try (var _ = PhaseTimer.timeTask("Task should be 5")) { 66 | Thread.sleep(5); 67 | try (var _ = PhaseTimer.timeTask("Task should be 5")) { 68 | Thread.sleep(5); 69 | } 70 | } 71 | } 72 | phaseTimer.completePhase("Phase"); 73 | } 74 | phaseTimer.print(); 75 | } 76 | } -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/ResettableModule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | public interface ResettableModule { 4 | void reset() throws Exception; 5 | } 6 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/ResultTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | import com.fasterxml.jackson.core.type.TypeReference; 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | 10 | public class ResultTest { 11 | ObjectMapper jackson = new ObjectMapper(); 12 | 13 | @Test 14 | public void jackson() throws Exception { 15 | Result r; 16 | assertThat(jackson.writeValueAsString(r = Result.ok("hello"))).isEqualTo("{\"ok\":\"hello\"}"); 17 | assertThat(jackson.writeValueAsString(r = Result.err(123))).isEqualTo("{\"err\":123}"); 18 | 19 | r = jackson.readValue("{\"ok\":\"hello\"}", new TypeReference<>() { 20 | }); 21 | assertThat(r).isEqualTo(Result.ok("hello")); 22 | 23 | r = jackson.readValue("{\"err\":123}", new TypeReference<>() { 24 | }); 25 | assertThat(r).isEqualTo(Result.err(123)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/ReusableContainerInitializer.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.util.Objects; 4 | 5 | import org.apache.commons.lang3.function.FailableConsumer; 6 | import org.testcontainers.containers.GenericContainer; 7 | 8 | import lombok.RequiredArgsConstructor; 9 | 10 | @RequiredArgsConstructor 11 | public class ReusableContainerInitializer> { 12 | private final T container; 13 | 14 | private final FailableConsumer initialize; 15 | 16 | private String initializedId = null; 17 | 18 | public T start() { 19 | container.start(); 20 | if (!Objects.equals(initializedId, container.getContainerId())) { 21 | try { 22 | initialize.accept(container); 23 | } catch (RuntimeException e) { 24 | throw e; 25 | } catch (Exception e) { 26 | throw new RuntimeException("Error initializing container", e); 27 | } 28 | initializedId = container.getContainerId(); 29 | } 30 | return container; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/TestClock.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.util.concurrent.atomic.AtomicLong; 4 | 5 | import javax.inject.Singleton; 6 | 7 | import com.google.inject.AbstractModule; 8 | import com.google.inject.Provides; 9 | 10 | import lombok.Getter; 11 | 12 | public class TestClock implements Clock { 13 | private final AtomicLong time = new AtomicLong(); 14 | 15 | @Override 16 | public long currentTimeMillis() { 17 | return time.get(); 18 | } 19 | 20 | public void advanceBy(long millis) { 21 | time.addAndGet(millis); 22 | } 23 | 24 | public void set(long millis) { 25 | time.set(millis); 26 | } 27 | 28 | public static class Module extends AbstractModule { 29 | @Getter(onMethod = @__({@Provides, @Singleton})) 30 | private final TestClock clock = new TestClock(); 31 | 32 | @Override 33 | protected void configure() { 34 | bind(Clock.class).to(TestClock.class); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/java/org/tillerino/ppaddict/util/TestModule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import com.google.inject.AbstractModule; 9 | 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Target(ElementType.TYPE) 12 | public @interface TestModule { 13 | Class[] value(); 14 | 15 | Class[] mocks() default { }; 16 | 17 | boolean cache() default true; 18 | 19 | Bind[] binds() default { }; 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot-model/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/pom.xml: -------------------------------------------------------------------------------- 1 | 4 | 4.0.0 5 | 6 | org.tillerino.osu 7 | tillerinobot-parent 8 | 0.20.0 9 | .. 10 | 11 | tillerinobot-rabbit 12 | Rabbit 13 | 14 | 15 | 16 | 17 | org.tillerino.osu 18 | tillerinobot-model 19 | 20 | 21 | 22 | 23 | com.rabbitmq 24 | amqp-client 25 | 26 | 27 | com.fasterxml.jackson.core 28 | jackson-databind 29 | 30 | 31 | com.fasterxml.jackson.module 32 | jackson-module-parameter-names 33 | 34 | 35 | com.fasterxml.jackson.datatype 36 | jackson-datatype-jdk8 37 | 38 | 39 | org.apache.commons 40 | commons-lang3 41 | 42 | 43 | org.apache.commons 44 | commons-collections4 45 | 46 | 47 | 48 | 49 | org.tillerino.osu 50 | tillerinobot-model 51 | tests 52 | test 53 | 54 | 55 | org.testcontainers 56 | rabbitmq 57 | test 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/main/java/org/tillerino/ppaddict/rabbit/RabbitMqConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import java.io.IOException; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; 7 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; 8 | import com.rabbitmq.client.Channel; 9 | import com.rabbitmq.client.Connection; 10 | import com.rabbitmq.client.ConnectionFactory; 11 | 12 | public class RabbitMqConfiguration { 13 | 14 | public static ConnectionFactory connectionFactory(String hostName, int portNumber, String virtualHost) { 15 | ConnectionFactory factory = new ConnectionFactory(); 16 | factory.setHost(hostName); 17 | factory.setPort(portNumber); 18 | factory.setVirtualHost(virtualHost); 19 | return factory; 20 | } 21 | 22 | public static ObjectMapper mapper() { 23 | return new ObjectMapper() 24 | .registerModule(new ParameterNamesModule()) 25 | .registerModule(new Jdk8Module()); 26 | } 27 | 28 | public static RemoteEventQueue externalEventQueue(Connection connection) throws IOException { 29 | Channel channel = connection.createChannel(); 30 | channel.basicQos(100); // completely uninformed value. adjust as needed. 31 | return new RemoteEventQueue(mapper(), channel, "", "irc-reader"); 32 | } 33 | 34 | public static RemoteEventQueue internalEventQueue(Connection connection) throws IOException { 35 | Channel channel = connection.createChannel(); 36 | channel.basicQos(100); // completely uninformed value. adjust as needed. 37 | return new RemoteEventQueue(mapper(), channel, "", "game-chat-events"); 38 | } 39 | 40 | public static RemoteResponseQueue responseQueue(Connection connection) throws IOException { 41 | Channel channel = connection.createChannel(); 42 | channel.basicQos(100); // completely uninformed value. adjust as needed. 43 | return new RemoteResponseQueue(mapper(), channel, "", "game-chat-responses"); 44 | } 45 | 46 | public static RemoteLiveActivity liveActivity(Connection connection) throws IOException { 47 | return new RemoteLiveActivity(mapper(), connection.createChannel(), "live-activity", ""); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/main/java/org/tillerino/ppaddict/rabbit/RemoteEventQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import org.tillerino.ppaddict.chat.GameChatEvent; 4 | import org.tillerino.ppaddict.chat.GameChatEventQueue; 5 | 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import com.rabbitmq.client.Channel; 8 | 9 | import lombok.extern.slf4j.Slf4j; 10 | 11 | /** 12 | * This is used twice: 13 | * - to get events from the IRC module to the main module (queue "irc-reader") 14 | * - to queue events internally (queue "game-chat-events") 15 | */ 16 | @Slf4j 17 | public class RemoteEventQueue extends AbstractRemoteQueue implements GameChatEventQueue { 18 | 19 | RemoteEventQueue(ObjectMapper mapper, Channel channel, String exchange, String queue) { 20 | super(mapper, channel, exchange, queue, log, GameChatEvent.class, 3); 21 | } 22 | 23 | @Override 24 | public void onEvent(GameChatEvent event) { 25 | send(event, event.getPriority()); 26 | } 27 | 28 | @Override 29 | public int size() { 30 | return super.size(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/main/java/org/tillerino/ppaddict/rabbit/RemoteResponseQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import org.tillerino.ppaddict.chat.GameChatEvent; 4 | import org.tillerino.ppaddict.chat.GameChatResponse; 5 | import org.tillerino.ppaddict.chat.GameChatResponseQueue; 6 | import org.tillerino.ppaddict.util.MdcUtils; 7 | 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | import com.rabbitmq.client.Channel; 10 | 11 | import lombok.AllArgsConstructor; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import lombok.extern.slf4j.Slf4j; 15 | 16 | @Slf4j 17 | public class RemoteResponseQueue extends AbstractRemoteQueue implements GameChatResponseQueue { 18 | 19 | RemoteResponseQueue(ObjectMapper mapper, Channel channel, String exchange, String queue) { 20 | super(mapper, channel, exchange, queue, log, EventReponsePair.class, null); 21 | } 22 | 23 | @Override 24 | public void onResponse(GameChatResponse response, GameChatEvent event) { 25 | event.getMeta().setMdc(MdcUtils.getSnapshot()); 26 | send(new EventReponsePair(event, response)); 27 | } 28 | 29 | @Override 30 | public int size() { 31 | return super.size(); 32 | } 33 | 34 | @Data 35 | @NoArgsConstructor 36 | @AllArgsConstructor 37 | public static class EventReponsePair { 38 | GameChatEvent event; 39 | GameChatResponse response; 40 | } 41 | } -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/test/java/org/tillerino/ppaddict/rabbit/RabbitMqContainerConnection.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import java.util.concurrent.ExecutorService; 4 | 5 | import org.junit.rules.ExternalResource; 6 | 7 | import com.rabbitmq.client.Connection; 8 | import com.rabbitmq.client.ConnectionFactory; 9 | 10 | import lombok.Getter; 11 | import lombok.RequiredArgsConstructor; 12 | 13 | @RequiredArgsConstructor 14 | public class RabbitMqContainerConnection extends ExternalResource { 15 | @Getter 16 | private Connection connection; 17 | 18 | private final ExecutorService sharedExecutorService; 19 | 20 | @Override 21 | protected void before() throws Throwable { 22 | ConnectionFactory connectionFactory = RabbitMqConfiguration.connectionFactory( 23 | RabbitMqContainer.getHost(), RabbitMqContainer.getAmqpPort(), RabbitMqContainer.getVirtualHost()); 24 | if (sharedExecutorService != null) { 25 | connectionFactory.setSharedExecutor(sharedExecutorService); 26 | } 27 | 28 | connection = connectionFactory.newConnection("test"); 29 | } 30 | 31 | @Override 32 | protected void after() { 33 | if (connection != null) { 34 | try { 35 | connection.close(); 36 | } catch (Exception e) { 37 | // we don't care 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/test/java/org/tillerino/ppaddict/rabbit/RemoteEventQueueTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | import org.tillerino.ppaddict.chat.GameChatEvent; 7 | import org.tillerino.ppaddict.chat.Joined; 8 | import org.tillerino.ppaddict.chat.PrivateAction; 9 | import org.tillerino.ppaddict.chat.PrivateMessage; 10 | import org.tillerino.ppaddict.chat.Sighted; 11 | import org.tillerino.ppaddict.util.MdcUtils; 12 | import org.tillerino.ppaddict.util.MdcUtils.MdcAttributes; 13 | 14 | import com.fasterxml.jackson.core.JsonProcessingException; 15 | import com.fasterxml.jackson.databind.JsonMappingException; 16 | import com.fasterxml.jackson.databind.ObjectMapper; 17 | 18 | public class RemoteEventQueueTest { 19 | private static final ObjectMapper OBJECT_MAPPER = RabbitMqConfiguration.mapper(); 20 | 21 | @Test 22 | public void testSerializations() throws Exception { 23 | roundTrip(new PrivateMessage(123, "n", 456, "m")); 24 | roundTrip(new PrivateAction(123, "n", 456, "a")); 25 | roundTrip(new Sighted(123, "n", 456)); 26 | roundTrip(new Joined(123, "n", 456)); 27 | } 28 | 29 | private void roundTrip(GameChatEvent message) throws JsonProcessingException, JsonMappingException { 30 | try (MdcAttributes with = MdcUtils.with("mdck", "mdcv")) { 31 | message.getMeta().setMdc(MdcUtils.getSnapshot()); 32 | message.getMeta().setRateLimiterBlockedTime(234); 33 | String serialized = OBJECT_MAPPER.writerFor(GameChatEvent.class).writeValueAsString(message); 34 | GameChatEvent deserialized = OBJECT_MAPPER.readValue(serialized, GameChatEvent.class); 35 | assertThat(deserialized).isEqualTo(message); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/test/java/org/tillerino/ppaddict/rabbit/RemoteResponseQueueTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rabbit; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | import org.tillerino.ppaddict.chat.GameChatResponse; 7 | 8 | import com.fasterxml.jackson.core.JsonProcessingException; 9 | import com.fasterxml.jackson.databind.JsonMappingException; 10 | import com.fasterxml.jackson.databind.ObjectMapper; 11 | 12 | public class RemoteResponseQueueTest { 13 | private static final ObjectMapper OBJECT_MAPPER = RabbitMqConfiguration.mapper(); 14 | 15 | @Test 16 | public void testSerializations() throws Exception { 17 | roundTrip(new GameChatResponse.Success("abc")); 18 | roundTrip(new GameChatResponse.Message("abc")); 19 | roundTrip(new GameChatResponse.Action("abc")); 20 | roundTrip(new GameChatResponse.Action("abc").then(new GameChatResponse.Success("def"))); 21 | roundTrip(GameChatResponse.none()); 22 | } 23 | 24 | private void roundTrip(GameChatResponse message) throws JsonProcessingException, JsonMappingException { 25 | String serialized = OBJECT_MAPPER.writerFor(GameChatResponse.class).writeValueAsString(message); 26 | GameChatResponse deserialized = OBJECT_MAPPER.readValue(serialized, GameChatResponse.class); 27 | assertThat(deserialized).isEqualTo(message); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /tillerinobot-rabbit/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tillerinobot-tests/src/test/java/org/tillerino/ppaddict/MessageQueueTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict; 2 | 3 | import static org.awaitility.Awaitility.await; 4 | import static org.hamcrest.CoreMatchers.equalTo; 5 | 6 | import java.util.concurrent.atomic.AtomicInteger; 7 | 8 | import org.junit.ClassRule; 9 | import org.junit.Test; 10 | import org.slf4j.MDC; 11 | import org.tillerino.ppaddict.chat.Sighted; 12 | import org.tillerino.ppaddict.rabbit.RabbitMqConfiguration; 13 | import org.tillerino.ppaddict.rabbit.RabbitMqContainerConnection; 14 | import org.tillerino.ppaddict.rabbit.RemoteEventQueue; 15 | 16 | public class MessageQueueTest { 17 | @ClassRule 18 | public static final RabbitMqContainerConnection rabbit = new RabbitMqContainerConnection(null); 19 | 20 | /** 21 | * The largest burst of messages is when the bot gets an overview of all online players. 22 | * We need to make sure that this works reasonably fast. 23 | */ 24 | @Test 25 | public void speed() throws Exception { 26 | RemoteEventQueue eventQueue = RabbitMqConfiguration.internalEventQueue(rabbit.getConnection()); 27 | eventQueue.setup(); 28 | 29 | AtomicInteger received = new AtomicInteger(); 30 | eventQueue.subscribe(x -> received.incrementAndGet()); 31 | 32 | for (long i = 0, event = System.currentTimeMillis(); i < 15000; i++) { 33 | MDC.put("eventId", "" + event++); 34 | MDC.put("pircbotx.id", "1"); 35 | MDC.put("pircbotx.server", "irc.ppy.sh"); 36 | MDC.put("pircbotx.port", "3306"); 37 | eventQueue.onEvent(new Sighted(event, "nickname", System.currentTimeMillis())); 38 | } 39 | 40 | await().untilAtomic(received, equalTo(15000)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tillerinobot-tests/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/Column.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Retention(RetentionPolicy.RUNTIME) 9 | @Target(ElementType.FIELD) 10 | public @interface Column { 11 | String value(); 12 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/KeyColumn.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Declares the key columns of a persisted class. 10 | * Since the order of fields is not constant at runtime, it is of no use to us 11 | * to annotate fields with a key-annotation in the case of compound keys. 12 | * Instead, we use this annotation at the class level. 13 | * 14 | *

15 | * Whenever we load data from the database without specifying a query, 16 | * a query is automatically constructed from this annotation. 17 | * The placeholders in that query are then filled from the given object array. 18 | * The order of the values in that array must match the order of the columns in this annotation. 19 | */ 20 | @Retention(RetentionPolicy.RUNTIME) 21 | @Target(ElementType.TYPE) 22 | public @interface KeyColumn { 23 | String[] value(); 24 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/Persister.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import java.sql.PreparedStatement; 4 | import java.sql.SQLException; 5 | 6 | import javax.annotation.Nonnull; 7 | 8 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 9 | import lombok.NonNull; 10 | 11 | /** 12 | * Wraps a {@link PreparedStatement} to persist Java objects to the database. 13 | * Depending on the chosen {@link Action}, this can be used to both insert and update rows. 14 | * 15 | *

16 | * This class will close the underlying {@link PreparedStatement} when closed. 17 | * It implements {@link AutoCloseable}, so it is best used in a try-with block. 18 | */ 19 | public class Persister implements AutoCloseable { 20 | public enum Action { 21 | INSERT("INSERT INTO"), 22 | INSERT_IGNORE("INSERT IGNORE INTO"), 23 | INSERT_DELAYED("INSERT DELAYED INTO"), 24 | REPLACE("REPLACE INTO"), 25 | ; 26 | private String command; 27 | 28 | Action(String command) { 29 | this.command = command; 30 | } 31 | } 32 | 33 | private final PreparedStatement statement; 34 | 35 | private final Mapping mapping; 36 | 37 | private int batched = 0; 38 | 39 | @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") 40 | Persister(Database database, Class cls, Action a) throws SQLException { 41 | mapping = Mapping.getOrCreateMapping(cls); 42 | 43 | statement = database.prepare(a.command + " `" + mapping.table() + "` (" + mapping.fields() + ") values (" + mapping.questionMarks() + ")"); 44 | } 45 | 46 | public int persist(@Nonnull @NonNull T obj) throws SQLException { 47 | return persist(obj, 0); 48 | } 49 | 50 | public int persist(@Nonnull @NonNull T obj, int batchUpTo) throws SQLException { 51 | if(batched > 1 && batched >= batchUpTo) { 52 | statement.executeBatch(); 53 | batched = 0; 54 | } 55 | mapping.set(obj, statement); 56 | if(batchUpTo <= 1) { 57 | return statement.executeUpdate(); 58 | } 59 | statement.addBatch(); 60 | batched++; 61 | if(batched >= batchUpTo) { 62 | statement.executeBatch(); 63 | batched = 0; 64 | } 65 | return 0; 66 | } 67 | 68 | @Override 69 | public void close() throws SQLException { 70 | if(batched > 0) { 71 | statement.executeBatch(); 72 | batched = 0; 73 | } 74 | statement.close(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/ResultSetIterator.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import java.sql.ResultSet; 4 | import java.sql.SQLException; 5 | import java.util.Iterator; 6 | 7 | class ResultSetIterator implements Iterator { 8 | private final ResultSet set; 9 | private final Mapping mapping; 10 | private boolean hasNext = false; 11 | private boolean consumed = true; 12 | 13 | ResultSetIterator(ResultSet set, Mapping mapping) { 14 | this.set = set; 15 | this.mapping = mapping; 16 | } 17 | 18 | @Override 19 | public boolean hasNext() { 20 | if (consumed) { 21 | try { 22 | hasNext = set.next(); 23 | consumed = false; 24 | } catch (SQLException e) { 25 | throw new RuntimeException(e); 26 | } 27 | } 28 | return hasNext; 29 | } 30 | 31 | @Override 32 | public T next() { 33 | consumed = true; 34 | try { 35 | T instance = mapping.constructor().newInstance(); 36 | 37 | mapping.get(instance, set); 38 | 39 | return instance; 40 | } catch (ReflectiveOperationException | SQLException e) { 41 | throw new RuntimeException(e); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/Table.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Retention(RetentionPolicy.RUNTIME) 9 | @Target(ElementType.TYPE) 10 | public @interface Table { 11 | String value(); 12 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/mormon/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * mORMon = minimal ORM Mysql-ONly. 3 | * 4 | *

5 | * A tiny ORM that only takes care of the basics: 6 | * mapping between Java objects and {@link ResultSet}/{@link PreparedStatement} 7 | * with some convenience stuff like streaming and batching sprinkled on top. 8 | * 9 | *

10 | * The "where" part of queries is written in plain SQL. 11 | * Everything is built for MySQL (e.g. how to escape column names, streaming, and batching). 12 | * 13 | *

14 | * Start by creating a {@link Database} instance. 15 | * For convenience, {@link DatabaseManager} implements a pool for {@link Database} instances. 16 | */ 17 | package org.tillerino.mormon; 18 | 19 | import java.sql.PreparedStatement; 20 | import java.sql.ResultSet; 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/chat/impl/ProcessorsModule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.impl; 2 | 3 | import org.tillerino.ppaddict.chat.GameChatEventConsumer; 4 | import org.tillerino.ppaddict.chat.GameChatResponseConsumer; 5 | 6 | import com.google.inject.AbstractModule; 7 | import com.google.inject.name.Names; 8 | 9 | /** 10 | * Binds {@link MessagePreprocessor} and {@link ResponsePostprocessor}. 11 | */ 12 | public class ProcessorsModule extends AbstractModule { 13 | @Override 14 | protected void configure() { 15 | bind(GameChatEventConsumer.class).annotatedWith(Names.named("messagePreprocessor")).to(MessagePreprocessor.class); 16 | bind(GameChatResponseConsumer.class).annotatedWith(Names.named("responsePostprocessor")) 17 | .to(ResponsePostprocessor.class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/chat/local/LocalGameChatMetrics.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.local; 2 | 3 | import javax.inject.Singleton; 4 | 5 | import org.mapstruct.MappingTarget; 6 | import org.mapstruct.factory.Mappers; 7 | import org.tillerino.ppaddict.chat.GameChatClientMetrics; 8 | 9 | import lombok.Data; 10 | import lombok.EqualsAndHashCode; 11 | import lombok.ToString; 12 | 13 | @Singleton 14 | @Data 15 | @ToString(callSuper = true) 16 | @EqualsAndHashCode(callSuper = true) 17 | public class LocalGameChatMetrics extends GameChatClientMetrics { 18 | private long lastSentMessage; 19 | private long lastRecommendation; 20 | private long responseQueueSize; 21 | private long eventQueueSize; 22 | 23 | @org.mapstruct.Mapper 24 | public interface Mapper { 25 | static final Mapper INSTANCE = Mappers.getMapper(Mapper.class); 26 | 27 | void loadFromBot(GameChatClientMetrics source, @MappingTarget GameChatClientMetrics target); 28 | 29 | LocalGameChatMetrics copy(LocalGameChatMetrics l); 30 | } 31 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/config/CachedDatabaseConfigServiceModule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.config; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.name.Names; 5 | 6 | public class CachedDatabaseConfigServiceModule extends AbstractModule { 7 | @Override 8 | protected void configure() { 9 | bind(ConfigService.class).to(CachingConfigService.class); 10 | bind(ConfigService.class).annotatedWith(Names.named("uncached")).to(DatabaseConfigService.class); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/config/CachingConfigService.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.config; 2 | 3 | import java.util.Optional; 4 | import java.util.concurrent.TimeUnit; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Named; 8 | import javax.inject.Singleton; 9 | 10 | import com.google.common.cache.CacheBuilder; 11 | import com.google.common.cache.CacheLoader; 12 | import com.google.common.cache.LoadingCache; 13 | import com.google.common.util.concurrent.UncheckedExecutionException; 14 | import org.apache.commons.lang3.function.Failable; 15 | 16 | @Singleton 17 | public class CachingConfigService implements ConfigService { 18 | private final LoadingCache> cache; 19 | 20 | @Inject 21 | public CachingConfigService(@Named("uncached") ConfigService delegate) { 22 | this.cache = CacheBuilder.newBuilder() 23 | .expireAfterAccess(1, TimeUnit.SECONDS) 24 | .build(CacheLoader.from(delegate::config)); 25 | } 26 | 27 | @Override 28 | public Optional config(String key) { 29 | try { 30 | return cache.getUnchecked(key); 31 | } catch (UncheckedExecutionException e) { 32 | throw Failable.rethrow(e.getCause()); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/config/DatabaseConfigService.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.config; 2 | 3 | import java.sql.SQLException; 4 | import java.util.Optional; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Singleton; 8 | 9 | import org.apache.commons.lang3.exception.ContextedRuntimeException; 10 | import org.tillerino.mormon.Database; 11 | import org.tillerino.mormon.DatabaseManager; 12 | 13 | import lombok.RequiredArgsConstructor; 14 | import tillerino.tillerinobot.data.BotConfig; 15 | 16 | @Singleton 17 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 18 | public class DatabaseConfigService implements ConfigService { 19 | private final DatabaseManager dbm; 20 | 21 | @Override 22 | public Optional config(String key) { 23 | try (Database db = dbm.getDatabase()) { 24 | return db.selectUnique(BotConfig.class)."where path = \{key}".map(BotConfig::getValue); 25 | } catch (SQLException e) { 26 | throw new ContextedRuntimeException("Unable to load config", e) 27 | .addContextValue("key", key); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/rest/AuthenticationService.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rest; 2 | 3 | import jakarta.ws.rs.ForbiddenException; 4 | import jakarta.ws.rs.GET; 5 | import jakarta.ws.rs.HeaderParam; 6 | import jakarta.ws.rs.NotFoundException; 7 | import jakarta.ws.rs.POST; 8 | import jakarta.ws.rs.Path; 9 | import jakarta.ws.rs.Produces; 10 | import jakarta.ws.rs.QueryParam; 11 | import jakarta.ws.rs.core.MediaType; 12 | 13 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 14 | 15 | import lombok.AllArgsConstructor; 16 | import lombok.Data; 17 | import lombok.NoArgsConstructor; 18 | 19 | /** 20 | * Service for API key authentication. 21 | */ 22 | public interface AuthenticationService { 23 | @Data 24 | @NoArgsConstructor 25 | @AllArgsConstructor 26 | @JsonIgnoreProperties(ignoreUnknown = true) 27 | static class Authorization { 28 | private boolean admin; 29 | } 30 | 31 | /** 32 | * Finds the authorization for a given API key. 33 | * 34 | * @throws NotFoundException if there is no such key. 35 | */ 36 | @GET 37 | @Path("/authorization") 38 | @Produces(MediaType.APPLICATION_JSON) 39 | Authorization getAuthorization(@HeaderParam("api-key") String apiKey) throws NotFoundException; 40 | 41 | /** 42 | * Creates a new API key for an osu user. 43 | * 44 | * @param adminKey admin key of the application. This key must be authorized for key creation. 45 | * @param osuUserId id of the user to create an API key for. Any existing key is revoked. 46 | * @return the new API key 47 | */ 48 | @POST 49 | @Path("/authentication") 50 | String createKey(@HeaderParam("api-key") String apiKey, @QueryParam("osu-user-id") int osuUserId) throws NotFoundException, ForbiddenException; 51 | } 52 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/rest/AuthenticationServiceImpl.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rest; 2 | 3 | import javax.inject.Inject; 4 | import javax.inject.Named; 5 | 6 | import jakarta.ws.rs.ForbiddenException; 7 | import jakarta.ws.rs.InternalServerErrorException; 8 | import jakarta.ws.rs.NotFoundException; 9 | import jakarta.ws.rs.client.ResponseProcessingException; 10 | 11 | import org.glassfish.jersey.client.JerseyClientBuilder; 12 | import org.glassfish.jersey.client.proxy.WebResourceFactory; 13 | 14 | import com.google.inject.AbstractModule; 15 | 16 | import lombok.extern.slf4j.Slf4j; 17 | 18 | /** 19 | * Implements the {@link AuthenticationService} against an internal HTTP API. 20 | */ 21 | @Slf4j 22 | public class AuthenticationServiceImpl implements AuthenticationService { 23 | private final AuthenticationService remoteService; 24 | 25 | @Inject 26 | public AuthenticationServiceImpl(@Named("ppaddict.auth.url") String authServiceBaseUrl) { 27 | remoteService = WebResourceFactory.newResource(AuthenticationService.class, 28 | JerseyClientBuilder.createClient().target(authServiceBaseUrl)); 29 | } 30 | 31 | @Override 32 | public Authorization getAuthorization(String key) { 33 | try { 34 | return remoteService.getAuthorization(key); 35 | } catch (ResponseProcessingException e) { 36 | log.error("Error getting authorization", e); 37 | throw new InternalServerErrorException(); 38 | } 39 | } 40 | 41 | @Override 42 | public String createKey(String adminKey, int osuUserId) throws NotFoundException, ForbiddenException { 43 | return remoteService.createKey(adminKey, osuUserId); 44 | } 45 | 46 | public static class RemoteAuthenticationModule extends AbstractModule { 47 | @Override 48 | protected void configure() { 49 | bind(AuthenticationService.class).to(AuthenticationServiceImpl.class); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/util/LoopingRunnable.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.util; 2 | 3 | import org.slf4j.MDC; 4 | 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | /** 8 | * Implements a runnable by calling a loop body over and over. The loop body is 9 | * implemented in {@link #loop()}. 10 | */ 11 | @Slf4j 12 | public abstract class LoopingRunnable implements Runnable { 13 | @Override 14 | public final void run() { 15 | for (;;) { 16 | try { 17 | MDC.clear(); 18 | loop(); 19 | } catch (InterruptedException e) { 20 | log.info("Interrupted. Stopping loop."); 21 | Thread.currentThread().interrupt(); 22 | return; 23 | } catch (Throwable e) { 24 | log.error("Error", e); 25 | throw e; 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Is repeatedly called from {@link #run()} until an 32 | * {@link InterruptedException} is thrown. All other exceptions are not 33 | * caught. This method is always called with an empty {@link MDC}. 34 | * 35 | * @throws InterruptedException to end the loop gracefully. 36 | */ 37 | protected abstract void loop() throws InterruptedException; 38 | } 39 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/web/data/HasLinkedOsuId.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.web.data; 2 | 3 | import javax.annotation.CheckForNull; 4 | 5 | import org.tillerino.osuApiModel.types.UserId; 6 | 7 | public interface HasLinkedOsuId { 8 | @CheckForNull 9 | @UserId Integer getLinkedOsuId(); 10 | 11 | void setLinkedOsuId(@UserId Integer osuId); 12 | } 13 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/web/data/PpaddictLinkKey.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.web.data; 2 | 3 | import org.tillerino.mormon.KeyColumn; 4 | import org.tillerino.mormon.Table; 5 | import org.tillerino.ppaddict.web.types.PpaddictId; 6 | 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Table("ppaddictlinkkeys") 12 | @KeyColumn("linkKey") 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class PpaddictLinkKey { 17 | private @PpaddictId String identifier; 18 | 19 | private String displayName; 20 | 21 | private String linkKey; 22 | 23 | private long expires; 24 | } 25 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/web/data/PpaddictUser.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.web.data; 2 | 3 | import javax.annotation.CheckForNull; 4 | 5 | import org.tillerino.mormon.KeyColumn; 6 | import org.tillerino.mormon.Table; 7 | import org.tillerino.ppaddict.web.types.PpaddictId; 8 | 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | @Table("ppaddictusers") 14 | @KeyColumn("identifier") 15 | @Data 16 | @NoArgsConstructor 17 | @AllArgsConstructor 18 | public class PpaddictUser { 19 | @PpaddictId 20 | private String identifier; 21 | 22 | @CheckForNull 23 | private String data; 24 | 25 | @PpaddictId 26 | private String forward; 27 | } 28 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/org/tillerino/ppaddict/web/types/PpaddictId.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.web.types; 2 | 3 | import javax.annotation.meta.TypeQualifier; 4 | 5 | @TypeQualifier 6 | public @interface PpaddictId { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/RateLimitingOsuApiDownloader.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import java.io.IOException; 4 | import java.net.URL; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Named; 8 | 9 | import jakarta.ws.rs.ServiceUnavailableException; 10 | 11 | import org.tillerino.osuApiModel.Downloader; 12 | import org.tillerino.ppaddict.util.MdcUtils; 13 | 14 | import com.fasterxml.jackson.databind.JsonNode; 15 | 16 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 17 | 18 | public class RateLimitingOsuApiDownloader extends Downloader { 19 | private final RateLimiter limiter; 20 | 21 | @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Injection") 22 | @Inject 23 | public RateLimitingOsuApiDownloader(@Named("osuapi.url") URL baseUrl, 24 | @Named("osuapi.key") String key, RateLimiter limiter) { 25 | super(baseUrl, key); 26 | this.limiter = limiter; 27 | } 28 | 29 | @Override 30 | public JsonNode get(String command, String... parameters) throws IOException { 31 | try { 32 | limiter.limitRate(); 33 | MdcUtils.incrementCounter(MdcUtils.MDC_EXTERNAL_API_CALLS); 34 | } catch (InterruptedException e) { 35 | Thread.currentThread().interrupt(); 36 | throw new ServiceUnavailableException(); 37 | } 38 | return super.get(command, parameters); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/UserException.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import lombok.NonNull; 4 | 5 | /** 6 | * This type of exception will be displayed to the user. 7 | * 8 | * @author Tillerino 9 | */ 10 | public class UserException extends Exception { 11 | private static final long serialVersionUID = 1L; 12 | 13 | private static final String ERROR_MESSAGE = "%s must be between %s and %s but was %s"; 14 | 15 | public UserException(String message) { 16 | super(message); 17 | } 18 | 19 | public static void validateInclusiveBetween(long floor, long ceil, long actual, @NonNull String desc) throws UserException { 20 | if (actual < floor || actual > ceil) { 21 | throw new UserException(String.format(ERROR_MESSAGE, desc, floor, ceil, actual)); 22 | } 23 | } 24 | 25 | public static void validateInclusiveBetween(double floor, double ceil, double actual, @NonNull String desc) throws UserException { 26 | if (actual < floor || actual > ceil) { 27 | throw new UserException(String.format(ERROR_MESSAGE, desc, floor, ceil, actual)); 28 | } 29 | } 30 | 31 | /** 32 | * This type of exception is extremely rare in a sense that it won't occur 33 | * again if the causing action is repeated. 34 | * 35 | * @author Tillerino 36 | */ 37 | public static class RareUserException extends UserException { 38 | private static final long serialVersionUID = 1L; 39 | 40 | public RareUserException(String message) { 41 | super(message); 42 | } 43 | 44 | } 45 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/data/BotConfig.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.data; 2 | 3 | import org.tillerino.mormon.KeyColumn; 4 | import org.tillerino.mormon.Table; 5 | 6 | import lombok.Data; 7 | 8 | /** 9 | * Configuration that can be changed while the bot runs. 10 | * This is stored as simple key-value in the database. 11 | */ 12 | @Data 13 | @Table("botconfig") 14 | @KeyColumn("path") 15 | public class BotConfig { 16 | private String path; 17 | 18 | private String value; 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/data/BotUserData.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.data; 2 | 3 | import javax.annotation.Nonnull; 4 | 5 | import org.tillerino.mormon.KeyColumn; 6 | import org.tillerino.mormon.Table; 7 | 8 | import lombok.Data; 9 | 10 | @Table("userdata") 11 | @KeyColumn("userId") 12 | @Data 13 | public class BotUserData { 14 | @Nonnull 15 | private Integer userId = 0; 16 | 17 | /* 18 | * At the time of this writing, the maximum length of data was 1440. 19 | */ 20 | private String userdata; 21 | } 22 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/data/GivenRecommendation.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.data; 2 | 3 | import org.tillerino.mormon.KeyColumn; 4 | import org.tillerino.mormon.Table; 5 | import org.tillerino.osuApiModel.types.BeatmapId; 6 | import org.tillerino.osuApiModel.types.BitwiseMods; 7 | import org.tillerino.osuApiModel.types.UserId; 8 | 9 | import lombok.Data; 10 | 11 | @Table("givenrecommendations") 12 | @KeyColumn("id") 13 | @Data 14 | public class GivenRecommendation { 15 | public GivenRecommendation(@UserId int userid, @BeatmapId int beatmapid, 16 | long date, @BitwiseMods long mods) { 17 | super(); 18 | this.userid = userid; 19 | this.beatmapid = beatmapid; 20 | this.date = date; 21 | this.mods = mods; 22 | } 23 | 24 | public GivenRecommendation() { 25 | 26 | } 27 | 28 | private Integer id; 29 | 30 | @UserId 31 | private int userid; 32 | @BeatmapId 33 | private int beatmapid; 34 | private long date; 35 | @BitwiseMods 36 | public long mods; 37 | 38 | /** 39 | * If true, this won't be taken into consideration when generating 40 | * recommendations. 41 | */ 42 | private boolean forgotten = false; 43 | /** 44 | * If true, this won't be displayed in the recommendations list in ppaddict 45 | * anymore. 46 | */ 47 | private boolean hidden = false; 48 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/data/UserNameMapping.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.data; 2 | 3 | 4 | import org.tillerino.mormon.KeyColumn; 5 | import org.tillerino.mormon.Table; 6 | import org.tillerino.osuApiModel.types.UserId; 7 | import org.tillerino.ppaddict.chat.IRCName; 8 | 9 | import lombok.Data; 10 | 11 | @Table("usernames") 12 | @KeyColumn("userName") 13 | @Data 14 | public class UserNameMapping { 15 | @IRCName 16 | private String userName; 17 | 18 | @UserId 19 | private int userid; 20 | 21 | private long resolved; 22 | 23 | private long firstresolveattempt; 24 | } 25 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/.gitignore: -------------------------------------------------------------------------------- 1 | /OsuScore.cpp 2 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/BeatmapImpl.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff; 2 | 3 | import org.tillerino.osuApiModel.types.BitwiseMods; 4 | 5 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 6 | import lombok.Builder; 7 | 8 | /** 9 | * This class implements {@link Beatmap} which is the interface that the 10 | * translated pp calculation uses to get info about the beatmap. 11 | */ 12 | @Builder 13 | // suppress warning about case-insensitive field collision, because we cannot change the names in CBeatmap 14 | @SuppressWarnings("squid:S1845") 15 | @SuppressFBWarnings("NM") 16 | public record BeatmapImpl( 17 | @BitwiseMods long modsUsed, 18 | float starDiff, 19 | float aim, 20 | float speed, 21 | float overallDifficulty, 22 | float approachRate, 23 | int maxCombo, 24 | float sliderFactor, 25 | float flashlight, 26 | float speedNoteCount, 27 | int circleCount, 28 | int spinnerCount, 29 | int sliderCount) implements Beatmap { 30 | 31 | 32 | @Override 33 | public float DifficultyAttribute(long mods, int kind) { 34 | if (Beatmap.getDiffMods(mods) != modsUsed) { 35 | throw new IllegalArgumentException("Unexpected mods " + mods + ". Was loaded with " + modsUsed); 36 | } 37 | 38 | return switch (kind) { 39 | case Beatmap.Aim -> aim; 40 | case Beatmap.Speed -> speed; 41 | case Beatmap.OD -> overallDifficulty; 42 | case Beatmap.AR -> approachRate; 43 | case Beatmap.MaxCombo -> maxCombo; 44 | case Beatmap.SliderFactor -> sliderFactor; 45 | case Beatmap.Flashlight -> flashlight; 46 | case Beatmap.SpeedNoteCount -> speedNoteCount; 47 | default -> throw new IllegalArgumentException("Unexpected kind: " + kind); 48 | }; 49 | } 50 | 51 | @Override 52 | public int NumHitCircles() { 53 | return circleCount; 54 | } 55 | 56 | @Override 57 | public int NumSpinners() { 58 | return spinnerCount; 59 | } 60 | 61 | @Override 62 | public int NumSliders() { 63 | return sliderCount; 64 | } 65 | 66 | public int getObjectCount() { 67 | return circleCount + sliderCount + spinnerCount; 68 | } 69 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/MathHelper.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff; 2 | 3 | import static java.lang.Math.max; 4 | import static java.lang.Math.min; 5 | 6 | @SuppressWarnings( "squid:S00100" ) 7 | public final class MathHelper { 8 | private MathHelper() { 9 | // utility class 10 | } 11 | 12 | static float static_cast_f32(int x) { 13 | return x; 14 | } 15 | 16 | static float static_cast_f32(double x) { 17 | return (float) x; 18 | } 19 | 20 | static int static_cast_s32(double x) { 21 | return (int) x; 22 | } 23 | 24 | static float std_pow(float b, float e) { 25 | return (float) Math.pow(b, e); 26 | } 27 | 28 | static float pow(float b, float e) { 29 | return (float) Math.pow(b, e); 30 | } 31 | 32 | static float Clamp(float x, float min, float max) { 33 | return max(min, min(max, x)); 34 | } 35 | 36 | static float log10(float x) { 37 | return (float) Math.log10(x); 38 | } 39 | 40 | static float std_max(float x, float y) { 41 | return Math.max(x, y); 42 | } 43 | 44 | static float std_min(float x, float y) { 45 | return Math.min(x, y); 46 | } 47 | 48 | static int std_max(int x, int y) { 49 | return Math.max(x, y); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/PercentageEstimates.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff; 2 | 3 | import org.tillerino.osuApiModel.types.BitwiseMods; 4 | 5 | import tillerino.tillerinobot.UserException; 6 | 7 | /** 8 | * Provides pp values for a beatmap played with a specified mod. 9 | * "PercentageEstimates" refers to the fact that the accuracy is specified and 10 | * that the value is estimated although the value is usually quite accurate. 11 | */ 12 | public interface PercentageEstimates { 13 | public double getPP(double acc); 14 | 15 | public double getPP(double acc, int combo, int misses) throws UserException; 16 | 17 | public double getPP(int x100, int x50, int combo, int misses); 18 | 19 | @BitwiseMods 20 | long getMods(); 21 | 22 | double getStarDiff(); 23 | 24 | double getApproachRate(); 25 | 26 | double getOverallDifficulty(); 27 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/PercentageEstimatesImpl.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff; 2 | 3 | import org.tillerino.osuApiModel.types.BitwiseMods; 4 | 5 | import lombok.Getter; 6 | import tillerino.tillerinobot.UserException; 7 | 8 | public class PercentageEstimatesImpl implements PercentageEstimates { 9 | private final BeatmapImpl beatmap; 10 | 11 | @Getter 12 | private final @BitwiseMods long mods; 13 | 14 | public PercentageEstimatesImpl(BeatmapImpl beatmap, @BitwiseMods long mods) { 15 | this.beatmap = beatmap; 16 | this.mods = mods; 17 | } 18 | 19 | @Override 20 | public double getPP(double acc) { 21 | AccuracyDistribution dist; 22 | try { 23 | dist = AccuracyDistribution.model(beatmap.getObjectCount(), 0, acc); 24 | } catch (UserException e) { 25 | // this should have been allowed to get here. 26 | throw new RuntimeException(e); 27 | } 28 | 29 | OsuScore score = new OsuScore((int) beatmap.DifficultyAttribute(getMods(), Beatmap.MaxCombo), 30 | dist.getX300(), dist.getX100(), dist.getX50(), dist.getMiss(), getMods()); 31 | 32 | return score.getPP(beatmap); 33 | } 34 | 35 | @Override 36 | public double getPP(double acc, int combo, int misses) throws UserException { 37 | AccuracyDistribution dist = AccuracyDistribution.model(beatmap.getObjectCount(), misses, acc); 38 | 39 | OsuScore score = new OsuScore(combo, dist.getX300(), dist.getX100(), dist.getX50(), dist.getMiss(), 40 | getMods()); 41 | 42 | return score.getPP(beatmap); 43 | } 44 | 45 | @Override 46 | public double getPP(int x100, int x50, int combo, int misses) { 47 | int x300 = beatmap.getObjectCount() - x50 - x100; 48 | OsuScore score = new OsuScore(combo, x300, x100, x50, misses, getMods()); 49 | 50 | return score.getPP(beatmap); 51 | } 52 | 53 | @Override 54 | public double getStarDiff() { 55 | return beatmap.starDiff(); 56 | } 57 | 58 | @Override 59 | public double getApproachRate() { 60 | return beatmap.approachRate(); 61 | } 62 | 63 | @Override 64 | public double getOverallDifficulty() { 65 | return beatmap.overallDifficulty(); 66 | } 67 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/sandoku/SanDokuError.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff.sandoku; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 7 | 8 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 9 | 10 | @SuppressFBWarnings(value = { "EI_EXPOSE_REP", "EI_EXPOSE_REP2" }, 11 | justification = "Yes, but also this is wayyy too annoying to do correctly. It's a DTO, relax.") 12 | @JsonIgnoreProperties(ignoreUnknown = true) 13 | public record SanDokuError( 14 | String title, 15 | Map> errors 16 | ) { 17 | } 18 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/diff/sandoku/SanDokuResponse.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.diff.sandoku; 2 | 3 | import org.tillerino.osuApiModel.types.BitwiseMods; 4 | import org.tillerino.osuApiModel.types.GameMode; 5 | 6 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 7 | 8 | import lombok.Builder; 9 | import tillerino.tillerinobot.diff.BeatmapImpl; 10 | 11 | @Builder(toBuilder = true) 12 | @JsonIgnoreProperties(ignoreUnknown = true) 13 | public record SanDokuResponse( 14 | @GameMode int beatmapGameMode, 15 | @GameMode int gameModeUsed, 16 | @BitwiseMods long modsUsed, 17 | SanDokuDiffCalcResult diffCalcResult) { 18 | 19 | @Builder(toBuilder = true) 20 | @JsonIgnoreProperties(ignoreUnknown = true) 21 | public static record SanDokuDiffCalcResult( 22 | // only declare fields which are needed for std calc 23 | int maxCombo, 24 | double starRating, 25 | double aim, 26 | double speed, 27 | double overallDifficulty, 28 | double approachRate, 29 | double flashlight, 30 | double sliderFactor, 31 | double speedNoteCount, 32 | int hitCircleCount, 33 | int sliderCount, 34 | int spinnerCount) { 35 | } 36 | 37 | public BeatmapImpl toBeatmap() { 38 | return BeatmapImpl.builder() 39 | .modsUsed(modsUsed) 40 | .starDiff((float) diffCalcResult.starRating) 41 | .aim((float) diffCalcResult.aim) 42 | .speed((float) diffCalcResult.speed) 43 | .overallDifficulty((float) diffCalcResult.overallDifficulty) 44 | .approachRate((float) diffCalcResult.approachRate) 45 | .maxCombo(diffCalcResult.maxCombo) 46 | .sliderFactor((float) diffCalcResult.sliderFactor) 47 | .flashlight((float) diffCalcResult.flashlight) 48 | .speedNoteCount((float) diffCalcResult.speedNoteCount) 49 | .circleCount(diffCalcResult.hitCircleCount) 50 | .spinnerCount(diffCalcResult.spinnerCount) 51 | .sliderCount(diffCalcResult.sliderCount) 52 | .build(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/ComplaintHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import static org.apache.commons.lang3.StringUtils.getLevenshteinDistance; 4 | 5 | import java.io.IOException; 6 | import java.sql.SQLException; 7 | import java.util.Arrays; 8 | 9 | import javax.inject.Inject; 10 | 11 | import org.apache.commons.lang3.ArrayUtils; 12 | import org.tillerino.osuApiModel.OsuApiUser; 13 | import org.tillerino.ppaddict.chat.GameChatResponse; 14 | import org.tillerino.ppaddict.chat.GameChatResponse.Success; 15 | 16 | import lombok.RequiredArgsConstructor; 17 | import lombok.extern.slf4j.Slf4j; 18 | import tillerino.tillerinobot.CommandHandler; 19 | import tillerino.tillerinobot.UserDataManager.UserData; 20 | import tillerino.tillerinobot.UserException; 21 | import tillerino.tillerinobot.lang.Language; 22 | import tillerino.tillerinobot.recommendations.Recommendation; 23 | import tillerino.tillerinobot.recommendations.RecommendationsManager; 24 | 25 | @Slf4j 26 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 27 | public class ComplaintHandler implements CommandHandler { 28 | private final RecommendationsManager manager; 29 | 30 | @Override 31 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language lang) 32 | throws UserException, IOException, SQLException, 33 | InterruptedException { 34 | if(getLevenshteinDistance(command.toLowerCase().substring(0, Math.min("complain".length(), command.length())), "complain") <= 2) { 35 | Recommendation lastRecommendation = manager 36 | .getLastRecommendation(apiUser.getUserId()); 37 | if(lastRecommendation != null && lastRecommendation.beatmap != null) { 38 | log.debug("COMPLAINT: " + lastRecommendation.beatmap.getBeatmap().getBeatmapId() + " mods: " + lastRecommendation.bareRecommendation.mods() + ". Recommendation source: " + Arrays.asList(ArrayUtils.toObject(lastRecommendation.bareRecommendation.causes()))); 39 | return new Success(lang.complaint()); 40 | } 41 | } 42 | return null; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/FixIDHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | 6 | import javax.inject.Inject; 7 | 8 | import org.tillerino.osuApiModel.OsuApiUser; 9 | import org.tillerino.osuApiModel.types.UserId; 10 | import org.tillerino.ppaddict.chat.GameChatResponse; 11 | import org.tillerino.ppaddict.chat.GameChatResponse.Message; 12 | 13 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 14 | import lombok.RequiredArgsConstructor; 15 | import tillerino.tillerinobot.CommandHandler; 16 | import tillerino.tillerinobot.IrcNameResolver; 17 | import tillerino.tillerinobot.UserDataManager.UserData; 18 | import tillerino.tillerinobot.UserException; 19 | import tillerino.tillerinobot.lang.Language; 20 | 21 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 22 | public class FixIDHandler implements CommandHandler { 23 | private static final String COMMAND = "!fixid"; 24 | private final IrcNameResolver resolver; 25 | 26 | @Override 27 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language lang) 28 | throws UserException, IOException, SQLException { 29 | 30 | if (!command.toLowerCase().startsWith(COMMAND)) { 31 | return null; 32 | } 33 | 34 | String idStr = command.substring(COMMAND.length()).trim(); 35 | int id = parseId(idStr); 36 | 37 | OsuApiUser user = resolver.resolveManually(id); 38 | if(user == null) { 39 | throw new UserException("That user-id does not exist :("); 40 | } else { 41 | return new Message("User '" + user.getUserName() + "' is now resolvable to user-id " + user.getUserId()); 42 | } 43 | } 44 | 45 | @SuppressFBWarnings("TQ") 46 | @UserId 47 | private int parseId(String idStr) throws UserException { 48 | try { 49 | return Integer.parseInt(idStr); 50 | } catch (NumberFormatException e) { 51 | throw new UserException("Invalid user-id :("); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/HelpHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import static org.apache.commons.lang3.StringUtils.getLevenshteinDistance; 4 | 5 | import java.io.IOException; 6 | import java.sql.SQLException; 7 | 8 | import org.tillerino.osuApiModel.OsuApiUser; 9 | import org.tillerino.ppaddict.chat.GameChatResponse; 10 | import org.tillerino.ppaddict.chat.GameChatResponse.Success; 11 | 12 | import tillerino.tillerinobot.CommandHandler; 13 | import tillerino.tillerinobot.UserDataManager.UserData; 14 | import tillerino.tillerinobot.UserException; 15 | import tillerino.tillerinobot.lang.Language; 16 | 17 | public class HelpHandler implements CommandHandler { 18 | 19 | @Override 20 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language lang) 21 | throws UserException, IOException, SQLException, 22 | InterruptedException { 23 | if (getLevenshteinDistance(command.toLowerCase(), "help") <= 1) { 24 | return new Success(lang.help()); 25 | } else if (getLevenshteinDistance(command.toLowerCase(), "faq") <= 1) { 26 | return new Success(lang.faq()); 27 | } 28 | return null; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/LinkPpaddictHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import java.io.IOException; 4 | import java.math.BigInteger; 5 | import java.security.SecureRandom; 6 | import java.sql.SQLException; 7 | import java.util.regex.Pattern; 8 | 9 | import org.apache.commons.lang3.StringUtils; 10 | import org.tillerino.osuApiModel.OsuApiUser; 11 | import org.tillerino.ppaddict.chat.GameChatResponse; 12 | import org.tillerino.ppaddict.chat.GameChatResponse.Success; 13 | 14 | import lombok.RequiredArgsConstructor; 15 | import tillerino.tillerinobot.BotBackend; 16 | import tillerino.tillerinobot.CommandHandler; 17 | import tillerino.tillerinobot.UserDataManager.UserData; 18 | import tillerino.tillerinobot.UserException; 19 | import tillerino.tillerinobot.lang.Language; 20 | 21 | @RequiredArgsConstructor 22 | public class LinkPpaddictHandler implements CommandHandler { 23 | public static final Pattern TOKEN_PATTERN = Pattern.compile("[0-9a-z]{32}"); 24 | private static final SecureRandom random = new SecureRandom(); 25 | 26 | private final BotBackend backend; 27 | 28 | @Override 29 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language lang) 30 | throws UserException, IOException, SQLException { 31 | if(!TOKEN_PATTERN.matcher(command).matches()) { 32 | return null; 33 | } 34 | String linkedName = backend.tryLinkToPatreon(command, apiUser); 35 | if(linkedName == null) 36 | throw new UserException("nothing happened."); 37 | else 38 | return new Success("linked to " + linkedName); 39 | } 40 | 41 | public static synchronized String newKey() { 42 | return StringUtils.leftPad(new BigInteger(165, LinkPpaddictHandler.random).toString(36), 32, '0'); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/RecentHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | import java.util.List; 6 | 7 | import org.tillerino.osuApiModel.OsuApiScore; 8 | import org.tillerino.osuApiModel.OsuApiUser; 9 | import org.tillerino.ppaddict.chat.GameChatResponse; 10 | import org.tillerino.ppaddict.chat.GameChatResponse.Success; 11 | 12 | import lombok.Value; 13 | import tillerino.tillerinobot.BeatmapMeta; 14 | import tillerino.tillerinobot.BotBackend; 15 | import tillerino.tillerinobot.CommandHandler; 16 | import tillerino.tillerinobot.UserDataManager.UserData; 17 | import tillerino.tillerinobot.UserException; 18 | import tillerino.tillerinobot.lang.Language; 19 | 20 | @Value 21 | public class RecentHandler implements CommandHandler { 22 | BotBackend backend; 23 | 24 | @Override 25 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language language) 26 | throws UserException, IOException, SQLException, InterruptedException { 27 | if (!command.equalsIgnoreCase("now")) { 28 | return null; 29 | } 30 | 31 | if(userData.getHearts() <= 0) { 32 | return null; 33 | } 34 | 35 | List recentPlays = backend.getRecentPlays(apiUser.getUserId()); 36 | if (recentPlays.isEmpty()) { 37 | throw new UserException(language.noRecentPlays()); 38 | } 39 | 40 | OsuApiScore score = recentPlays.get(0); 41 | 42 | final BeatmapMeta estimates = backend.loadBeatmap(score.getBeatmapId(), score.getMods(), language); 43 | 44 | if (estimates == null) { 45 | throw new UserException(language.unknownBeatmap()); 46 | } 47 | if (estimates.getMods() != score.getMods()) { 48 | throw new UserException(language.noInformationForMods()); 49 | } 50 | 51 | userData.setLastSongInfo(estimates.getBeatmapWithMods()); 52 | return new Success(estimates.formInfoMessage(false, true, null, 53 | userData.getHearts(), score.getAccuracy(), null, null)); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/ResetHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | 6 | import javax.inject.Inject; 7 | 8 | import org.tillerino.osuApiModel.OsuApiUser; 9 | import org.tillerino.ppaddict.chat.GameChatResponse; 10 | 11 | import tillerino.tillerinobot.CommandHandler; 12 | import tillerino.tillerinobot.UserDataManager.UserData; 13 | import tillerino.tillerinobot.UserException; 14 | import tillerino.tillerinobot.lang.Language; 15 | import tillerino.tillerinobot.recommendations.RecommendationsManager; 16 | 17 | public class ResetHandler implements CommandHandler { 18 | RecommendationsManager backend; 19 | 20 | @Inject 21 | public ResetHandler(RecommendationsManager backend) { 22 | super(); 23 | this.backend = backend; 24 | } 25 | 26 | @Override 27 | public GameChatResponse handle(String command, OsuApiUser apiUser, UserData userData, Language lang) 28 | throws UserException, IOException, SQLException { 29 | if (!command.equalsIgnoreCase("reset")) 30 | return null; 31 | 32 | backend.forgetRecommendations(apiUser.getUserId()); 33 | 34 | return GameChatResponse.none(); 35 | } 36 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/BooleanOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import org.tillerino.osuApiModel.OsuApiUser; 4 | import org.tillerino.ppaddict.chat.GameChatResponse; 5 | 6 | import tillerino.tillerinobot.UserDataManager.UserData; 7 | import tillerino.tillerinobot.UserException; 8 | import tillerino.tillerinobot.lang.Language; 9 | 10 | import javax.annotation.Nonnull; 11 | import javax.annotation.Nullable; 12 | 13 | public abstract class BooleanOptionHandler extends OptionHandler { 14 | protected BooleanOptionHandler(@Nonnull String description, @Nonnull String optionName, @Nullable String shortOptionName, int minHearts) { 15 | super(description, optionName, shortOptionName, minHearts); 16 | } 17 | 18 | @Override 19 | protected void handleSet(String value, UserData userData, OsuApiUser apiUser, Language lang) throws UserException { 20 | handleSetBoolean(parseBoolean(value, lang), userData); 21 | } 22 | 23 | @Nonnull 24 | @Override 25 | protected String getCurrentValue(UserData userData) { 26 | return getCurrentBooleanValue(userData) ? "ON" : "OFF"; 27 | } 28 | 29 | protected abstract void handleSetBoolean(boolean value, UserData userData); 30 | 31 | protected abstract boolean getCurrentBooleanValue(UserData userData); 32 | 33 | public static boolean parseBoolean(final @Nonnull String original, Language lang) throws UserException { 34 | String s = original.toLowerCase(); 35 | if (s.equals("on") || s.equals("true") || s.equals("yes") || s.equals("1")) { 36 | return true; 37 | } 38 | if (s.equals("off") || s.equals("false") || s.equals("no") || s.equals("0")) { 39 | return false; 40 | } 41 | throw new UserException(lang.invalidChoice(original, "on|true|yes|1|off|false|no|0")); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/DefaultOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.tillerino.osuApiModel.OsuApiUser; 5 | import tillerino.tillerinobot.UserDataManager; 6 | import tillerino.tillerinobot.UserException; 7 | import tillerino.tillerinobot.lang.Language; 8 | import tillerino.tillerinobot.recommendations.RecommendationRequestParser; 9 | 10 | import javax.annotation.Nonnull; 11 | import java.io.IOException; 12 | import java.sql.SQLException; 13 | 14 | public class DefaultOptionHandler extends OptionHandler { 15 | private final RecommendationRequestParser requestParser; 16 | 17 | public DefaultOptionHandler(RecommendationRequestParser requestParser) { 18 | super("Default recommendation settings", "default", null, 0); 19 | this.requestParser = requestParser; 20 | } 21 | 22 | @Override 23 | protected void handleSet(String value, UserDataManager.UserData userData, OsuApiUser apiUser, Language lang) throws UserException, SQLException, IOException { 24 | if (value.isEmpty()) { 25 | userData.setDefaultRecommendationOptions(null); 26 | } else { 27 | requestParser.parseSamplerSettings(apiUser, value, lang); 28 | userData.setDefaultRecommendationOptions(value); 29 | } 30 | } 31 | 32 | @Nonnull 33 | @Override 34 | protected String getCurrentValue(UserDataManager.UserData userData) { 35 | return StringUtils.defaultString(userData.getDefaultRecommendationOptions(), "-"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/LangOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import org.tillerino.osuApiModel.OsuApiUser; 4 | import org.tillerino.ppaddict.chat.GameChatResponse; 5 | import tillerino.tillerinobot.UserDataManager.UserData; 6 | import tillerino.tillerinobot.UserException; 7 | import tillerino.tillerinobot.handlers.OptionsHandler; 8 | import tillerino.tillerinobot.lang.Language; 9 | import tillerino.tillerinobot.lang.LanguageIdentifier; 10 | 11 | import javax.annotation.Nonnull; 12 | import java.util.stream.Stream; 13 | 14 | import static java.util.stream.Collectors.joining; 15 | 16 | public class LangOptionHandler extends OptionHandler { 17 | public LangOptionHandler() { 18 | super("Language", "language", "lang", 0); 19 | } 20 | 21 | @Override 22 | protected void handleSet(String value, UserData userData, OsuApiUser apiUser, Language lang) throws UserException { 23 | LanguageIdentifier ident; 24 | try { 25 | ident = OptionsHandler.find(LanguageIdentifier.values(), i -> i.token, value); 26 | } catch (IllegalArgumentException e) { 27 | String choices = Stream.of(LanguageIdentifier.values()) 28 | .map(i -> i.token) 29 | .sorted() 30 | .collect(joining(", ")); 31 | throw new UserException(lang.invalidChoice(value, choices)); 32 | } 33 | 34 | userData.setLanguage(ident); 35 | } 36 | 37 | @Override 38 | protected GameChatResponse responseAfterSet(UserData userData, OsuApiUser apiUser) { 39 | return userData.usingLanguage(lang -> lang.optionalCommentOnLanguage(apiUser)); 40 | } 41 | 42 | @Override 43 | @Nonnull 44 | protected String getCurrentValue(UserData userData) { 45 | return userData.getLanguageIdentifier().token; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/MapMetaDataOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import tillerino.tillerinobot.UserDataManager.UserData; 4 | 5 | public class MapMetaDataOptionHandler extends BooleanOptionHandler{ 6 | public MapMetaDataOptionHandler() { 7 | super("Show map metadata on recommendations", "r-metadata", null, 0); 8 | } 9 | 10 | @Override 11 | protected void handleSetBoolean(boolean value, UserData userData) { 12 | userData.setShowMapMetaDataOnRecommendation(value); 13 | } 14 | 15 | @Override 16 | protected boolean getCurrentBooleanValue(UserData userData) { 17 | return userData.isShowMapMetaDataOnRecommendation(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/OsutrackWelcomeOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import tillerino.tillerinobot.UserDataManager.UserData; 4 | 5 | public class OsutrackWelcomeOptionHandler extends BooleanOptionHandler { 6 | public OsutrackWelcomeOptionHandler() { 7 | super("osu!track on welcome", "osutrack-welcome", null, 1); 8 | } 9 | 10 | @Override 11 | protected void handleSetBoolean(boolean value, UserData userData) { 12 | userData.setOsuTrackWelcomeEnabled(value); 13 | } 14 | 15 | @Override 16 | protected boolean getCurrentBooleanValue(UserData userData) { 17 | return userData.isOsuTrackWelcomeEnabled(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/handlers/options/WelcomeOptionHandler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers.options; 2 | 3 | import tillerino.tillerinobot.UserDataManager.UserData; 4 | 5 | public class WelcomeOptionHandler extends BooleanOptionHandler{ 6 | public WelcomeOptionHandler() { 7 | super("Welcome Message", "welcome", null, 1); 8 | } 9 | 10 | @Override 11 | protected void handleSetBoolean(boolean value, UserData userData) { 12 | userData.setShowWelcomeMessage(value); 13 | } 14 | 15 | @Override 16 | protected boolean getCurrentBooleanValue(UserData userData) { 17 | return userData.isShowWelcomeMessage(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/lang/AbstractMutableLanguage.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.lang; 2 | 3 | import tillerino.tillerinobot.util.IsMutable; 4 | 5 | public abstract class AbstractMutableLanguage implements Language, IsMutable { 6 | private transient boolean modified; 7 | 8 | @Override 9 | public boolean isModified() { 10 | return modified; 11 | } 12 | 13 | @Override 14 | public void clearModified() { 15 | modified = false; 16 | } 17 | 18 | /** 19 | * After this method has been called, calls to {@link #isModified()} will return 20 | * true until {@link #clearModified()} is called. 21 | */ 22 | protected void registerModification() { 23 | modified = true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/lang/LanguageIdentifier.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.lang; 2 | 3 | // suppress warning about enum constant naming pattern 4 | @SuppressWarnings("squid:S00115") 5 | public enum LanguageIdentifier { 6 | Default(Default.class), 7 | English(Default.class), 8 | Tsundere(TsundereEnglish.class), 9 | TsundereGerman(TsundereGerman.class), 10 | Italiano(Italiano.class), 11 | Français(Francais.class), 12 | Polski(Polish.class), 13 | Nederlands(Nederlands.class), 14 | עברית(Hebrew.class), 15 | Farsi(Farsi.class), 16 | Português_BR(Portuguese.class), 17 | Deutsch(Deutsch.class), 18 | Čeština(Czech.class), 19 | Magyar(Hungarian.class), 20 | 한국어(Korean.class), 21 | Dansk(Dansk.class), 22 | Türkçe(Turkish.class), 23 | 日本語(Japanese.class), 24 | Español(Spanish.class), 25 | Ελληνικά(Greek.class), 26 | Русский(Russian.class), 27 | Lietuvių(Lithuanian.class), 28 | Português_PT(PortuguesePortugal.class), 29 | Svenska(Svenska.class), 30 | Romana(Romana.class), 31 | 繁體中文(ChineseTraditional.class), 32 | български(Bulgarian.class), 33 | Norsk(Norwegian.class), 34 | Indonesian(Indonesian.class), 35 | 简体中文(ChineseSimple.class), 36 | Català(Catalan.class), 37 | Slovenščina(Slovenian.class), 38 | Schwiizerdütsch(Swissgerman.class), 39 | Slovenčina(Slovak.class), 40 | Vietnamese(Vietnamese.class, "Tiếng Việt"), 41 | ; // please end identifier entries with a comma and leave this semicolon here 42 | 43 | public final Class cls; 44 | 45 | public final String token; 46 | 47 | private LanguageIdentifier(Class cls) { 48 | this.cls = cls; 49 | this.token = name(); 50 | } 51 | 52 | private LanguageIdentifier(Class cls, String token) { 53 | this.cls = cls; 54 | this.token = token; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/lang/StringShuffler.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.lang; 2 | 3 | import java.util.Arrays; 4 | import java.util.Collections; 5 | import java.util.Random; 6 | 7 | import com.fasterxml.jackson.annotation.JsonCreator; 8 | 9 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 10 | import lombok.AllArgsConstructor; 11 | import lombok.Getter; 12 | 13 | /* 14 | * 20 bytes 15 | */ 16 | /** 17 | * Device to permanently shuffle an array of Strings while the size of this 18 | * object is independent of the number of strings. This is accomplished by 19 | * saving the seed for shuffling the array and shuffling it every time a String 20 | * is requested instead of shuffling it once. 21 | */ 22 | @AllArgsConstructor(onConstructor = @__(@JsonCreator)) 23 | @Getter 24 | public class StringShuffler { 25 | /* 26 | * 8 bytes; 27 | */ 28 | private final long seed; 29 | 30 | @SuppressFBWarnings(value = "DMI_RANDOM_USED_ONLY_ONCE", justification = "false positive") 31 | public StringShuffler(Random globalRandom) { 32 | seed = globalRandom.nextLong(); 33 | } 34 | 35 | /* 36 | * 4 bytes 37 | */ 38 | private int index = 0; 39 | 40 | public String get(String... strings) { 41 | Random random = new Random(seed); 42 | 43 | String[] forShuffling = strings.clone(); 44 | 45 | Collections.shuffle(Arrays.asList(forShuffling), random); 46 | 47 | return forShuffling[(index++) % forShuffling.length]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/osutrack/Highscore.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.osutrack; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.ToString; 6 | import org.tillerino.osuApiModel.OsuApiScore; 7 | 8 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 9 | 10 | @JsonIgnoreProperties(ignoreUnknown = true) 11 | @Data 12 | @ToString(callSuper = true) 13 | @EqualsAndHashCode(callSuper = true) 14 | public class Highscore extends OsuApiScore { 15 | private int ranking; 16 | } 17 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/osutrack/OsutrackApi.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.osutrack; 2 | 3 | import jakarta.ws.rs.POST; 4 | import jakarta.ws.rs.Path; 5 | import jakarta.ws.rs.Produces; 6 | import jakarta.ws.rs.QueryParam; 7 | import jakarta.ws.rs.core.MediaType; 8 | 9 | public interface OsutrackApi { 10 | @POST 11 | @Path("update") 12 | @Produces(MediaType.APPLICATION_JSON) 13 | UpdateResult getUpdate(@QueryParam("user") int osuUserId); 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/osutrack/OsutrackDownloader.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.osutrack; 2 | 3 | import jakarta.ws.rs.client.ClientBuilder; 4 | import org.glassfish.jersey.client.proxy.WebResourceFactory; 5 | 6 | public class OsutrackDownloader { 7 | // __________________________________________________________________________________________ 8 | // /\ \ 9 | // \_| If you're reading this and thinking, hey, I wanna use that osutrack API as well: | 10 | // | --> PLEASE ask FIRST for permission from ameo (https://ameobea.me/osutrack/) <-- | 11 | // | _____________________________________________________________________________________|_ 12 | // \_/_______________________________________________________________________________________/ 13 | private static final String OSUTRACK_API_BASE = "https://osutrack-api.ameo.dev/"; 14 | private static final OsutrackApi OSUTRACK_API = WebResourceFactory.newResource(OsutrackApi.class, ClientBuilder 15 | .newClient() 16 | .target(OSUTRACK_API_BASE) 17 | .queryParam("mode", 0)); 18 | 19 | protected void completeUpdateObject(UpdateResult updateResult) { 20 | for (Highscore highscore : updateResult.getNewHighscores()) { 21 | highscore.setMode(0); 22 | } 23 | } 24 | 25 | public UpdateResult getUpdate(int osuUserId) { 26 | UpdateResult updateResult = OSUTRACK_API.getUpdate(osuUserId); 27 | completeUpdateObject(updateResult); 28 | return updateResult; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/osutrack/UpdateResult.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.osutrack; 2 | 3 | import java.util.List; 4 | 5 | import org.tillerino.osuApiModel.types.GameMode; 6 | 7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | 10 | import lombok.Data; 11 | 12 | @JsonIgnoreProperties(ignoreUnknown = true) 13 | @Data 14 | public class UpdateResult { 15 | private String username; 16 | 17 | @GameMode 18 | private int mode; 19 | 20 | @JsonProperty("playcount") 21 | private int playCount; 22 | 23 | @JsonProperty("pp_rank") 24 | private int ppRank; 25 | 26 | @JsonProperty("pp_raw") 27 | private float ppRaw; 28 | 29 | private float accuracy; 30 | 31 | @JsonProperty("total_score") 32 | private long totalScore; 33 | 34 | @JsonProperty("ranked_score") 35 | private long rankedScore; 36 | 37 | private int count300; 38 | 39 | private int count100; 40 | 41 | private int count50; 42 | 43 | private float level; 44 | 45 | @JsonProperty("count_rank_a") 46 | private int countRankA; 47 | 48 | @JsonProperty("count_rank_s") 49 | private int countRankS; 50 | 51 | @JsonProperty("count_rank_ss") 52 | private int countRankSS; 53 | 54 | @JsonProperty("levelup") 55 | private boolean levelUp; 56 | 57 | private boolean first; 58 | 59 | private boolean exists; 60 | 61 | @JsonProperty("newhs") 62 | private List newHighscores; 63 | } 64 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/ApproachRate.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import org.tillerino.osuApiModel.OsuApiBeatmap; 4 | 5 | import lombok.EqualsAndHashCode; 6 | 7 | @EqualsAndHashCode 8 | public class ApproachRate implements NumericBeatmapProperty { 9 | @Override 10 | public String getName() { 11 | return "AR"; 12 | } 13 | 14 | @Override 15 | public double getValue(OsuApiBeatmap beatmap, long mods) { 16 | return beatmap.getApproachRate(mods); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/BeatsPerMinute.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.EqualsAndHashCode; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | 7 | @EqualsAndHashCode 8 | public class BeatsPerMinute implements NumericBeatmapProperty { 9 | 10 | @Override 11 | public String getName() { 12 | return "BPM"; 13 | } 14 | 15 | @Override 16 | public double getValue(OsuApiBeatmap beatmap, long mods) { 17 | return beatmap.getBpm(mods); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/CircleSize.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.EqualsAndHashCode; 4 | import org.tillerino.osuApiModel.OsuApiBeatmap; 5 | 6 | @EqualsAndHashCode 7 | public class CircleSize implements NumericBeatmapProperty { 8 | 9 | @Override 10 | public String getName() { 11 | return "CS"; 12 | } 13 | 14 | @Override 15 | public double getValue(OsuApiBeatmap beatmap, long mods) { 16 | return beatmap.getCircleSize(mods); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/ExcludeMod.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.Value; 4 | 5 | import java.util.Optional; 6 | 7 | import org.tillerino.osuApiModel.Mods; 8 | import org.tillerino.osuApiModel.OsuApiBeatmap; 9 | 10 | import tillerino.tillerinobot.UserException; 11 | import tillerino.tillerinobot.lang.Language; 12 | import tillerino.tillerinobot.predicates.PredicateParser.PredicateBuilder; 13 | import tillerino.tillerinobot.recommendations.BareRecommendation; 14 | import tillerino.tillerinobot.recommendations.RecommendationRequest; 15 | 16 | @Value 17 | public class ExcludeMod implements RecommendationPredicate { 18 | Mods mod; 19 | 20 | @Override 21 | public boolean test(BareRecommendation r, OsuApiBeatmap beatmap) { 22 | return !mod.is(r.mods()); 23 | } 24 | 25 | @Override 26 | public boolean contradicts(RecommendationPredicate otherPredicate) { 27 | return false; 28 | } 29 | 30 | @Override 31 | public String getOriginalArgument() { 32 | return "-" + mod.getShortName(); 33 | } 34 | 35 | public static class Builder implements PredicateBuilder { 36 | @Override 37 | public ExcludeMod build(String argument, Language lang) throws UserException { 38 | if (!argument.startsWith("-")) { 39 | return null; 40 | } 41 | try { 42 | Mods mod = Mods.fromShortName(argument.substring(1).toUpperCase()); 43 | if (mod == null) { 44 | return null; 45 | } 46 | return new ExcludeMod(mod); 47 | } catch (IllegalArgumentException e) { 48 | return null; 49 | } 50 | } 51 | 52 | } 53 | 54 | @Override 55 | public Optional findNonPredicateContradiction(RecommendationRequest request) { 56 | if (mod.is(request.requestedMods())) { 57 | return Optional.of(String.format("%s -%s", mod.getShortName(), mod.getShortName())); 58 | } 59 | return Optional.empty(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/MapLength.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.EqualsAndHashCode; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | 7 | @EqualsAndHashCode 8 | public class MapLength implements NumericBeatmapProperty { 9 | 10 | @Override 11 | public String getName() { 12 | return "LEN"; 13 | } 14 | 15 | @Override 16 | public double getValue(OsuApiBeatmap beatmap, long mods) { 17 | return beatmap.getTotalLength(mods); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/NumericBeatmapProperty.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import java.util.Optional; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | import org.tillerino.osuApiModel.types.BitwiseMods; 7 | 8 | import tillerino.tillerinobot.recommendations.RecommendationRequest; 9 | 10 | public interface NumericBeatmapProperty { 11 | String getName(); 12 | 13 | double getValue(OsuApiBeatmap beatmap, @BitwiseMods long mods); 14 | 15 | /** 16 | * see 17 | * {@link RecommendationPredicate#findNonPredicateContradiction(RecommendationRequest)} 18 | * 19 | * @param value the parsed value for this property 20 | */ 21 | default Optional findNonPredicateContradiction(RecommendationRequest request, NumericPropertyPredicate value) { 22 | return Optional.empty(); 23 | } 24 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/NumericPropertyPredicate.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.Value; 4 | import tillerino.tillerinobot.recommendations.BareRecommendation; 5 | import tillerino.tillerinobot.recommendations.RecommendationRequest; 6 | 7 | import java.util.Optional; 8 | 9 | import org.tillerino.osuApiModel.OsuApiBeatmap; 10 | 11 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 12 | 13 | @Value 14 | public class NumericPropertyPredicate 15 | implements RecommendationPredicate { 16 | String originalArgument; 17 | T property; 18 | double min; 19 | boolean includeMin; 20 | double max; 21 | boolean includeMax; 22 | 23 | @Override 24 | public boolean test(BareRecommendation r, OsuApiBeatmap beatmap) { 25 | double value = property.getValue(beatmap, r.mods()); 26 | 27 | if(value < min) { 28 | return false; 29 | } 30 | if(value <= min && !includeMin) { 31 | return false; 32 | } 33 | if(value > max) { 34 | return false; 35 | } 36 | return value < max || includeMax; 37 | } 38 | 39 | @Override 40 | @SuppressFBWarnings(value = "SA_LOCAL_SELF_COMPARISON", justification = "Looks like a bug") 41 | public boolean contradicts(RecommendationPredicate otherPredicate) { 42 | if (otherPredicate instanceof NumericPropertyPredicate predicate 43 | && predicate.property.getClass() == property.getClass()) { 44 | if (predicate.min > max || min > predicate.max) { 45 | return true; 46 | } 47 | if(predicate.min >= max && predicate.includeMin != includeMax) { 48 | return true; 49 | } 50 | if(min >= predicate.max && includeMin != predicate.includeMax) { 51 | return true; 52 | } 53 | } 54 | 55 | return false; 56 | } 57 | 58 | @Override 59 | public Optional findNonPredicateContradiction(RecommendationRequest request) { 60 | return property.findNonPredicateContradiction(request, this); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/OverallDifficulty.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.EqualsAndHashCode; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | 7 | @EqualsAndHashCode 8 | public class OverallDifficulty implements NumericBeatmapProperty { 9 | 10 | @Override 11 | public String getName() { 12 | return "OD"; 13 | } 14 | 15 | @Override 16 | public double getValue(OsuApiBeatmap beatmap, long mods) { 17 | return beatmap.getOverallDifficulty(mods); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/PredicateParser.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | 7 | import javax.annotation.CheckForNull; 8 | 9 | import tillerino.tillerinobot.UserException; 10 | import tillerino.tillerinobot.lang.Language; 11 | 12 | public class PredicateParser { 13 | public interface PredicateBuilder { 14 | /** 15 | * Parses the given string. 16 | * 17 | * @param argument 18 | * doesn't contain spaces 19 | * @param lang 20 | * for error messages 21 | * @return null if the argument cannot be parsed 22 | */ 23 | T build(String argument, Language lang) throws UserException; 24 | } 25 | 26 | List> builders = new ArrayList<>(); 27 | 28 | public PredicateParser() { 29 | List properties = Arrays.asList(new ApproachRate(), new BeatsPerMinute(), new OverallDifficulty(), new MapLength(), new CircleSize(), new StarDiff()); 30 | 31 | for (NumericBeatmapProperty property : properties) { 32 | builders.add(new NumericPredicateBuilder<>(property)); 33 | } 34 | 35 | builders.add(new ExcludeMod.Builder()); 36 | } 37 | 38 | public @CheckForNull RecommendationPredicate tryParse(String argument, Language lang) throws UserException { 39 | for (PredicateBuilder predicateBuilder : builders) { 40 | RecommendationPredicate predicate = predicateBuilder.build(argument, lang); 41 | if (predicate != null) 42 | return predicate; 43 | } 44 | return null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/RecommendationPredicate.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import java.util.Optional; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | 7 | import tillerino.tillerinobot.recommendations.BareRecommendation; 8 | import tillerino.tillerinobot.recommendations.RecommendationRequest; 9 | 10 | public interface RecommendationPredicate { 11 | boolean test(BareRecommendation r, OsuApiBeatmap beatmap); 12 | 13 | /** 14 | * Checks if this predicate contradicts the given predicate. 15 | */ 16 | boolean contradicts(RecommendationPredicate otherPredicate); 17 | 18 | /** 19 | * Checks if this predicate contradicts any settings in the request beside other 20 | * predicates. 21 | * 22 | * @return If there is a contradiction, a string describing the contradiction, 23 | * an empty optional otherwise. 24 | */ 25 | Optional findNonPredicateContradiction(RecommendationRequest request); 26 | 27 | String getOriginalArgument(); 28 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/predicates/StarDiff.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import java.util.Optional; 4 | 5 | import org.tillerino.osuApiModel.Mods; 6 | import org.tillerino.osuApiModel.OsuApiBeatmap; 7 | 8 | import lombok.EqualsAndHashCode; 9 | import tillerino.tillerinobot.recommendations.RecommendationRequest; 10 | 11 | @EqualsAndHashCode 12 | public class StarDiff implements NumericBeatmapProperty { 13 | @Override 14 | public String getName() { 15 | return "STAR"; 16 | } 17 | 18 | @Override 19 | public double getValue(OsuApiBeatmap beatmap, long mods) { 20 | return beatmap.getStarDifficulty(); 21 | } 22 | 23 | @Override 24 | public Optional findNonPredicateContradiction(RecommendationRequest request, NumericPropertyPredicate value) { 25 | if (request.requestedMods() != 0L) { 26 | return Optional.of(String.format("%s %s", Mods.toShortNamesContinuous(Mods.getMods(request.requestedMods())), value.getOriginalArgument())); 27 | } 28 | return Optional.empty(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/AllRecommenders.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | import java.util.Collection; 6 | import java.util.List; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | 11 | import org.tillerino.ppaddict.util.MaintenanceException; 12 | 13 | import lombok.RequiredArgsConstructor; 14 | import tillerino.tillerinobot.UserException; 15 | 16 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 17 | public class AllRecommenders implements Recommender { 18 | @Named("standard") 19 | private final Recommender standard; 20 | 21 | private final NamePendingApprovalRecommender nap; 22 | 23 | @Override 24 | public List loadTopPlays(int userId) throws SQLException, MaintenanceException, IOException { 25 | return standard.loadTopPlays(userId); 26 | } 27 | 28 | @Override 29 | public Collection loadRecommendations(List topPlays, Collection exclude, 30 | Model model, boolean nomod, long requestMods) throws SQLException, IOException, UserException { 31 | Recommender delegate = switch (model) { 32 | case NAP -> nap; 33 | default -> standard; 34 | }; 35 | 36 | return delegate.loadRecommendations(topPlays, exclude, model, nomod, requestMods); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/BareRecommendation.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import org.tillerino.osuApiModel.types.BeatmapId; 4 | import org.tillerino.osuApiModel.types.BitwiseMods; 5 | 6 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 7 | 8 | /** 9 | * Recommendation as returned by the backend. Needs to be enriched before being displayed. 10 | * 11 | * @author Tillerino 12 | */ 13 | @SuppressFBWarnings({"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) 14 | public record BareRecommendation( 15 | @BeatmapId int beatmapId, 16 | 17 | /** 18 | * mods for this recommendation 19 | * 20 | * @return 0 for no mods, -1 for unknown mods, any other long for mods according 21 | * to {@link Mods} 22 | */ 23 | @BitwiseMods long mods, 24 | 25 | long[] causes, 26 | 27 | /** 28 | * returns a guess at how much pp the player could achieve for this 29 | * recommendation 30 | * 31 | * @return null if no personal pp were calculated 32 | */ 33 | Integer personalPP, 34 | 35 | /** 36 | * @return this is not normed, so the sum of all probabilities can be greater 37 | * than 1 and this must be accounted for! 38 | */ 39 | double probability) { 40 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/Model.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | 6 | /** 7 | * The type of recommendation model that the player has chosen. 8 | * 9 | * @author Tillerino 10 | */ 11 | @RequiredArgsConstructor 12 | public enum Model { 13 | ALPHA(false), 14 | BETA(false), 15 | GAMMA8(true), 16 | GAMMA9(true), 17 | GAMMA10(true), 18 | /** 19 | * External model made by NamePendingApproval 20 | */ 21 | NAP(true) 22 | ; 23 | 24 | @Getter 25 | private final boolean modsCapable; 26 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/Recommendation.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import tillerino.tillerinobot.BeatmapMeta; 5 | 6 | /** 7 | * Enriched Recommendation. 8 | * 9 | * @author Tillerino 10 | */ 11 | @RequiredArgsConstructor 12 | public class Recommendation { 13 | public final BeatmapMeta beatmap; 14 | 15 | public final BareRecommendation bareRecommendation; 16 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/RecommendationRequest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | import java.util.List; 6 | 7 | import org.tillerino.osuApiModel.types.BitwiseMods; 8 | 9 | import lombok.Builder; 10 | import lombok.Getter; 11 | import tillerino.tillerinobot.predicates.RecommendationPredicate; 12 | 13 | @Builder 14 | public record RecommendationRequest( 15 | boolean nomod, 16 | Model model, 17 | @BitwiseMods long requestedMods, 18 | List predicates, 19 | Shift difficultyShift 20 | ) { 21 | public RecommendationRequest { 22 | predicates = new ArrayList<>(predicates); 23 | } 24 | 25 | public List predicates() { 26 | return Collections.unmodifiableList(predicates); 27 | } 28 | 29 | public static class RecommendationRequestBuilder { 30 | @Getter 31 | @BitwiseMods 32 | private long requestedMods = 0L; 33 | 34 | private List predicates = new ArrayList<>(); 35 | 36 | private Shift difficultyShift = Shift.NONE; 37 | 38 | public RecommendationRequestBuilder requestedMods(@BitwiseMods long requestedMods) { 39 | this.requestedMods = requestedMods; 40 | return this; 41 | } 42 | 43 | public RecommendationRequestBuilder predicate(RecommendationPredicate predicate) { 44 | predicates.add(predicate); 45 | return this; 46 | } 47 | 48 | public Model getModel() { 49 | return model; 50 | } 51 | 52 | public List getPredicates() { 53 | return Collections.unmodifiableList(predicates); 54 | } 55 | } 56 | 57 | /** 58 | * Modifies the difficulty of recommendations. 59 | */ 60 | static enum Shift { 61 | /** 62 | * Regular strength. 63 | */ 64 | NONE, 65 | /** 66 | * The player is weak compared to their top scores. Recommendations are easier. 67 | */ 68 | SUCC, 69 | /** 70 | * Even weaker than {@link #SUCC} 71 | */ 72 | SUCCER, 73 | /** 74 | * Even weaker than {@link #SUCCERBERG} 75 | */ 76 | SUCCERBERG 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/Recommender.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | import java.util.Collection; 6 | import java.util.List; 7 | 8 | import javax.annotation.Nonnull; 9 | 10 | import org.tillerino.osuApiModel.types.BitwiseMods; 11 | import org.tillerino.osuApiModel.types.UserId; 12 | import org.tillerino.ppaddict.util.MaintenanceException; 13 | 14 | import tillerino.tillerinobot.UserException; 15 | 16 | public interface Recommender { 17 | List loadTopPlays(@UserId int userId) throws SQLException, MaintenanceException, IOException; 18 | 19 | /** 20 | * @param topPlays base for the recommendation 21 | * @param exclude these maps will be excluded (give top50 and previously given recommendations) 22 | * @param model selected model 23 | * @param nomod don't recommend mods 24 | * @param requestMods request specific mods (these will be included, but this won't exclude other mods) 25 | */ 26 | public Collection loadRecommendations(List topPlays, @Nonnull Collection exclude, 27 | @Nonnull Model model, boolean nomod, @BitwiseMods long requestMods) throws SQLException, IOException, UserException; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/recommendations/TopPlay.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import org.tillerino.osuApiModel.types.BeatmapId; 4 | import org.tillerino.osuApiModel.types.BitwiseMods; 5 | import org.tillerino.osuApiModel.types.UserId; 6 | 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import tillerino.tillerinobot.UserDataManager.UserData.BeatmapWithMods; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class TopPlay { 16 | @UserId 17 | private int userid; 18 | private int place; 19 | 20 | @BeatmapId 21 | private int beatmapid; 22 | @BitwiseMods 23 | private long mods; 24 | 25 | private double pp; 26 | 27 | public BeatmapWithMods idAndMods() { 28 | return new BeatmapWithMods(beatmapid, mods); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/AuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.io.IOException; 4 | import java.util.Optional; 5 | 6 | import javax.annotation.Priority; 7 | import javax.inject.Inject; 8 | 9 | import jakarta.ws.rs.NotFoundException; 10 | import jakarta.ws.rs.Priorities; 11 | import jakarta.ws.rs.WebApplicationException; 12 | import jakarta.ws.rs.container.ContainerRequestContext; 13 | import jakarta.ws.rs.container.ContainerRequestFilter; 14 | import jakarta.ws.rs.core.Response.Status; 15 | 16 | import org.apache.commons.lang3.StringUtils; 17 | import org.slf4j.MDC; 18 | import org.tillerino.ppaddict.rest.AuthenticationService; 19 | import org.tillerino.ppaddict.util.MdcUtils; 20 | 21 | import lombok.RequiredArgsConstructor; 22 | 23 | @KeyRequired 24 | @Priority(Priorities.AUTHENTICATION) 25 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 26 | public class AuthenticationFilter implements ContainerRequestFilter { 27 | private final AuthenticationService authentication; 28 | 29 | @Override 30 | public void filter(ContainerRequestContext requestContext) throws IOException { 31 | String apiKey = Optional.ofNullable(requestContext.getUriInfo().getQueryParameters().get("k")) 32 | .flatMap(l -> l.stream().findFirst()).orElse(requestContext.getHeaderString("api-key")); 33 | 34 | try { 35 | if (apiKey == null) { 36 | throw new NotFoundException(); 37 | } 38 | authentication.getAuthorization(apiKey); 39 | } catch (NotFoundException e) { 40 | throw new WebApplicationException("Unknown API key", Status.UNAUTHORIZED); 41 | } 42 | // abbreviate. never log credentials. keys are made to be unique in the first 8 characters. 43 | MDC.put(MdcUtils.MDC_API_KEY, StringUtils.substring(apiKey, 0, 8)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BeatmapDifficulties.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.util.List; 4 | 5 | import jakarta.ws.rs.DefaultValue; 6 | import jakarta.ws.rs.GET; 7 | import jakarta.ws.rs.Path; 8 | import jakarta.ws.rs.Produces; 9 | import jakarta.ws.rs.QueryParam; 10 | import jakarta.ws.rs.core.MediaType; 11 | 12 | import org.tillerino.osuApiModel.types.BeatmapId; 13 | import org.tillerino.osuApiModel.types.BitwiseMods; 14 | 15 | import tillerino.tillerinobot.rest.BeatmapInfoService.BeatmapInfo; 16 | 17 | @Path("/beatmapinfo") 18 | public interface BeatmapDifficulties { 19 | @KeyRequired 20 | @GET 21 | @Produces(MediaType.APPLICATION_JSON) 22 | BeatmapInfo getBeatmapInfo(@QueryParam("beatmapid") @BeatmapId int beatmapid, 23 | @QueryParam("mods") @BitwiseMods long mods, 24 | @QueryParam("acc") List requestedAccs, 25 | @QueryParam("wait") @DefaultValue("1000") long wait) throws Throwable; 26 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BeatmapResource.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import jakarta.ws.rs.Consumes; 4 | import jakarta.ws.rs.GET; 5 | import jakarta.ws.rs.PUT; 6 | import jakarta.ws.rs.Path; 7 | import jakarta.ws.rs.Produces; 8 | import jakarta.ws.rs.core.MediaType; 9 | 10 | import org.tillerino.osuApiModel.OsuApiBeatmap; 11 | 12 | import io.swagger.annotations.Api; 13 | import io.swagger.annotations.ApiOperation; 14 | import io.swagger.annotations.ApiResponse; 15 | import io.swagger.annotations.ApiResponses; 16 | import io.swagger.annotations.Authorization; 17 | 18 | @Path("") 19 | @Api(hidden = true) 20 | @Produces(MediaType.APPLICATION_JSON) 21 | @KeyRequired 22 | public interface BeatmapResource { 23 | @ApiOperation(value = "Get a beatmap object", tags = "public", authorizations = @Authorization("api_key")) 24 | @GET 25 | OsuApiBeatmap get(); 26 | 27 | @ApiOperation(value = "Get a beatmap file", tags = "public", authorizations = @Authorization("api_key")) 28 | @Path("/file") 29 | @GET 30 | @Produces(MediaType.TEXT_PLAIN) 31 | String getFile(); 32 | 33 | @ApiOperation(value = "Update a beatmap file", tags = "public", authorizations = @Authorization("api_key")) 34 | @ApiResponses({ 35 | @ApiResponse(code = 403, message = "The supplied beatmap file did not have the required hash value"), 36 | @ApiResponse(code = 204, message = "Beatmap file saved") 37 | }) 38 | @Path("/file") 39 | @PUT 40 | @Consumes(MediaType.TEXT_PLAIN) 41 | void setFile(String content); 42 | } 43 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BeatmapsService.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import jakarta.ws.rs.Path; 4 | import jakarta.ws.rs.PathParam; 5 | 6 | import org.tillerino.osuApiModel.types.BeatmapId; 7 | 8 | import io.swagger.annotations.Api; 9 | import io.swagger.annotations.ApiOperation; 10 | 11 | @Api 12 | @Path("/beatmaps") 13 | public interface BeatmapsService { 14 | @Path("/byId/{id}") 15 | @ApiOperation("") 16 | BeatmapResource byId(@PathParam("id") @BeatmapId int id); 17 | 18 | @Path("byHash/{hash}") 19 | @ApiOperation("") 20 | BeatmapResource byHash(@PathParam("hash") String hash); 21 | } 22 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BotApiDefinition.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.util.Collections; 4 | import java.util.HashSet; 5 | import java.util.List; 6 | import java.util.Set; 7 | 8 | import javax.inject.Inject; 9 | 10 | import jakarta.ws.rs.container.ContainerResponseFilter; 11 | import jakarta.ws.rs.core.Application; 12 | 13 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 14 | 15 | /** 16 | * @author Tillerino 17 | */ 18 | public class BotApiDefinition extends Application { 19 | Set resourceInstances = new HashSet<>(); 20 | 21 | @Inject 22 | public BotApiDefinition(BotInfoService botInfo, BeatmapInfoService beatmapInfo, 23 | UserByIdService userById, BeatmapsService beatmaps, 24 | AuthenticationFilter authentication, ApiLoggingFeature logging) { 25 | super(); 26 | 27 | resourceInstances.add(botInfo); 28 | resourceInstances.add(beatmapInfo); 29 | resourceInstances.add(userById); 30 | resourceInstances.add(new DelegatingBeatmapsService(beatmaps)); 31 | resourceInstances.add(authentication); 32 | resourceInstances.add(logging); 33 | resourceInstances.add((ContainerResponseFilter) (requestContext, responseContext) -> { 34 | // allow requests from github page, e.g. swagger UI. 35 | List origin = requestContext.getHeaders().getOrDefault("Origin", Collections.emptyList()); 36 | if (origin.stream().allMatch(x -> x.startsWith("https://tillerino.github.io"))) { 37 | origin.forEach(o -> responseContext.getHeaders().add("Access-Control-Allow-Origin", o)); 38 | responseContext.getHeaders().add("Access-Control-Allow-Headers", "api-key"); 39 | } 40 | }); 41 | } 42 | 43 | @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "We intentionally modify this externally. Sorry :D") 44 | @Override 45 | public Set getSingletons() { 46 | return resourceInstances; 47 | } 48 | 49 | @Override 50 | public Set> getClasses() { 51 | return Collections.singleton(PrintMessageExceptionMapper.class); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BotInfoService.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import javax.inject.Inject; 4 | import javax.inject.Singleton; 5 | 6 | import jakarta.ws.rs.NotFoundException; 7 | 8 | import org.tillerino.ppaddict.chat.GameChatClient; 9 | import org.tillerino.ppaddict.chat.GameChatClientMetrics; 10 | import org.tillerino.ppaddict.chat.local.LocalGameChatMetrics; 11 | import org.tillerino.ppaddict.util.Clock; 12 | 13 | import lombok.RequiredArgsConstructor; 14 | 15 | @Singleton 16 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 17 | public class BotInfoService implements BotStatus { 18 | private final GameChatClient bot; 19 | 20 | private final LocalGameChatMetrics botInfo; 21 | 22 | private final Clock clock; 23 | 24 | @Override 25 | public LocalGameChatMetrics botinfo() { 26 | GameChatClientMetrics botMetrics = bot.getMetrics().unwrapOrElse(__ -> { 27 | GameChatClientMetrics metrics = new GameChatClientMetrics(); 28 | metrics.setLastInteraction(-1); // as a marker 29 | return metrics; 30 | }); 31 | LocalGameChatMetrics.Mapper.INSTANCE.loadFromBot(botMetrics, botInfo); 32 | return LocalGameChatMetrics.Mapper.INSTANCE.copy(botInfo); 33 | } 34 | 35 | /* 36 | * The following are endpoints for automated health checks, so they don't return anything 37 | * valuable other than a 200 or 404. 38 | */ 39 | @Override 40 | public boolean isReceiving() { 41 | // set remotely, so we need to call botinfo() 42 | if (botinfo().getLastReceivedMessage() < clock.currentTimeMillis() - 10000) { 43 | throw new NotFoundException(); 44 | } 45 | return true; 46 | } 47 | 48 | @Override 49 | public boolean isSending() { 50 | // set locally 51 | if (botInfo.getLastSentMessage() < clock.currentTimeMillis() - 60000) { 52 | throw new NotFoundException(); 53 | } 54 | return true; 55 | } 56 | 57 | @Override 58 | public boolean isRecommending() { 59 | // set locally 60 | if (botInfo.getLastRecommendation() < clock.currentTimeMillis() - 60000) { 61 | throw new NotFoundException(); 62 | } 63 | return true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/BotStatus.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import org.tillerino.ppaddict.chat.local.LocalGameChatMetrics; 4 | 5 | import jakarta.ws.rs.GET; 6 | import jakarta.ws.rs.Path; 7 | import jakarta.ws.rs.Produces; 8 | import jakarta.ws.rs.core.MediaType; 9 | 10 | @Path("/botinfo") 11 | public interface BotStatus { 12 | @GET 13 | @Produces(MediaType.APPLICATION_JSON) 14 | LocalGameChatMetrics botinfo(); 15 | 16 | @GET 17 | @Produces(MediaType.TEXT_PLAIN) 18 | @Path("/isReceiving") 19 | boolean isReceiving(); 20 | 21 | @GET 22 | @Produces(MediaType.TEXT_PLAIN) 23 | @Path("/isSending") 24 | boolean isSending(); 25 | 26 | @GET 27 | @Produces(MediaType.TEXT_PLAIN) 28 | @Path("/isRecommending") 29 | boolean isRecommending(); 30 | } -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/DelegatingBeatmapsService.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import javax.inject.Inject; 4 | 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 8 | public class DelegatingBeatmapsService implements BeatmapsService { 9 | private final BeatmapsService delegate; 10 | 11 | @Override 12 | public BeatmapResource byId(int id) { 13 | return delegate.byId(id); 14 | } 15 | 16 | @Override 17 | public BeatmapResource byHash(String hash) { 18 | return delegate.byHash(hash); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/KeyRequired.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import static java.lang.annotation.ElementType.METHOD; 4 | import static java.lang.annotation.ElementType.TYPE; 5 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 6 | 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.Target; 9 | 10 | import jakarta.ws.rs.NameBinding; 11 | 12 | /** 13 | * Marks classes and methods which require a general key to be present in the API 14 | */ 15 | @NameBinding 16 | @Retention(RUNTIME) 17 | @Target({ TYPE, METHOD }) 18 | public @interface KeyRequired { 19 | 20 | } 21 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/PrintMessageExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import jakarta.ws.rs.WebApplicationException; 4 | import jakarta.ws.rs.core.MediaType; 5 | import jakarta.ws.rs.core.Response; 6 | import jakarta.ws.rs.ext.ExceptionMapper; 7 | 8 | public class PrintMessageExceptionMapper implements ExceptionMapper { 9 | @Override 10 | public Response toResponse(WebApplicationException exception) { 11 | Response response = exception.getResponse(); 12 | if (!response.hasEntity() && exception.getMessage() != null) { 13 | return Response.fromResponse(response).entity(exception.getMessage()).type(MediaType.TEXT_PLAIN).build(); 14 | } 15 | return response; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/RestUtils.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.ws.rs.WebApplicationException; 6 | import jakarta.ws.rs.core.Response; 7 | import jakarta.ws.rs.core.Response.Status; 8 | 9 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 10 | 11 | public final class RestUtils { 12 | private RestUtils() { 13 | // utils class 14 | } 15 | 16 | public static WebApplicationException getBadGateway(IOException e) { 17 | return new WebApplicationException(e != null ? e.getMessage() : "Communication with the osu API server failed.", Status.fromStatusCode(502)); 18 | } 19 | 20 | public static WebApplicationException getInterrupted() { 21 | return new WebApplicationException("The server is being shutdown for maintenance", Status.SERVICE_UNAVAILABLE); 22 | } 23 | 24 | @SuppressFBWarnings(value = "SA_LOCAL_SELF_COMPARISON", justification = "Looks like a bug") 25 | public static Throwable refreshWebApplicationException(Throwable t) { 26 | if (t instanceof WebApplicationException web) { 27 | return new WebApplicationException(t.getCause(), Response.fromResponse(web.getResponse()).build()); 28 | } 29 | return t; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/rest/UserByIdService.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.io.IOException; 4 | import java.sql.SQLException; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Singleton; 8 | 9 | import jakarta.ws.rs.GET; 10 | import jakarta.ws.rs.NotFoundException; 11 | import jakarta.ws.rs.Path; 12 | import jakarta.ws.rs.Produces; 13 | import jakarta.ws.rs.QueryParam; 14 | import jakarta.ws.rs.core.MediaType; 15 | 16 | import org.tillerino.osuApiModel.OsuApiUser; 17 | import org.tillerino.osuApiModel.types.UserId; 18 | 19 | import lombok.RequiredArgsConstructor; 20 | import tillerino.tillerinobot.IrcNameResolver; 21 | 22 | @Singleton 23 | @Path("/userbyid") 24 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 25 | public class UserByIdService { 26 | private final IrcNameResolver resolver; 27 | 28 | @KeyRequired 29 | @GET 30 | @Produces(MediaType.APPLICATION_JSON) 31 | public OsuApiUser getUserById(@QueryParam("id") @UserId int id) throws SQLException { 32 | try { 33 | OsuApiUser user = resolver.resolveManually(id); 34 | if (user == null) { 35 | throw new NotFoundException("user with that id does not exist"); 36 | } else { 37 | return user; 38 | } 39 | } catch (IOException e) { 40 | throw RestUtils.getBadGateway(null); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tillerinobot/src/main/java/tillerino/tillerinobot/util/IsMutable.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.util; 2 | 3 | public interface IsMutable { 4 | /** 5 | * Checks whether this object has been modified. 6 | */ 7 | boolean isModified(); 8 | 9 | /** 10 | * After this method has been called, calls to {@link #isModified()} will return 11 | * false until the next modification of this object. 12 | */ 13 | void clearModified(); 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot/src/main/resources/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tillerino/Tillerinobot/7c59b2557fe9be2a227ca5fb7b476d06c5c181d3/tillerinobot/src/main/resources/.gitignore -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/mormon/LoaderTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 4 | 5 | import javax.inject.Inject; 6 | 7 | import lombok.NoArgsConstructor; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.tillerino.ppaddict.util.InjectionRunner; 11 | import org.tillerino.ppaddict.util.TestModule; 12 | 13 | import tillerino.tillerinobot.AbstractDatabaseTest.DockeredMysqlModule; 14 | 15 | @RunWith(InjectionRunner.class) 16 | @TestModule(value = { DockeredMysqlModule.class }) 17 | public class LoaderTest { 18 | @Inject 19 | DatabaseManager dbm; 20 | 21 | @Table("does_not_exist") 22 | @NoArgsConstructor 23 | static class DoesNotExist { 24 | 25 | } 26 | 27 | @Test 28 | public void wrongParameterCount() throws Exception { 29 | try(Database db = dbm.getDatabase(); 30 | Loader loader = db.loader(DoesNotExist.class, "where field_name = ?")) { 31 | assertThatThrownBy(() -> loader.query(1, 2)).hasMessage("Expected 1 parameters but received 2"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/mormon/LoaderTestManual.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.mormon; 2 | 3 | import javax.inject.Inject; 4 | 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.tillerino.mormon.Persister.Action; 8 | import org.tillerino.ppaddict.util.InjectionRunner; 9 | import org.tillerino.ppaddict.util.TestModule; 10 | 11 | import tillerino.tillerinobot.AbstractDatabaseTest.DockeredMysqlModule; 12 | 13 | /** 14 | * Check if streaming works. 15 | */ 16 | @RunWith(InjectionRunner.class) 17 | @TestModule(value = { DockeredMysqlModule.class }) 18 | public class LoaderTestManual { 19 | @Inject 20 | DatabaseManager dbm; 21 | 22 | @Table("byteArrays") 23 | public static class ByteArrays { 24 | public byte[] payload; 25 | } 26 | 27 | @Test 28 | public void testStreaming() throws Exception { 29 | Database db = dbm.getDatabase(); 30 | db.connection().createStatement().execute("CREATE TABLE `byteArrays` (`payload` longblob NOT NULL)"); 31 | for (int i = 0; i < 1024; i++) { 32 | ByteArrays b = new ByteArrays(); 33 | b.payload = new byte[500 * 1024]; 34 | db.persister(ByteArrays.class, Action.INSERT) 35 | .persist(b); 36 | System.out.println(i); 37 | } 38 | System.out.println(); 39 | for (ByteArrays b : db.streamingLoader(ByteArrays.class, "").query()) { 40 | // put a breakpoint here, check memory consumption after GC 41 | System.out.println("x"); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/chat/impl/BouncerTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.impl; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.Assert.*; 5 | 6 | import org.junit.Test; 7 | import org.tillerino.ppaddict.chat.impl.Bouncer.SemaphorePayload; 8 | import org.tillerino.ppaddict.util.Clock; 9 | import org.tillerino.ppaddict.util.TestClock; 10 | 11 | public class BouncerTest { 12 | Clock clock = new TestClock(); 13 | 14 | Bouncer bouncer = new Bouncer(clock); 15 | 16 | @Test 17 | public void testEnter() throws Exception { 18 | assertTrue(bouncer.tryEnter("nick", 1)); 19 | assertThat(bouncer.get("nick")).contains(new SemaphorePayload(1, 0, 0, false)); 20 | } 21 | 22 | @Test 23 | public void testRepeatedDenied() throws Exception { 24 | assertTrue(bouncer.tryEnter("it's a me", 1)); 25 | assertFalse(bouncer.tryEnter("it's a me", 2)); 26 | assertThat(bouncer.get("it's a me").get()).hasFieldOrPropertyWithValue("attemptsSinceEntered", 1); 27 | } 28 | 29 | @Test 30 | public void testOkAfterLeaving() throws Exception { 31 | assertTrue(bouncer.tryEnter("it's a me", 1)); 32 | assertTrue(bouncer.exit("it's a me", 1)); 33 | assertTrue(bouncer.tryEnter("it's a me", 2)); 34 | } 35 | 36 | @Test 37 | public void testFalseExit() throws Exception { 38 | assertFalse(bouncer.exit("it's a me", 1)); 39 | assertTrue(bouncer.tryEnter("it's a me", 1)); 40 | assertFalse(bouncer.exit("it's a me", 2)); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/chat/local/InMemoryQueuesModule.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.local; 2 | 3 | import org.tillerino.ppaddict.chat.GameChatEventQueue; 4 | import org.tillerino.ppaddict.chat.GameChatResponseQueue; 5 | 6 | import com.google.inject.AbstractModule; 7 | 8 | public class InMemoryQueuesModule extends AbstractModule { 9 | @Override 10 | protected void configure() { 11 | bind(GameChatEventQueue.class).to(LocalGameChatEventQueue.class); 12 | bind(GameChatResponseQueue.class).to(LocalGameChatResponseQueue.class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/chat/local/LocalGameChatEventQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.local; 2 | 3 | import java.util.concurrent.BlockingQueue; 4 | import java.util.concurrent.LinkedBlockingQueue; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Singleton; 8 | 9 | import org.tillerino.ppaddict.chat.GameChatEvent; 10 | import org.tillerino.ppaddict.chat.GameChatEventQueue; 11 | import org.tillerino.ppaddict.chat.impl.MessageHandlerScheduler; 12 | import org.tillerino.ppaddict.util.LoopingRunnable; 13 | import org.tillerino.ppaddict.util.MdcUtils; 14 | 15 | import lombok.RequiredArgsConstructor; 16 | 17 | @Singleton 18 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 19 | public class LocalGameChatEventQueue extends LoopingRunnable implements GameChatEventQueue { 20 | private final BlockingQueue queue = new LinkedBlockingQueue<>(); 21 | 22 | private final MessageHandlerScheduler scheduler; 23 | 24 | private final LocalGameChatMetrics botInfo; 25 | 26 | @Override 27 | public void onEvent(GameChatEvent event) throws InterruptedException { 28 | event.getMeta().setMdc(MdcUtils.getSnapshot()); 29 | queue.put(event); 30 | botInfo.setEventQueueSize(size()); 31 | } 32 | 33 | @Override 34 | protected void loop() throws InterruptedException { 35 | scheduler.onEvent(queue.take()); 36 | botInfo.setEventQueueSize(size()); 37 | } 38 | 39 | @Override 40 | public int size() { 41 | return queue.size(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/chat/local/LocalGameChatResponseQueue.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.chat.local; 2 | 3 | import java.util.concurrent.BlockingQueue; 4 | import java.util.concurrent.LinkedBlockingQueue; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Named; 8 | import javax.inject.Singleton; 9 | 10 | import org.apache.commons.lang3.tuple.Pair; 11 | import org.tillerino.ppaddict.chat.*; 12 | import org.tillerino.ppaddict.util.LoopingRunnable; 13 | import org.tillerino.ppaddict.util.MdcUtils; 14 | import org.tillerino.ppaddict.util.MdcUtils.MdcAttributes; 15 | 16 | import lombok.RequiredArgsConstructor; 17 | import lombok.extern.slf4j.Slf4j; 18 | 19 | /** 20 | * Implements {@link GameChatResponseQueue} with a simple local, in-memory 21 | * version. If {@link #run()} is executed, the queue feeds the queued responses 22 | * synchronously downstream. 23 | */ 24 | @Slf4j 25 | @Singleton 26 | @RequiredArgsConstructor(onConstructor = @__(@Inject)) 27 | public class LocalGameChatResponseQueue extends LoopingRunnable implements GameChatResponseQueue { 28 | private final @Named("responsePostprocessor") GameChatResponseConsumer downstream; 29 | 30 | private final BlockingQueue> queue = new LinkedBlockingQueue<>(); 31 | 32 | private final LocalGameChatMetrics botInfo; 33 | 34 | @Override 35 | public void onResponse(GameChatResponse response, GameChatEvent event) throws InterruptedException { 36 | event.getMeta().setMdc(MdcUtils.getSnapshot()); 37 | queue.put(Pair.of(response, event)); 38 | botInfo.setResponseQueueSize(queue.size()); 39 | } 40 | 41 | @Override 42 | protected void loop() throws InterruptedException { 43 | Pair response = queue.take(); 44 | botInfo.setResponseQueueSize(queue.size()); 45 | try (MdcAttributes mdc = response.getRight().getMeta().getMdc().apply()) { 46 | downstream.onResponse(response.getLeft(), response.getRight()); 47 | } catch (InterruptedException e) { 48 | throw e; 49 | } catch (Throwable e) { 50 | log.error("Exception while handling response", e); 51 | } 52 | } 53 | 54 | @Override 55 | public int size() { 56 | return queue.size(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/config/DatabaseConfigServiceTest.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.config; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import javax.inject.Inject; 6 | 7 | import org.junit.Test; 8 | import org.tillerino.mormon.Persister.Action; 9 | import org.tillerino.ppaddict.util.TestModule; 10 | 11 | import tillerino.tillerinobot.AbstractDatabaseTest; 12 | import tillerino.tillerinobot.data.BotConfig; 13 | 14 | @TestModule(CachedDatabaseConfigServiceModule.class) 15 | public class DatabaseConfigServiceTest extends AbstractDatabaseTest { 16 | @Inject 17 | ConfigService config; 18 | 19 | @Test 20 | public void noConfigIsDefault() throws Exception { 21 | assertThat(config.scoresMaintenance()).isFalse(); 22 | } 23 | 24 | @Test 25 | public void falsee() throws Exception { 26 | BotConfig botConfig = new BotConfig(); 27 | botConfig.setPath("api-scores-maintenance"); 28 | botConfig.setValue("false"); 29 | db.persist(botConfig, Action.INSERT); 30 | assertThat(config.scoresMaintenance()).isFalse(); 31 | } 32 | 33 | @Test 34 | public void truee() throws Exception { 35 | BotConfig botConfig = new BotConfig(); 36 | botConfig.setPath("api-scores-maintenance"); 37 | botConfig.setValue("true"); 38 | db.persist(botConfig, Action.INSERT); 39 | assertThat(config.scoresMaintenance()).isTrue(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/org/tillerino/ppaddict/rest/AuthenticationServiceImplIT.java: -------------------------------------------------------------------------------- 1 | package org.tillerino.ppaddict.rest; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 5 | 6 | import javax.inject.Inject; 7 | 8 | import jakarta.ws.rs.NotAuthorizedException; 9 | 10 | import org.junit.Before; 11 | import org.junit.Rule; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | import org.tillerino.MockServerRule; 15 | import org.tillerino.ppaddict.util.InjectionRunner; 16 | import org.tillerino.ppaddict.util.TestModule; 17 | 18 | @RunWith(InjectionRunner.class) 19 | @TestModule({ MockServerRule.MockServerModule.class, AuthenticationServiceImpl.RemoteAuthenticationModule.class }) 20 | public class AuthenticationServiceImplIT{ 21 | @Rule 22 | public MockServerRule mockServer = new MockServerRule(); 23 | 24 | @Inject 25 | AuthenticationService auth; 26 | 27 | @Before 28 | public void before() { 29 | mockServer.mockJsonGet("/auth/authorization", "{ \"unknownProperty\": true }", "api-key", "regular"); 30 | mockServer.mockJsonGet("/auth/authorization", "{ \"admin\": true }", "api-key", "adminKey"); 31 | mockServer.mockStatusCodeGet("/auth/authorization", 401, "api-key", "garbage"); 32 | } 33 | 34 | @Test 35 | public void testPositive() throws Exception { 36 | assertThat(auth.getAuthorization("regular")).hasFieldOrPropertyWithValue("admin", false); 37 | } 38 | 39 | @Test 40 | public void testNegative() throws Exception { 41 | assertThatThrownBy(() -> auth.getAuthorization("garbage")).isInstanceOf(NotAuthorizedException.class); 42 | } 43 | 44 | @Test 45 | public void testAdmin() throws Exception { 46 | assertThat(auth.getAuthorization("adminKey")).hasFieldOrPropertyWithValue("admin", true); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/AbstractDatabaseTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import static tillerino.tillerinobot.MysqlContainer.mysql; 4 | 5 | import java.sql.SQLException; 6 | import java.util.Properties; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | 11 | import org.junit.After; 12 | import org.junit.Before; 13 | import org.junit.Rule; 14 | import org.junit.rules.TestRule; 15 | import org.junit.runner.RunWith; 16 | import org.tillerino.mormon.Database; 17 | import org.tillerino.mormon.DatabaseManager; 18 | import org.tillerino.ppaddict.util.InjectionRunner; 19 | import org.tillerino.ppaddict.util.TestModule; 20 | 21 | import com.google.inject.AbstractModule; 22 | import com.google.inject.Provides; 23 | 24 | import tillerino.tillerinobot.MysqlContainer.MysqlDatabaseLifecycle; 25 | 26 | /** 27 | * Creates a MySQL instance in running in Docker. 28 | */ 29 | @TestModule(AbstractDatabaseTest.DockeredMysqlModule.class) 30 | @RunWith(InjectionRunner.class) 31 | public abstract class AbstractDatabaseTest { 32 | public static class DockeredMysqlModule extends AbstractModule { 33 | @Provides 34 | @Named("mysql") 35 | Properties myqslProperties() { 36 | Properties props = new Properties(); 37 | props.put("host", mysql().getHost()); 38 | props.put("port", "" + mysql().getMappedPort(3306)); 39 | props.put("user", mysql().getUsername()); 40 | props.put("password", mysql().getPassword()); 41 | props.put("database", mysql().getDatabaseName()); 42 | return props; 43 | } 44 | } 45 | 46 | @Rule 47 | public TestRule resetMysql = new MysqlDatabaseLifecycle(); 48 | 49 | @Inject 50 | protected DatabaseManager dbm; 51 | protected Database db; 52 | 53 | @Before 54 | public void createEntityManager() { 55 | db = dbm.getDatabase(); 56 | } 57 | 58 | @After 59 | public void closeEntityManager() throws SQLException { 60 | db.close(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/CommandHandlerTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | import static org.junit.Assert.assertEquals; 3 | import static org.junit.Assert.assertFalse; 4 | import static org.junit.Assert.assertNull; 5 | import static org.junit.Assert.assertTrue; 6 | 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | import org.tillerino.ppaddict.chat.GameChatResponse; 10 | 11 | import tillerino.tillerinobot.UserDataManager.UserData; 12 | import tillerino.tillerinobot.lang.Default; 13 | import tillerino.tillerinobot.lang.LanguageIdentifier; 14 | 15 | public class CommandHandlerTest { 16 | UserData userData = new UserData(); 17 | 18 | boolean called_b, called_c; 19 | 20 | @Before 21 | public void setUp() { 22 | userData.setLanguage(LanguageIdentifier.Default); 23 | } 24 | 25 | CommandHandler handler = CommandHandler.handling( 26 | "A ", 27 | CommandHandler.alwaysHandling("B", (a, c, d, lang) -> { called_b = true; return GameChatResponse.none(); }) 28 | .or(CommandHandler.alwaysHandling("C", 29 | (a, c, d, lang) -> { called_c = true; return GameChatResponse.none(); }))); 30 | 31 | @Test 32 | public void testNestedChoices() throws Exception { 33 | assertEquals("A (B|C)", handler.getChoices()); 34 | } 35 | 36 | @Test 37 | public void testPass() throws Exception { 38 | assertNull(handler.handle("X", null, null, null)); 39 | } 40 | 41 | @Test(expected = UserException.class) 42 | public void testNoNestedChoice() throws Exception { 43 | handler.handle("A X", null, userData, new Default()); 44 | } 45 | 46 | @Test 47 | public void testB() throws Exception { 48 | handler.handle("A B", null, userData, null); 49 | assertTrue(called_b); 50 | assertFalse(called_c); 51 | } 52 | 53 | @Test 54 | public void testC() throws Exception { 55 | handler.handle("A C", null, userData, null); 56 | assertTrue(called_c); 57 | assertFalse(called_b); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/FakeAuthenticationService.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import java.util.UUID; 4 | 5 | import jakarta.ws.rs.ForbiddenException; 6 | import jakarta.ws.rs.NotFoundException; 7 | 8 | import org.tillerino.ppaddict.rest.AuthenticationService; 9 | 10 | public class FakeAuthenticationService implements AuthenticationService { 11 | @Override 12 | public Authorization getAuthorization(String key) throws NotFoundException { 13 | if (key.equals("testKey") || key.equals("valid-key")) { 14 | return new Authorization(false); 15 | } 16 | throw new NotFoundException(); 17 | } 18 | 19 | @Override 20 | public String createKey(String adminKey, int osuUserId) throws NotFoundException, ForbiddenException { 21 | return UUID.randomUUID().toString(); // not quite the usual format, but meh 22 | } 23 | } -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/IrcNameResolverTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.Assert.assertNotNull; 5 | import static org.junit.Assert.assertNull; 6 | 7 | import javax.inject.Inject; 8 | 9 | import org.junit.Test; 10 | import org.tillerino.ppaddict.util.TestModule; 11 | 12 | import tillerino.tillerinobot.data.UserNameMapping; 13 | 14 | @TestModule(value = TestBackend.Module.class, cache = false) 15 | public class IrcNameResolverTest extends AbstractDatabaseTest { 16 | @Inject 17 | TestBackend backend; 18 | 19 | @Inject 20 | IrcNameResolver resolver; 21 | 22 | @Test 23 | public void testBasic() throws Exception { 24 | assertNull(resolver.resolveIRCName("anybody")); 25 | 26 | db.truncate(UserNameMapping.class); 27 | backend.hintUser("anybody", false, 1000, 1000); 28 | assertNotNull(resolver.resolveIRCName("anybody")); 29 | 30 | assertThat(db.selectUnique(UserNameMapping.class)."where userName = \{"anybody"}") 31 | .hasValueSatisfying(m -> assertThat(m.getUserid()).isEqualTo(1)); 32 | } 33 | 34 | @Test 35 | public void testFix() throws Exception { 36 | backend.hintUser("this_underscore space_bullshit", false, 1000, 1000); 37 | assertNull(resolver.resolveIRCName("this_underscore_space_bullshit")); 38 | resolver.resolveManually(backend.downloadUser("this_underscore space_bullshit").getUserId()); 39 | assertNotNull(resolver.resolveIRCName("this_underscore_space_bullshit")); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/UserDataManagerTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.Assert.assertFalse; 5 | import static org.junit.Assert.assertTrue; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import javax.inject.Inject; 11 | 12 | import org.junit.Test; 13 | import org.tillerino.ppaddict.util.TestModule; 14 | 15 | import tillerino.tillerinobot.UserDataManager.UserData; 16 | import tillerino.tillerinobot.UserDataManager.UserData.BeatmapWithMods; 17 | 18 | @TestModule(TestBackend.Module.class) 19 | public class UserDataManagerTest extends AbstractDatabaseTest { 20 | @Inject 21 | UserDataManager manager; 22 | 23 | @Test 24 | public void testSaveLoad() throws Exception { 25 | UserData data = manager.loadUserData(534678); 26 | assertFalse(data.isAllowedToDebug()); 27 | data.setAllowedToDebug(true); 28 | data.setLastSongInfo(new BeatmapWithMods(123, 456)); 29 | data.close(); 30 | 31 | reloadManager(); 32 | 33 | data = manager.loadUserData(534678); 34 | assertTrue(data.isAllowedToDebug()); 35 | assertThat(data.getLastSongInfo()).hasFieldOrPropertyWithValue("beatmap", 123); 36 | } 37 | 38 | private void reloadManager() { 39 | manager = new UserDataManager(null, dbm); 40 | } 41 | 42 | @Test 43 | public void testLanguageMutability() throws Exception { 44 | UserDataManager manager = new UserDataManager(null, dbm); 45 | List answers = new ArrayList<>(); 46 | try(UserData data = manager.loadUserData(534678)) { 47 | data.usingLanguage(language -> { 48 | answers.add(language.apiTimeoutException()); 49 | for (;;) { 50 | String answer = language.apiTimeoutException(); 51 | if (answer.equals(answers.get(0))) { 52 | assertThat(answers).size().as("number of responses to API timeout").isGreaterThan(1); 53 | break; 54 | } 55 | answers.add(answer); 56 | } 57 | return null; 58 | }); 59 | } 60 | 61 | // at this point we got the first answer again. Time go serialize, deserialize and check if we get the second answer next. 62 | reloadManager(); 63 | try(UserData data = manager.loadUserData(534678)) { 64 | data.usingLanguage(lang -> { 65 | assertThat(lang.apiTimeoutException()).as("API timeout message after reload").isEqualTo(answers.get(1)); 66 | return null; 67 | }); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/data/ApiBeatmapTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.data; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.Assert.*; 5 | import static org.mockito.Mockito.spy; 6 | 7 | import org.junit.Test; 8 | import org.tillerino.mormon.Persister.Action; 9 | import org.tillerino.osuApiModel.Downloader; 10 | import tillerino.tillerinobot.AbstractDatabaseTest; 11 | 12 | public class ApiBeatmapTest extends AbstractDatabaseTest { 13 | 14 | protected static Downloader downloader = spy(Downloader.createTestDownloader(AbstractDatabaseTest.class)); 15 | 16 | @Test 17 | public void testSchema() throws Exception { 18 | assertNotNull(ApiBeatmap.loadOrDownload(db, 53, 0L, 0, downloader)); 19 | } 20 | 21 | @Test 22 | public void testStoring() throws Exception { 23 | ApiBeatmap original = newApiBeatmap(); 24 | db.persister(ApiBeatmap.class, Action.INSERT).persist(original); 25 | assertThat(db.loader(ApiBeatmap.class, "").queryUnique()).hasValueSatisfying(original::equals); 26 | } 27 | 28 | public static ApiBeatmap newApiBeatmap() { 29 | ApiBeatmap apiBeatmap = new ApiBeatmap(); 30 | apiBeatmap.setArtist("no artist"); 31 | apiBeatmap.setTitle("no title"); 32 | apiBeatmap.setVersion("no version"); 33 | apiBeatmap.setCreator("no creator"); 34 | apiBeatmap.setSource("no source"); 35 | apiBeatmap.setFileMd5("no md5"); 36 | return apiBeatmap; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/handlers/DebugHandlerTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import static org.junit.Assert.assertNotNull; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.mockito.Mock; 8 | import org.mockito.MockitoAnnotations; 9 | 10 | import tillerino.tillerinobot.BotBackend; 11 | import tillerino.tillerinobot.IrcNameResolver; 12 | import tillerino.tillerinobot.UserDataManager.UserData; 13 | 14 | public class DebugHandlerTest { 15 | @Mock 16 | BotBackend backend; 17 | 18 | @Mock 19 | IrcNameResolver resolver; 20 | 21 | DebugHandler handler; 22 | 23 | UserData userData = new UserData(); 24 | 25 | @Before 26 | public void initMocks() { 27 | MockitoAnnotations.initMocks(this); 28 | 29 | handler = new DebugHandler(backend, resolver); 30 | userData.setAllowedToDebug(true); 31 | } 32 | 33 | @Test 34 | public void testIfHandles() throws Exception { 35 | assertNotNull(handler.handle("debug resolve bla", null, userData, null)); 36 | assertNotNull(handler.handle("debug getUserByIdFresh 1", null, userData, null)); 37 | assertNotNull(handler.handle("debug getUserByIdCached 1", null, userData, null)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/handlers/RecommendHandlerTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.handlers; 2 | 3 | import static org.mockito.Mockito.any; 4 | import static org.mockito.Mockito.eq; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.when; 8 | 9 | import org.junit.Test; 10 | import org.tillerino.osuApiModel.OsuApiBeatmap; 11 | import org.tillerino.osuApiModel.OsuApiUser; 12 | import org.tillerino.ppaddict.chat.LiveActivity; 13 | 14 | import tillerino.tillerinobot.BeatmapMeta; 15 | import tillerino.tillerinobot.UserDataManager.UserData; 16 | import tillerino.tillerinobot.diff.PercentageEstimates; 17 | import tillerino.tillerinobot.lang.Default; 18 | import tillerino.tillerinobot.recommendations.BareRecommendation; 19 | import tillerino.tillerinobot.recommendations.Recommendation; 20 | import tillerino.tillerinobot.recommendations.RecommendationsManager; 21 | 22 | public class RecommendHandlerTest { 23 | @Test 24 | public void testDefaultSettings() throws Exception { 25 | RecommendationsManager manager = mock(RecommendationsManager.class); 26 | OsuApiBeatmap beatmap = new OsuApiBeatmap(); 27 | beatmap.setMaxCombo(100); 28 | when(manager.getRecommendation(any(), any(), any())).thenReturn( 29 | new Recommendation(new BeatmapMeta(beatmap, null, mock(PercentageEstimates.class)), new BareRecommendation(0, 0, null, null, 0))); 30 | UserData userData = mock(UserData.class); 31 | 32 | when(userData.getDefaultRecommendationOptions()).thenReturn("dt"); 33 | new RecommendHandler(manager, mock(LiveActivity.class)).handle("r", mock(OsuApiUser.class), userData, new Default()); 34 | verify(manager).getRecommendation(any(), eq("dt"), any()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/lang/StringShufflerTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.lang; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | import java.util.Random; 8 | 9 | import org.junit.Test; 10 | 11 | 12 | public class StringShufflerTest { 13 | @Test 14 | public void testShuffling() { 15 | Random rnd = new Random(); 16 | 17 | StringShuffler shuffler = new StringShuffler(rnd); 18 | 19 | Map map = new HashMap(); 20 | 21 | for(int i = 1; i <= 100; i++) { 22 | String[] strings = { "a", "b", "c", "d", "e" }; 23 | 24 | for (int j = 0; j < strings.length; j++) { 25 | String s = shuffler.get(strings); 26 | 27 | Integer x = map.get(s); 28 | if(x == null) { 29 | x = 0; 30 | } 31 | 32 | x++; 33 | 34 | map.put(s, x); 35 | } 36 | 37 | for (Integer count : map.values()) { 38 | assertEquals(i, (int) count); 39 | } 40 | } 41 | } 42 | 43 | @Test 44 | public void testInTsundere() { 45 | TsundereEnglish tsundere = new TsundereEnglish(); 46 | 47 | Map map = new HashMap(); 48 | 49 | for(int i = 1; i <= 100; i++) { 50 | for (int j = 0; j < 3; j++) { 51 | String s = tsundere.unknownBeatmap(); 52 | 53 | Integer x = map.get(s); 54 | if(x == null) { 55 | x = 0; 56 | } 57 | 58 | x++; 59 | 60 | map.put(s, x); 61 | } 62 | 63 | for (Integer count : map.values()) { 64 | assertEquals(i, (int) count); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/lang/TsundereTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.lang; 2 | 3 | import static org.junit.Assert.fail; 4 | import static org.mockito.ArgumentMatchers.anyString; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.spy; 7 | import static org.mockito.Mockito.times; 8 | import static org.mockito.Mockito.verify; 9 | 10 | import org.junit.Test; 11 | import org.tillerino.osuApiModel.OsuApiUser; 12 | import org.tillerino.ppaddict.chat.LiveActivity; 13 | 14 | import tillerino.tillerinobot.BotBackend; 15 | import tillerino.tillerinobot.TestBackend; 16 | import tillerino.tillerinobot.UserException; 17 | import tillerino.tillerinobot.handlers.RecommendHandler; 18 | import tillerino.tillerinobot.recommendations.RecommendationRequestParser; 19 | import tillerino.tillerinobot.recommendations.RecommendationsManager; 20 | import tillerino.tillerinobot.recommendations.Recommender; 21 | 22 | public class TsundereTest { 23 | 24 | @Test 25 | public void testInvalidChoice() throws Exception { 26 | // spy on a fresh tsundere object 27 | TsundereEnglish tsundere = spy(new TsundereEnglish()); 28 | 29 | // mock backend and create RecommendationsManager and RecommendHandler based on mocked backend 30 | BotBackend backend = mock(BotBackend.class); 31 | RecommendHandler handler = new RecommendHandler(new RecommendationsManager(backend, null, 32 | new RecommendationRequestParser(backend), 33 | new TestBackend.TestBeatmapsLoader(), mock(Recommender.class)), 34 | mock(LiveActivity.class)); 35 | 36 | // make a bullshit call to the handler four times 37 | for (int i = 0; i < 4; i++) { 38 | try { 39 | handler.handle("r bullshit", mock(OsuApiUser.class), null, tsundere); 40 | // we should not get this far because we're expecting an exception 41 | fail("there should be an exception"); 42 | } catch (UserException e) { 43 | // good, we're expecting this 44 | } 45 | } 46 | 47 | // invalid choice should have been called all four times 48 | verify(tsundere, times(4)).invalidChoice(anyString(), anyString()); 49 | // three of those times, unknownRecommendationParameter should have been called as well 50 | verify(tsundere, times(3)).unknownRecommendationParameter(); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/osutrack/TestOsutrackDownloader.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.osutrack; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.DeserializationFeature; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | 7 | import java.io.BufferedReader; 8 | import java.io.InputStream; 9 | import java.io.InputStreamReader; 10 | import java.util.stream.Collectors; 11 | 12 | import jakarta.ws.rs.NotFoundException; 13 | 14 | public class TestOsutrackDownloader extends OsutrackDownloader { 15 | static final ObjectMapper JACKSON = new ObjectMapper() 16 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); 17 | 18 | protected UpdateResult parseJson(String json) { 19 | UpdateResult updateResult; 20 | try { 21 | updateResult = JACKSON.readValue(json, UpdateResult.class); 22 | } catch (JsonProcessingException e) { 23 | throw new RuntimeException(e); 24 | } 25 | completeUpdateObject(updateResult); 26 | return updateResult; 27 | } 28 | 29 | 30 | @Override 31 | public UpdateResult getUpdate(int osuUserId) { 32 | InputStream inputStream = TestOsutrackDownloader.class.getResourceAsStream("/osutrack/" + osuUserId + ".json"); 33 | if (inputStream == null) { 34 | throw new NotFoundException(); 35 | } 36 | String json = new BufferedReader(new InputStreamReader(inputStream)).lines() 37 | .collect(Collectors.joining("\n")); 38 | return parseJson(json); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/predicates/PredicateParserTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import org.junit.Test; 6 | import org.tillerino.osuApiModel.Mods; 7 | 8 | 9 | public class PredicateParserTest { 10 | PredicateParser parser = new PredicateParser(); 11 | 12 | @Test 13 | public void testApproachRate() throws Exception { 14 | RecommendationPredicate predicate = parser.tryParse("AR=9", null); 15 | 16 | assertEquals(new NumericPropertyPredicate<>( 17 | "AR=9", new ApproachRate(), 9, true, 9, true), predicate); 18 | } 19 | 20 | @Test 21 | public void testOverallDifficulty() throws Exception { 22 | RecommendationPredicate predicate = parser.tryParse("OD=9", null); 23 | 24 | assertEquals(new NumericPropertyPredicate<>( 25 | "OD=9", new OverallDifficulty(), 9, true, 9, true), predicate); 26 | } 27 | 28 | @Test 29 | public void testBPM() throws Exception { 30 | RecommendationPredicate predicate = parser.tryParse("BPM>=9000", null); 31 | 32 | assertEquals(new NumericPropertyPredicate<>( 33 | "BPM>=9000", new BeatsPerMinute(), 9000, true, 34 | Double.POSITIVE_INFINITY, true), 35 | predicate); 36 | } 37 | 38 | @Test 39 | public void testExcludeMods() throws Exception { 40 | RecommendationPredicate predicate = parser.tryParse("-hr", null); 41 | 42 | assertEquals(new ExcludeMod(Mods.HardRock), 43 | predicate); 44 | } 45 | 46 | @Test 47 | public void testUnknown() throws Exception { 48 | assertNull(parser.tryParse("yourMom", null)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/predicates/TitleLength.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.predicates; 2 | 3 | import lombok.EqualsAndHashCode; 4 | 5 | import org.tillerino.osuApiModel.OsuApiBeatmap; 6 | 7 | @EqualsAndHashCode 8 | public class TitleLength implements NumericBeatmapProperty { 9 | @Override 10 | public String getName() { 11 | return "TL"; 12 | } 13 | 14 | @Override 15 | public double getValue(OsuApiBeatmap beatmap, long mods) { 16 | return beatmap.getTitle().length() * (mods != 0l ? 2 : 1); 17 | } 18 | } -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/recommendations/NamePendingApprovalRecommenderTest.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.recommendations; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.net.URI; 6 | import java.util.Collection; 7 | import java.util.List; 8 | 9 | import org.junit.Rule; 10 | import org.junit.Test; 11 | import org.mockserver.model.HttpRequest; 12 | import org.mockserver.model.HttpResponse; 13 | import org.mockserver.model.JsonBody; 14 | import org.mockserver.model.MediaType; 15 | import org.tillerino.MockServerRule; 16 | 17 | public class NamePendingApprovalRecommenderTest { 18 | @Rule 19 | public final MockServerRule mockServer = new MockServerRule(); 20 | 21 | @Test 22 | public void getRecommendations() throws Exception { 23 | MockServerRule.mockServer().when(HttpRequest.request("/recommend") 24 | .withContentType(MediaType.APPLICATION_JSON) 25 | .withHeader("Authorization", "Bearer my-token") 26 | .withHeader("User-Agent", "https://github.com/Tillerino/Tillerinobot") 27 | .withBody(new JsonBody(""" 28 | { 29 | "topPlays" : [ { 30 | "beatmapid" : 116128, 31 | "mods" : 0, 32 | "pp" : 240.588 33 | } ], 34 | "exclude" : [ 456, 116128 ], 35 | "nomod" : true, 36 | "requestMods" : 64 37 | }"""))) 38 | .respond(HttpResponse.response(""" 39 | { 40 | "recommendations": [ { 41 | "beatmapId": 2785705, 42 | "mods": 8, 43 | "pp": 221, 44 | "probability": 0.001763579140327404 45 | } ] 46 | } 47 | """)); 48 | 49 | NamePendingApprovalRecommender recommender = new NamePendingApprovalRecommender( 50 | URI.create(MockServerRule.getExternalMockServerAddress() + "/recommend"), "my-token"); 51 | List topPlays = List.of(new TopPlay(0, 0, 116128, 0, 240.588)); 52 | Collection exclusions = List.of(456); 53 | Collection loadRecommendations = recommender.loadRecommendations(topPlays, exclusions, Model.NAP, true, 64); 54 | assertThat(loadRecommendations).singleElement().satisfies(recommendation -> assertThat(recommendation) 55 | .hasFieldOrPropertyWithValue("beatmapId", 2785705) 56 | .hasFieldOrPropertyWithValue("mods", 8L) 57 | .hasFieldOrPropertyWithValue("personalPP", 221) 58 | .hasFieldOrPropertyWithValue("probability", 0.001763579140327404)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/rest/BeatmapInfoServiceIT.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import static io.restassured.RestAssured.given; 4 | import static org.hamcrest.CoreMatchers.is; 5 | 6 | import javax.inject.Inject; 7 | 8 | import org.junit.Rule; 9 | import org.junit.Test; 10 | import org.tillerino.MockServerRule; 11 | import org.tillerino.MockServerRule.MockServerModule; 12 | import org.tillerino.ppaddict.chat.GameChatClient; 13 | import org.tillerino.ppaddict.rest.AuthenticationServiceImpl.RemoteAuthenticationModule; 14 | import org.tillerino.ppaddict.util.TestClock; 15 | import org.tillerino.ppaddict.util.TestModule; 16 | 17 | import tillerino.tillerinobot.AbstractDatabaseTest; 18 | import tillerino.tillerinobot.TestBackend; 19 | 20 | @TestModule(value = { RemoteAuthenticationModule.class, MockServerModule.class, TestClock.Module.class, 21 | TestBackend.Module.class }, mocks = { GameChatClient.class, BeatmapsService.class }) 22 | public class BeatmapInfoServiceIT extends AbstractDatabaseTest { 23 | @Inject 24 | @Rule 25 | public BotApiRule botApi; 26 | 27 | @Rule 28 | public MockServerRule mockServer = new MockServerRule(); 29 | 30 | @Test 31 | public void testRegular() throws Exception { 32 | mockServer.mockJsonGet("/auth/authorization", "{ }", "api-key", "valid-key"); 33 | 34 | given().header("api-key", "valid-key") 35 | .get("/beatmapinfo?wait=2000&beatmapid=129891&mods=0") 36 | .then() 37 | .body("beatmapid", is(129891)); 38 | } 39 | 40 | @Test 41 | public void testCors() throws Exception { 42 | given() 43 | .header("Origin", "https://tillerino.github.io") 44 | .options("/botinfo") 45 | .then() 46 | .assertThat() 47 | .statusCode(200) 48 | .header("Access-Control-Allow-Origin", "https://tillerino.github.io") 49 | .header("Access-Control-Allow-Headers", "api-key"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/rest/BotApiRule.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import javax.inject.Inject; 4 | 5 | import io.restassured.RestAssured; 6 | 7 | public class BotApiRule extends JdkServerResource { 8 | @Inject 9 | public BotApiRule(BotApiDefinition app) { 10 | super(app, "localhost", 0); 11 | } 12 | 13 | @Override 14 | protected void before() throws Throwable { 15 | super.before(); 16 | RestAssured.baseURI = "http://localhost:" + getPort(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tillerinobot/src/test/java/tillerino/tillerinobot/rest/JdkServerResource.java: -------------------------------------------------------------------------------- 1 | package tillerino.tillerinobot.rest; 2 | 3 | import java.net.URI; 4 | 5 | import jakarta.ws.rs.core.Application; 6 | 7 | import org.glassfish.jersey.jdkhttp.JdkHttpServerFactory; 8 | import org.glassfish.jersey.server.ResourceConfig; 9 | import org.junit.rules.ExternalResource; 10 | 11 | import com.sun.net.httpserver.HttpServer; 12 | 13 | import lombok.RequiredArgsConstructor; 14 | import lombok.extern.slf4j.Slf4j; 15 | 16 | @Slf4j 17 | @RequiredArgsConstructor 18 | public class JdkServerResource extends ExternalResource { 19 | private final Application app; 20 | 21 | private final String host; 22 | 23 | private final int port; 24 | 25 | private int actualPort = 0; 26 | 27 | private HttpServer server; 28 | 29 | @Override 30 | protected void before() throws Throwable { 31 | server = JdkHttpServerFactory.createHttpServer(new URI("http", null, host, port, "/", null, null), 32 | ResourceConfig.forApplication(app)); 33 | actualPort = server.getAddress().getPort(); 34 | } 35 | 36 | @Override 37 | protected void after() { 38 | try { 39 | server.stop(1); 40 | } catch (Exception e) { 41 | log.error("Stopping Jetty failed", e); 42 | } 43 | } 44 | 45 | public int getPort() { 46 | return actualPort; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/MOSAIC.WAV - Magical Pants (Short Ver.) (Imaginative) [look at bg].osu: -------------------------------------------------------------------------------- 1 | osu file format v14 2 | 3 | [General] 4 | AudioFilename: audio.mp3 5 | AudioLeadIn: 0 6 | PreviewTime: 62695 7 | Countdown: 0 8 | SampleSet: Normal 9 | StackLeniency: 0.7 10 | Mode: 0 11 | LetterboxInBreaks: 0 12 | WidescreenStoryboard: 1 13 | 14 | [Editor] 15 | Bookmarks: 3524,16156,18682,28787,38892,48998,54050,61629,72998,91945,96998,99524 16 | DistanceSpacing: 1.1 17 | BeatDivisor: 8 18 | GridSize: 8 19 | TimelineZoom: 2.2 20 | 21 | [Metadata] 22 | Title:Magical Pants (Short Ver.) 23 | TitleUnicode:まじかるパンツ (Short Ver.) 24 | Artist:MOSAIC.WAV 25 | ArtistUnicode:モザイクウェブ 26 | Creator:imaginative 27 | Version:look at bg 28 | Source: 29 | Tags: 30 | BeatmapID:2467738 31 | BeatmapSetID:1183754 32 | 33 | [Difficulty] 34 | HPDrainRate:5 35 | CircleSize:5 36 | OverallDifficulty:5 37 | ApproachRate:5 38 | SliderMultiplier:1.4 39 | SliderTickRate:1 40 | 41 | [Events] 42 | //Background and Video events 43 | 0,0,"flipped pantsu!.jpg",0,0 44 | //Break Periods 45 | //Storyboard Layer 0 (Background) 46 | //Storyboard Layer 1 (Fail) 47 | //Storyboard Layer 2 (Pass) 48 | //Storyboard Layer 3 (Foreground) 49 | //Storyboard Layer 4 (Overlay) 50 | //Storyboard Sound Samples 51 | 52 | [TimingPoints] 53 | -315,315.789473684211,4,1,0,100,1,0 54 | 62842,-100,4,1,0,100,0,1 55 | 72632,-100,4,1,0,100,0,0 56 | 72948,-100,4,1,0,100,0,1 57 | 83053,-100,4,1,0,100,0,1 58 | 84316,-100,4,1,0,100,0,0 59 | 60 | 61 | [HitObjects] 62 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/osutrack/2345.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "has space", 3 | "mode": 0, 4 | "playcount": 0, 5 | "pp_rank": 0, 6 | "pp_raw": 0, 7 | "accuracy": 0, 8 | "total_score": 0, 9 | "ranked_score": 0, 10 | "count300": 0, 11 | "count50": 0, 12 | "count100": 0, 13 | "level": 0, 14 | "count_rank_a": 0, 15 | "count_rank_s": 0, 16 | "count_rank_ss": 0, 17 | "levelup": false, 18 | "first": false, 19 | "exists": true, 20 | "newhs": [] 21 | } 22 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/osutrack/2756335.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "oliebol", 3 | "mode": 0, 4 | "playcount": 0, 5 | "pp_rank": 0, 6 | "pp_raw": 0, 7 | "accuracy": 0, 8 | "total_score": 0, 9 | "ranked_score": 0, 10 | "count300": 0, 11 | "count50": 0, 12 | "count100": 0, 13 | "level": 0, 14 | "count_rank_a": 0, 15 | "count_rank_s": 0, 16 | "count_rank_ss": 0, 17 | "levelup": false, 18 | "first": false, 19 | "exists": true, 20 | "newhs": [] 21 | } 22 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/osutrack/56917.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "fartownik", 3 | "mode": "0", 4 | "playcount": 1568, 5 | "pp_rank": 3, 6 | "pp_raw": 26.25, 7 | "accuracy": 0.0062103271484375, 8 | "total_score": 3383072599, 9 | "ranked_score": 384014313, 10 | "count300": 285078, 11 | "count50": 899, 12 | "count100": 10886, 13 | "level": 0.033999999999992, 14 | "count_rank_a": 10, 15 | "count_rank_s": 8, 16 | "count_rank_ss": 1, 17 | "levelup": false, 18 | "first": false, 19 | "exists": true, 20 | "newhs": [ 21 | { 22 | "beatmap_id": "768986", 23 | "score": "33506036", 24 | "maxcombo": "1161", 25 | "count50": "0", 26 | "count100": "10", 27 | "count300": "852", 28 | "countmiss": "0", 29 | "countkatu": "9", 30 | "countgeki": "214", 31 | "perfect": "1", 32 | "enabled_mods": "24", 33 | "user_id": "56917", 34 | "date": "2016-12-25 07:26:27", 35 | "rank": "SH", 36 | "pp": "414.058", 37 | "ranking": 6 38 | }, 39 | { 40 | "beatmap_id": "693195", 41 | "score": "24034117", 42 | "maxcombo": "931", 43 | "count50": "1", 44 | "count100": "7", 45 | "count300": "964", 46 | "countmiss": "3", 47 | "countkatu": "6", 48 | "countgeki": "147", 49 | "perfect": "0", 50 | "enabled_mods": "24", 51 | "user_id": "56917", 52 | "date": "2016-12-17 04:50:25", 53 | "rank": "A", 54 | "pp": "331.885", 55 | "ranking": 88 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /tillerinobot/src/test/resources/osuv1api/api/get_beatmaps%3Fb%3D53%26mods%3D0: -------------------------------------------------------------------------------- 1 | [{"beatmapset_id":"3","beatmap_id":"53","approved":"1","total_length":"83","hit_length":"77","version":"-Crusin-","file_md5":"1d23c37a2fda439be752ae2bca06c0cd","diff_size":"5","diff_overall":"4","diff_approach":"4","diff_drain":"3","mode":"0","count_normal":"67","count_slider":"15","count_spinner":"1","submit_date":"2007-10-06 19:32:02","approved_date":"2007-10-06 19:32:02","last_update":"2007-10-06 19:32:02","artist":"Ni-Ni","title":"1,2,3,4, 007 [Wipeout Series]","creator":"MCXD","creator_id":"141","bpm":"172","source":"","tags":"","genre_id":"2","language_id":"2","favourite_count":"121","rating":"7.7178","download_unavailable":"0","audio_unavailable":"0","playcount":"88254","passcount":"42585","max_combo":"124","diff_aim":"1.0225720405578613","diff_speed":"0.6706022620201111","difficultyrating":"1.8691591024398804", 2 | "_comment": "we don't add star and aim difficulty here to test our old algorithm" 3 | }] --------------------------------------------------------------------------------