From fe121c85465ca3a82ac361c60099170cb44281f4 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sat, 20 Jun 2026 00:23:15 +0200 Subject: [PATCH] feat: economy system with minigames, bank, rob, jobs, fishing, blackjack, slots + economy domain (EconomyAccount, Job, SlotCombination, EconomyTransaction) + EconomyRepository + SqlEconomyRepository + DB schema + EconomyService (transfer, daily, deposit/withdraw, stat upgrades) + FishingService (fish grid with difficulty levels, speed bonus) + BlackjackService (dealer stands on 16, ties lose) + SlotService (weighted combos up to x50) + JobService (6 jobs with level reqs and efficiency stat) + 11 commands: balance, baltop, pay, daily, bank, rob, fish, blackjack, slot, job, work + Tests for SqlEconomyRepository + EconomyModule + EconomyCommandRegistrar wired into BotModule --- .dockerignore | 13 + .editorconfig | 12 + .env.example | 6 + .github/workflows/ci.yml | 19 + Dockerfile | 20 + FEATURES.md | 145 ++- README.md | 61 +- TODO.md | 928 ++++-------------- build.gradle | 65 +- config/checkstyle/checkstyle.xml | 90 ++ config/spotbugs/exclude.xml | 6 + docker-compose.yml | 21 + gradle/libs.versions.toml | 40 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 120 +++ settings.gradle | 6 + .../creator/loki/{ => bootstrap}/App.java | 100 +- .../ReadyListener.java | 34 +- .../java/creator/loki/command/Command.java | 32 +- .../loki/command/CommandCategories.java | 12 + .../creator/loki/command/CommandContext.java | 88 +- .../creator/loki/command/SlashCommand.java | 29 - .../social/negative/PokeCommand.java | 78 -- .../social/negative/RoastCommand.java | 68 -- .../social/negative/SlapCommand.java | 78 -- .../social/positive/CuddleCommand.java | 78 -- .../interaction/negative/TickleCommand.java | 78 -- .../positive/ComplimentCommand.java | 67 -- .../interaction/positive/FeedCommand.java | 78 -- .../interaction/positive/HugCommand.java | 61 -- .../interaction/positive/KissCommand.java | 92 -- .../interaction/positive/PatCommand.java | 91 -- .../command/impl/moderation/BanCommand.java | 126 --- .../command/impl/moderation/ClearCommand.java | 108 -- .../command/impl/moderation/KickCommand.java | 118 --- .../command/impl/setup/LogSetupCommand.java | 122 --- .../loki/command/impl/stats/RankCommand.java | 89 -- .../loki/command/impl/stats/StatsCommand.java | 75 -- .../command/impl/utility/HelpCommand.java | 159 --- .../command/impl/utility/ProfileCommand.java | 88 -- .../impl/utility/system/MetricsCommand.java | 63 -- .../java/creator/loki/config/BotConfig.java | 51 +- .../creator/loki/config/ConfigLoader.java | 106 +- .../creator/loki/core/BotInitializer.java | 239 +---- .../creator/loki/core/EventDispatcher.java | 260 ++--- .../creator/loki/core/ListenerAggregator.java | 54 + .../java/creator/loki/core/MessageCache.java | 32 + .../creator/loki/core/ShutdownManager.java | 85 ++ .../exception/CommandException.java | 45 +- .../exception/CommandExceptionHandler.java | 126 +-- .../loki/core/registry/CommandRegistrar.java | 6 + .../loki/core/registry/CommandRegistry.java | 70 +- .../core/registry/PrefixCommandRegistrar.java | 151 +-- .../core/registry/SlashCommandRegistrar.java | 34 - .../core/registry/SlashCommandRegistry.java | 67 -- .../loki/core/security/CooldownManager.java | 166 ++-- .../loki/core/security/RateLimiter.java | 320 +++--- .../java/creator/loki/database/Database.java | 102 +- .../loki/database/DatabaseMigration.java | 381 ++++--- .../creator/loki/database/DatabaseSchema.java | 361 ++++--- src/main/java/creator/loki/di/BotModule.java | 177 ++-- .../java/creator/loki/embed/EmbedFactory.java | 158 +-- .../creator/loki/embed/EmbedTemplates.java | 100 +- .../creator/loki/embed/HelpCategories.java | 75 ++ .../commands/GenerateRatingsCommand.java | 158 +++ .../config/repository}/ConfigRepository.java | 8 +- .../repository/GuildConfigRepository.java | 16 + .../PrefixedGuildConfigRepository.java | 35 + .../config/repository/StringConfigStore.java | 16 + .../loki/feature/counter/CounterManager.java | 336 +++---- .../loki/feature/counter/CounterModule.java | 27 + .../feature/counter/CounterSetupCommand.java | 159 --- .../counter/CounterSetupPrefixCommand.java | 186 ++++ .../loki/feature/counter/DailyStats.java | 132 +-- .../counter/MemberCounterListener.java | 172 ++-- .../repository/CounterStatsRepository.java | 137 +++ .../economy/EconomyCommandRegistrar.java | 63 ++ .../loki/feature/economy/EconomyModule.java | 57 ++ .../economy/command/bank/BankCommand.java | 93 ++ .../economy/command/core/BalanceCommand.java | 57 ++ .../economy/command/core/BaltopCommand.java | 57 ++ .../economy/command/core/DailyCommand.java | 55 ++ .../economy/command/core/PayCommand.java | 96 ++ .../command/minigame/BlackjackCommand.java | 148 +++ .../economy/command/minigame/FishCommand.java | 106 ++ .../economy/command/minigame/JobCommand.java | 146 +++ .../economy/command/minigame/SlotCommand.java | 96 ++ .../economy/command/minigame/WorkCommand.java | 78 ++ .../economy/command/social/RobCommand.java | 99 ++ .../economy/domain/EconomyAccount.java | 151 +++ .../economy/domain/EconomyTransaction.java | 9 + .../loki/feature/economy/domain/Job.java | 39 + .../economy/domain/SlotCombination.java | 31 + .../economy/repository/EconomyRepository.java | 17 + .../repository/SqlEconomyRepository.java | 165 ++++ .../economy/service/BlackjackService.java | 139 +++ .../economy/service/EconomyService.java | 118 +++ .../economy/service/FishingService.java | 120 +++ .../feature/economy/service/JobService.java | 91 ++ .../feature/economy/service/SlotService.java | 60 ++ .../EntertainmentCommandRegistrar.java | 64 ++ .../commands}/BoykisserCommand.java | 200 ++-- .../commands/ChoiceOptionParser.java | 78 ++ .../entertainment/commands}/CoinCommand.java | 94 +- .../entertainment/commands}/DiceCommand.java | 120 +-- .../commands}/EightBallCommand.java | 148 +-- .../entertainment/commands}/FatCommand.java | 190 ++-- .../entertainment/commands}/GayCommand.java | 194 ++-- .../commands}/GlazbobaCommand.java | 192 ++-- .../entertainment/commands}/IqCommand.java | 184 ++-- .../entertainment/commands}/MemeCommand.java | 146 +-- .../commands}/MustacheCommand.java | 208 ++-- .../entertainment/commands}/PpCommand.java | 206 ++-- .../commands/RandomChoiceCommand.java | 135 +++ .../entertainment/commands}/RateCommand.java | 208 ++-- .../entertainment/commands}/RatingConfig.java | 2 +- .../entertainment/commands}/RatingLevel.java | 2 +- .../help}/HelpMenuHandler.java | 308 +++--- .../feature/help/commands/HelpCommand.java | 128 +++ .../info/commands}/AvatarCommand.java | 116 +-- .../info/commands}/InfoCommand.java | 237 +++-- .../info/commands}/PingCommand.java | 88 +- .../info/commands/ServerStatsCommand.java | 79 ++ .../LeaderboardButtonListener.java | 106 +- .../leaderboard/LeaderboardButtons.java | 38 +- .../leaderboard/LeaderboardCommand.java} | 116 +-- .../leaderboard/LeaderboardFormatter.java | 178 ++-- .../feature/level/LevelCommandRegistrar.java | 35 + .../loki/feature/level/LevelModule.java | 73 ++ .../loki/feature/level/LevelUpNotifier.java | 198 ++-- .../loki/feature/level/MessageListener.java | 45 + .../loki/feature/level/RankCommand.java | 89 -- .../level/ResetLevelsButtonHandler.java | 18 +- .../feature/level/ResetLevelsCommand.java | 43 +- .../loki/feature/level/StatsCommand.java | 148 ++- .../loki/feature/level/VoiceTracker.java | 205 ++-- .../loki/feature/level/domain/UserStats.java | 133 +-- .../repository/SqlUserStatsRepository.java | 240 +++++ .../repository/UserStatsRepository.java | 121 ++- .../level/service/LeaderboardService.java | 52 +- .../feature/level/service/LevelService.java | 45 +- .../level/service/MessageCooldownService.java | 2 +- .../logging/LogSetupPrefixCommand.java | 161 +++ .../feature/metrics/HealthHttpServer.java | 112 +++ .../loki/feature/metrics/MetricsModule.java | 10 + .../metrics/commands/MetricsCommand.java | 81 ++ .../ModerationCommandRegistrar.java | 76 ++ .../feature/moderation/ModerationLogger.java | 238 +++++ .../feature/moderation/ModerationModule.java | 51 + .../feature/moderation/MuteRoleService.java | 131 +++ .../feature/moderation/MuteScheduler.java | 126 +++ .../moderation/commands/CaseCommand.java | 102 ++ .../moderation/commands/MuteCommand.java | 266 +++++ .../moderation/commands/SlowmodeCommand.java | 100 ++ .../moderation/commands}/SwapCommand.java | 14 +- .../moderation/commands/UnmuteCommand.java | 182 ++++ .../moderation/commands/UnwarnCommand.java | 102 ++ .../moderation/commands/WarnCommand.java | 205 ++++ .../commands/WarningsHistoryCommand.java | 112 +++ .../repository/ActiveMuteRepository.java | 106 ++ .../repository/ModerationCaseRepository.java | 120 +++ .../repository/WarningRepository.java | 142 +++ .../loki/feature/music/GuildMusicManager.java | 100 ++ .../feature/music/MusicCommandRegistrar.java | 28 + .../loki/feature/music/MusicModule.java | 11 + .../loki/feature/music/PlayerManager.java | 29 + .../loki/feature/music/SearchManager.java | 42 + .../feature/music/commands/LeaveCommand.java | 36 + .../music/commands/NowPlayingCommand.java | 69 ++ .../feature/music/commands/PlayCommand.java | 322 ++++++ .../feature/music/commands/QueueCommand.java | 67 ++ .../feature/music/commands/SkipCommand.java | 55 ++ .../feature/music/commands/StopCommand.java | 35 + .../feature/music/commands/VolumeCommand.java | 48 + .../loki/feature/profile/ProfileCommand.java | 2 +- .../loki/feature/profile/ProfileData.java | 9 +- .../loki/feature/profile/ProfileModule.java | 27 + .../loki/feature/profile/ProfileService.java | 45 +- .../feature/profile/RankCardGenerator.java | 109 +- .../rating/repository/RatingsRepository.java | 149 +++ .../reputation}/MinusRepCommand.java | 34 +- .../reputation}/RepCommand.java | 48 +- .../reputation}/RepTopCommand.java | 32 +- .../repository/ReputationRepository.java | 129 +++ .../loki/feature/role/CreateRoleCommand.java | 398 ++++---- .../loki/feature/role/CustomRoleManager.java | 196 ++-- .../loki/feature/role/DeleteRoleCommand.java | 264 ++--- .../loki/feature/role/EditRoleCommand.java | 274 +++--- .../loki/feature/role/MyRolesCommand.java | 244 ++--- .../feature/role/RoleCommandValidator.java | 134 +-- .../creator/loki/feature/role/RoleModule.java | 26 + .../role/repository/CustomRoleRepository.java | 159 +++ .../social => feature/ship}/ShipCommand.java | 224 ++--- .../ship/repository/ShipPairRepository.java | 105 ++ .../social/commands}/ComplimentCommand.java | 135 +-- .../social/commands}/CuddleCommand.java | 157 +-- .../social/commands}/FeedCommand.java | 157 +-- .../social/commands}/HugCommand.java | 123 +-- .../social/commands}/KissCommand.java | 185 ++-- .../social/commands}/PatCommand.java | 183 ++-- .../social/commands}/PokeCommand.java | 157 +-- .../social/commands}/RoastCommand.java | 137 +-- .../social/commands}/SlapCommand.java | 157 +-- .../social/commands}/TickleCommand.java | 157 +-- .../utility/UtilityCommandRegistrar.java | 87 ++ .../utility/commands}/PollCommand.java | 206 ++-- .../feature/voice/AudioPlayerSendHandler.java | 32 + .../creator/loki/feature/voice/TtsPlayer.java | 113 +++ .../feature/voice/VoiceButtonHandler.java | 103 +- .../loki/feature/voice/VoiceChannelData.java | 45 +- .../feature/voice/VoiceChannelListener.java | 41 +- .../feature/voice/VoiceChannelManager.java | 214 ++-- .../loki/feature/voice/VoiceModalHandler.java | 93 ++ .../loki/feature/voice/VoiceModule.java | 43 + .../loki/feature/voice/VoicePreset.java | 110 +-- .../loki/feature/voice/VoiceSetupCommand.java | 159 --- .../voice/VoiceSetupPrefixCommand.java | 158 +++ .../loki/feature/voice/VoiceSyncListener.java | 32 + .../feature/voice/commands/TtsCommand.java | 86 ++ .../repository/VoiceChannelRepository.java | 100 ++ .../repository/VoicePresetRepository.java | 105 ++ .../loki/feature/welcome/WelcomeListener.java | 64 ++ .../loki/feature/welcome/WelcomeModule.java | 12 + .../loki/integration/nekos/NekosLifeApi.java | 147 ++- .../java/creator/loki/level/LevelSystem.java | 193 ---- .../loki/listener/MessageListener.java | 65 -- .../loki/listener/ModerationLogger.java | 242 ----- .../interaction/SlashCommandListener.java | 75 -- .../loki/listener/voice/VoiceTracker.java | 100 -- .../loki/metrics/MetricsCollector.java | 167 ---- .../java/creator/loki/profile/LevelUtils.java | 56 -- .../creator/loki/profile/ProfileData.java | 56 -- .../loki/profile/ProfileGenerator.java | 238 ----- .../creator/loki/profile/ProfileService.java | 166 ---- .../impl/JsonUserStatsRepository.java | 50 - .../loki/util/GuildChannelResolve.java | 76 ++ .../creator/loki/util/MuteDurationParser.java | 56 ++ src/main/resources/assets/fonts/Inter/OFL.txt | 186 ++-- .../assets/fonts/Noto_Sans_Symbols/OFL.txt | 186 ++-- .../icons/clock-five-thirty-svgrepo-com.svg | 6 +- .../assets/icons/lightning-svgrepo-com.svg | 26 +- .../message-square-lines-svgrepo-com.svg | 6 +- .../assets/icons/shield-alt-1-svgrepo-com.svg | 6 +- .../assets/icons/star-svgrepo-com.svg | 6 +- .../creator/loki/config/BotConfigTest.java | 43 + .../security}/CooldownManagerTest.java | 176 ++-- .../security}/GlobalRateLimiterTest.java | 244 ++--- .../creator/loki/embed/EmbedFactoryTest.java | 155 +++ .../loki/embed/EmbedTemplatesTest.java | 87 ++ .../repository/ConfigRepositoryTest.java | 99 ++ .../repository/SqlEconomyRepositoryTest.java | 148 +++ .../commands/ChoiceOptionParserTest.java | 139 +++ .../commands/RatingLevelTest.java | 134 +++ .../leaderboard/LeaderboardFormatterTest.java | 94 ++ .../level/domain/LevelProgressTest.java | 33 + .../level/domain/LevelUpResultTest.java | 31 + .../feature/level/domain/UserStatsTest.java | 147 +++ .../feature/level/domain/XpFormulaTest.java | 151 +++ .../SqlUserStatsRepositoryTest.java | 277 ++++++ .../level/service/LevelServiceTest.java | 192 ++++ .../service/MessageCooldownServiceTest.java | 85 ++ .../metrics/MetricsCollectorTest.java | 264 ++--- .../repository/ActiveMuteRepositoryTest.java | 145 +++ .../repository/WarningRepositoryTest.java | 183 ++++ .../repository/RatingsRepositoryTest.java | 202 ++++ .../feature/role/CustomRoleManagerTest.java | 127 +++ .../repository/ShipPairRepositoryTest.java | 117 +++ .../voice/VoiceChannelManagerTest.java | 120 +++ .../creator/loki/profile/LevelUtilsTest.java | 131 --- 270 files changed, 18632 insertions(+), 11656 deletions(-) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 config/checkstyle/checkstyle.xml create mode 100644 config/spotbugs/exclude.xml create mode 100644 docker-compose.yml create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradlew rename src/main/java/creator/loki/{ => bootstrap}/App.java (95%) rename src/main/java/creator/loki/{listener => bootstrap}/ReadyListener.java (85%) create mode 100644 src/main/java/creator/loki/command/CommandCategories.java delete mode 100644 src/main/java/creator/loki/command/SlashCommand.java delete mode 100644 src/main/java/creator/loki/command/entertainment/social/negative/PokeCommand.java delete mode 100644 src/main/java/creator/loki/command/entertainment/social/negative/RoastCommand.java delete mode 100644 src/main/java/creator/loki/command/entertainment/social/negative/SlapCommand.java delete mode 100644 src/main/java/creator/loki/command/entertainment/social/positive/CuddleCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/negative/TickleCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/positive/ComplimentCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/positive/FeedCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/positive/HugCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/positive/KissCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/entertainment/interaction/positive/PatCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/moderation/BanCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/moderation/ClearCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/moderation/KickCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/setup/LogSetupCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/stats/RankCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/stats/StatsCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/utility/HelpCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/utility/ProfileCommand.java delete mode 100644 src/main/java/creator/loki/command/impl/utility/system/MetricsCommand.java create mode 100644 src/main/java/creator/loki/core/ListenerAggregator.java create mode 100644 src/main/java/creator/loki/core/MessageCache.java create mode 100644 src/main/java/creator/loki/core/ShutdownManager.java rename src/main/java/creator/loki/{ => core}/exception/CommandException.java (82%) rename src/main/java/creator/loki/{ => core}/exception/CommandExceptionHandler.java (68%) create mode 100644 src/main/java/creator/loki/core/registry/CommandRegistrar.java delete mode 100644 src/main/java/creator/loki/core/registry/SlashCommandRegistrar.java delete mode 100644 src/main/java/creator/loki/core/registry/SlashCommandRegistry.java create mode 100644 src/main/java/creator/loki/embed/HelpCategories.java create mode 100644 src/main/java/creator/loki/feature/admin/commands/GenerateRatingsCommand.java rename src/main/java/creator/loki/{database => feature/config/repository}/ConfigRepository.java (91%) create mode 100644 src/main/java/creator/loki/feature/config/repository/GuildConfigRepository.java create mode 100644 src/main/java/creator/loki/feature/config/repository/PrefixedGuildConfigRepository.java create mode 100644 src/main/java/creator/loki/feature/config/repository/StringConfigStore.java create mode 100644 src/main/java/creator/loki/feature/counter/CounterModule.java delete mode 100644 src/main/java/creator/loki/feature/counter/CounterSetupCommand.java create mode 100644 src/main/java/creator/loki/feature/counter/CounterSetupPrefixCommand.java create mode 100644 src/main/java/creator/loki/feature/counter/repository/CounterStatsRepository.java create mode 100644 src/main/java/creator/loki/feature/economy/EconomyCommandRegistrar.java create mode 100644 src/main/java/creator/loki/feature/economy/EconomyModule.java create mode 100644 src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/core/BalanceCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/core/BaltopCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/core/DailyCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/core/PayCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/minigame/FishCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/minigame/JobCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/minigame/SlotCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/minigame/WorkCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/command/social/RobCommand.java create mode 100644 src/main/java/creator/loki/feature/economy/domain/EconomyAccount.java create mode 100644 src/main/java/creator/loki/feature/economy/domain/EconomyTransaction.java create mode 100644 src/main/java/creator/loki/feature/economy/domain/Job.java create mode 100644 src/main/java/creator/loki/feature/economy/domain/SlotCombination.java create mode 100644 src/main/java/creator/loki/feature/economy/repository/EconomyRepository.java create mode 100644 src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java create mode 100644 src/main/java/creator/loki/feature/economy/service/BlackjackService.java create mode 100644 src/main/java/creator/loki/feature/economy/service/EconomyService.java create mode 100644 src/main/java/creator/loki/feature/economy/service/FishingService.java create mode 100644 src/main/java/creator/loki/feature/economy/service/JobService.java create mode 100644 src/main/java/creator/loki/feature/economy/service/SlotService.java create mode 100644 src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/BoykisserCommand.java (83%) create mode 100644 src/main/java/creator/loki/feature/entertainment/commands/ChoiceOptionParser.java rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/CoinCommand.java (74%) rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/DiceCommand.java (81%) rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/EightBallCommand.java (83%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/FatCommand.java (82%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/GayCommand.java (85%) rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/GlazbobaCommand.java (87%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/IqCommand.java (82%) rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/MemeCommand.java (83%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/MustacheCommand.java (84%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/PpCommand.java (84%) create mode 100644 src/main/java/creator/loki/feature/entertainment/commands/RandomChoiceCommand.java rename src/main/java/creator/loki/{command/impl/entertainment/games/random => feature/entertainment/commands}/RateCommand.java (84%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/RatingConfig.java (98%) rename src/main/java/creator/loki/{command/impl/entertainment/games/rating => feature/entertainment/commands}/RatingLevel.java (95%) rename src/main/java/creator/loki/{listener/interaction => feature/help}/HelpMenuHandler.java (67%) create mode 100644 src/main/java/creator/loki/feature/help/commands/HelpCommand.java rename src/main/java/creator/loki/{command/impl/utility/info => feature/info/commands}/AvatarCommand.java (82%) rename src/main/java/creator/loki/{command/impl/utility/info => feature/info/commands}/InfoCommand.java (79%) rename src/main/java/creator/loki/{command/impl/utility/info => feature/info/commands}/PingCommand.java (86%) create mode 100644 src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java rename src/main/java/creator/loki/{command/impl/stats => feature}/leaderboard/LeaderboardButtonListener.java (69%) rename src/main/java/creator/loki/{command/impl/stats => feature}/leaderboard/LeaderboardButtons.java (88%) rename src/main/java/creator/loki/{command/impl/stats/leaderboard/NewLeaderboardCommand.java => feature/leaderboard/LeaderboardCommand.java} (73%) rename src/main/java/creator/loki/{command/impl/stats => feature}/leaderboard/LeaderboardFormatter.java (89%) create mode 100644 src/main/java/creator/loki/feature/level/LevelCommandRegistrar.java create mode 100644 src/main/java/creator/loki/feature/level/LevelModule.java create mode 100644 src/main/java/creator/loki/feature/level/MessageListener.java delete mode 100644 src/main/java/creator/loki/feature/level/RankCommand.java create mode 100644 src/main/java/creator/loki/feature/level/repository/SqlUserStatsRepository.java rename src/main/java/creator/loki/{ => feature/level}/repository/UserStatsRepository.java (68%) create mode 100644 src/main/java/creator/loki/feature/logging/LogSetupPrefixCommand.java create mode 100644 src/main/java/creator/loki/feature/metrics/HealthHttpServer.java create mode 100644 src/main/java/creator/loki/feature/metrics/MetricsModule.java create mode 100644 src/main/java/creator/loki/feature/metrics/commands/MetricsCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/ModerationCommandRegistrar.java create mode 100644 src/main/java/creator/loki/feature/moderation/ModerationLogger.java create mode 100644 src/main/java/creator/loki/feature/moderation/ModerationModule.java create mode 100644 src/main/java/creator/loki/feature/moderation/MuteRoleService.java create mode 100644 src/main/java/creator/loki/feature/moderation/MuteScheduler.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/CaseCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/MuteCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/SlowmodeCommand.java rename src/main/java/creator/loki/{command/impl/utility => feature/moderation/commands}/SwapCommand.java (94%) create mode 100644 src/main/java/creator/loki/feature/moderation/commands/UnmuteCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/UnwarnCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/WarnCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/commands/WarningsHistoryCommand.java create mode 100644 src/main/java/creator/loki/feature/moderation/repository/ActiveMuteRepository.java create mode 100644 src/main/java/creator/loki/feature/moderation/repository/ModerationCaseRepository.java create mode 100644 src/main/java/creator/loki/feature/moderation/repository/WarningRepository.java create mode 100644 src/main/java/creator/loki/feature/music/GuildMusicManager.java create mode 100644 src/main/java/creator/loki/feature/music/MusicCommandRegistrar.java create mode 100644 src/main/java/creator/loki/feature/music/MusicModule.java create mode 100644 src/main/java/creator/loki/feature/music/PlayerManager.java create mode 100644 src/main/java/creator/loki/feature/music/SearchManager.java create mode 100644 src/main/java/creator/loki/feature/music/commands/LeaveCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/NowPlayingCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/PlayCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/QueueCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/SkipCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/StopCommand.java create mode 100644 src/main/java/creator/loki/feature/music/commands/VolumeCommand.java create mode 100644 src/main/java/creator/loki/feature/profile/ProfileModule.java create mode 100644 src/main/java/creator/loki/feature/rating/repository/RatingsRepository.java rename src/main/java/creator/loki/{command/impl/stats => feature/reputation}/MinusRepCommand.java (70%) rename src/main/java/creator/loki/{command/impl/stats => feature/reputation}/RepCommand.java (63%) rename src/main/java/creator/loki/{command/impl/stats => feature/reputation}/RepTopCommand.java (76%) create mode 100644 src/main/java/creator/loki/feature/reputation/repository/ReputationRepository.java create mode 100644 src/main/java/creator/loki/feature/role/RoleModule.java create mode 100644 src/main/java/creator/loki/feature/role/repository/CustomRoleRepository.java rename src/main/java/creator/loki/{command/impl/entertainment/interaction/social => feature/ship}/ShipCommand.java (70%) create mode 100644 src/main/java/creator/loki/feature/ship/repository/ShipPairRepository.java rename src/main/java/creator/loki/{command/entertainment/social/positive => feature/social/commands}/ComplimentCommand.java (56%) rename src/main/java/creator/loki/{command/impl/entertainment/interaction/positive => feature/social/commands}/CuddleCommand.java (80%) rename src/main/java/creator/loki/{command/entertainment/social/positive => feature/social/commands}/FeedCommand.java (80%) rename src/main/java/creator/loki/{command/entertainment/social/positive => feature/social/commands}/HugCommand.java (74%) rename src/main/java/creator/loki/{command/entertainment/social/positive => feature/social/commands}/KissCommand.java (83%) rename src/main/java/creator/loki/{command/entertainment/social/positive => feature/social/commands}/PatCommand.java (92%) rename src/main/java/creator/loki/{command/impl/entertainment/interaction/negative => feature/social/commands}/PokeCommand.java (80%) rename src/main/java/creator/loki/{command/impl/entertainment/interaction/negative => feature/social/commands}/RoastCommand.java (63%) rename src/main/java/creator/loki/{command/impl/entertainment/interaction/negative => feature/social/commands}/SlapCommand.java (80%) rename src/main/java/creator/loki/{command/entertainment/social/negative => feature/social/commands}/TickleCommand.java (81%) create mode 100644 src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java rename src/main/java/creator/loki/{command/impl/utility => feature/utility/commands}/PollCommand.java (89%) create mode 100644 src/main/java/creator/loki/feature/voice/AudioPlayerSendHandler.java create mode 100644 src/main/java/creator/loki/feature/voice/TtsPlayer.java create mode 100644 src/main/java/creator/loki/feature/voice/VoiceModalHandler.java create mode 100644 src/main/java/creator/loki/feature/voice/VoiceModule.java delete mode 100644 src/main/java/creator/loki/feature/voice/VoiceSetupCommand.java create mode 100644 src/main/java/creator/loki/feature/voice/VoiceSetupPrefixCommand.java create mode 100644 src/main/java/creator/loki/feature/voice/VoiceSyncListener.java create mode 100644 src/main/java/creator/loki/feature/voice/commands/TtsCommand.java create mode 100644 src/main/java/creator/loki/feature/voice/repository/VoiceChannelRepository.java create mode 100644 src/main/java/creator/loki/feature/voice/repository/VoicePresetRepository.java create mode 100644 src/main/java/creator/loki/feature/welcome/WelcomeListener.java create mode 100644 src/main/java/creator/loki/feature/welcome/WelcomeModule.java delete mode 100644 src/main/java/creator/loki/level/LevelSystem.java delete mode 100644 src/main/java/creator/loki/listener/MessageListener.java delete mode 100644 src/main/java/creator/loki/listener/ModerationLogger.java delete mode 100644 src/main/java/creator/loki/listener/interaction/SlashCommandListener.java delete mode 100644 src/main/java/creator/loki/listener/voice/VoiceTracker.java delete mode 100644 src/main/java/creator/loki/metrics/MetricsCollector.java delete mode 100644 src/main/java/creator/loki/profile/LevelUtils.java delete mode 100644 src/main/java/creator/loki/profile/ProfileData.java delete mode 100644 src/main/java/creator/loki/profile/ProfileGenerator.java delete mode 100644 src/main/java/creator/loki/profile/ProfileService.java delete mode 100644 src/main/java/creator/loki/repository/impl/JsonUserStatsRepository.java create mode 100644 src/main/java/creator/loki/util/GuildChannelResolve.java create mode 100644 src/main/java/creator/loki/util/MuteDurationParser.java create mode 100644 src/test/java/creator/loki/config/BotConfigTest.java rename src/test/java/creator/loki/{util => core/security}/CooldownManagerTest.java (95%) rename src/test/java/creator/loki/{util => core/security}/GlobalRateLimiterTest.java (95%) create mode 100644 src/test/java/creator/loki/embed/EmbedFactoryTest.java create mode 100644 src/test/java/creator/loki/embed/EmbedTemplatesTest.java create mode 100644 src/test/java/creator/loki/feature/config/repository/ConfigRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/economy/repository/SqlEconomyRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/entertainment/commands/ChoiceOptionParserTest.java create mode 100644 src/test/java/creator/loki/feature/entertainment/commands/RatingLevelTest.java create mode 100644 src/test/java/creator/loki/feature/leaderboard/LeaderboardFormatterTest.java create mode 100644 src/test/java/creator/loki/feature/level/domain/LevelProgressTest.java create mode 100644 src/test/java/creator/loki/feature/level/domain/LevelUpResultTest.java create mode 100644 src/test/java/creator/loki/feature/level/domain/UserStatsTest.java create mode 100644 src/test/java/creator/loki/feature/level/domain/XpFormulaTest.java create mode 100644 src/test/java/creator/loki/feature/level/repository/SqlUserStatsRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/level/service/LevelServiceTest.java create mode 100644 src/test/java/creator/loki/feature/level/service/MessageCooldownServiceTest.java rename src/test/java/creator/loki/{ => feature}/metrics/MetricsCollectorTest.java (95%) create mode 100644 src/test/java/creator/loki/feature/moderation/repository/ActiveMuteRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/moderation/repository/WarningRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/rating/repository/RatingsRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/role/CustomRoleManagerTest.java create mode 100644 src/test/java/creator/loki/feature/ship/repository/ShipPairRepositoryTest.java create mode 100644 src/test/java/creator/loki/feature/voice/VoiceChannelManagerTest.java delete mode 100644 src/test/java/creator/loki/profile/LevelUtilsTest.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c905585 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.gitattributes +.env +.gradle +build +bin +out +.idea +.vscode +*.md +logs +data diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a749a38 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..279471a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_BOT_TOKEN=your_token_here +BOT_PREFIX=! +BOT_ACTIVITY=Используй !help +HEALTH_HTTP_PORT=8712 +SUPER_ADMIN_ID=your_discord_user_id +DB_PATH=data/bot.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae75894 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9a728ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build +COPY gradle gradle +COPY gradlew settings.gradle build.gradle ./ +COPY src src +RUN ./gradlew jar --no-daemon --stacktrace + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S bot && adduser -S bot -G bot +WORKDIR /app +COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar +COPY --from=builder /build/data/ ./data/ +RUN mkdir -p /app/data && chown -R bot:bot /app +USER bot +VOLUME /app/data +EXPOSE 8712 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8712/health || exit 1 +ENV DB_PATH=/app/data/bot.db +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/FEATURES.md b/FEATURES.md index 1cbead3..007c0c5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,7 +2,7 @@ ## 📋 Описание -Многофункциональный Discord бот с системой уровней, личными голосовыми каналами, модерацией и развлекательными командами. +Многофункциональный Discord бот с системой уровней, личными голосовыми каналами, модерацией и развлекательными командами. **Все команды — префиксные** (ниже для краткости префикс `!`; реальный префикс задаётся в конфиге бота). **Технологии:** - Java 21 @@ -20,8 +20,8 @@ - 10 XP за сообщение (cooldown 60 сек) - 1 XP за минуту в войсе - **Команды:** - - `/rank [@user]` - карточка ранга - - `/leaderboard` - топ по уровням/сообщениям/войсу + - `!rank [@user]` — карточка ранга + - `!leaderboard` — топ по уровням/сообщениям/войсу - **Уведомления:** Красивые embed при повышении уровня - **Награды:** Роли за достижение уровней (5, 10, 20, 30, 50) @@ -34,14 +34,14 @@ - 🎨 Битрейт - 🚫 Удалить - **Автоудаление:** Пустые каналы удаляются автоматически -- **Настройка:** `/voice-setup` +- **Настройка:** `!voice-setup` ### 📊 Счетчик участников - **Автообновление:** Название канала с количеством участников - **Формат:** `【👥】Участники: 123` - **Статистика:** Присоединения/выходы за день - **История:** 30 дней -- **Настройка:** `/counter-setup` +- **Настройка:** `!counter-setup` ### 📝 Логи модерации - **Что логируется:** @@ -50,48 +50,47 @@ - 👢 Кики - 🔇 Муты/размуты - 🎤 Отключение микрофона/звука -- **Настройка:** `/log-setup moderation` +- **Настройка:** `!log-setup moderation` / `!log-setup levelup` ### 🎮 Развлекательные команды **Игры:** -- `/coin` - подбросить монетку -- `/dice` - бросить кубик -- `/8ball` - магический шар -- `/ship @user1 @user2` - совместимость -- `/rate` - оценить что-то -- `/gay/@fat/@iq/@pp` - процентомеры +- `!coin` — подбросить монетку +- `!dice` — бросить кубик +- `!8ball` — магический шар +- `!ship @user1 @user2` — совместимость +- `!rate` — оценить что-то +- `!gay`, `!fat`, `!iq`, `!pp` — процентомеры -**Взаимодействия:** -- `/hug/@kiss/@pat/@poke` - обнять/поцеловать/погладить/ткнуть -- `/slap/@tickle/@cuddle` - шлепнуть/пощекотать/обнять -- `/compliment/@roast` - комплимент/жарка -- `/feed` - покормить +**GIF-интерактив (nekos.life):** +- `!hug`, `!kiss`, `!poke`, `!slap`, `!tickle`, `!cuddle`, `!feed` + +**Развлечения (текст):** +- `!pat` — погладить (без GIF, несколько pat-pat) +- `!compliment`, `!roast` — комплимент / жарка ### 🛡️ Модерация -- `/ban/@kick` - бан/кик пользователя -- `/clear ` - очистить сообщения +- `!ban`, `!kick` — бан / кик пользователя +- `!clear ` — очистить сообщения - Автологирование всех действий ### 🎨 Кастомные роли -- `/createrole` - создать роль -- `/deleterole` - удалить роль -- `/editrole` - редактировать роль -- `/myroles` - мои роли +- `!createrole` — создать роль +- `!deleterole` — удалить роль +- `!editrole` — редактировать роль +- `!myroles` — мои роли ### 📈 Статистика -- `/stats [@user]` - статистика пользователя -- `/rep/@minusrep` - репутация -- `/reptop` - топ по репутации -- `/serverstats` - статистика сервера +- `!stats` — статистика сервера +- `!rep`, `!minusrep` — репутация +- `!reptop` — топ по репутации ### 🔧 Утилиты -- `/avatar [@user]` - аватар пользователя -- `/info [@user]` - информация о пользователе -- `/poll` - создать голосование -- `/ping` - пинг бота -- `/help` - справка -- `/tts ` - озвучить текст +- `!avatar [@user]` — аватар пользователя +- `!info [@user]` — информация о пользователе +- `!poll` — создать голосование +- `!ping` — пинг бота +- `!help` — справка --- @@ -99,26 +98,72 @@ ### Личные войс-каналы ``` -/voice-setup trigger - канал-триггер -/voice-setup category - категория для каналов -/voice-setup text-channel - текстовый канал для панелей -/voice-setup info - показать настройки +!voice-setup trigger - канал-триггер +!voice-setup category - категория для каналов +!voice-setup text-channel - текстовый канал для панелей +!voice-setup info - показать настройки ``` ### Счетчик участников ``` -/counter-setup channel - установить канал -/counter-setup format - формат ({count}) -/counter-setup info - показать настройки +!counter-setup channel - установить канал +!counter-setup format - формат ({count}) +!counter-setup info - показать настройки ``` ### Логирование ``` -/log-setup moderation - канал логов модерации -/log-setup levelup - канал уведомлений level up -/log-setup info - показать настройки +!log-setup moderation - канал логов модерации +!log-setup levelup - канал уведомлений level up +!log-setup info - показать настройки ``` +### Сброс уровней (одноразовая команда) +``` +!resetlevels +``` +**Описание:** Сбрасывает уровни и XP всех пользователей на сервере. + +**Требования:** +- Права администратора +- Команда работает только один раз +- Требует подтверждения через кнопки + +**Что сбрасывается:** +- Уровень → 0 +- XP → 0 + +**Что сохраняется:** +- Время в голосовых каналах +- Количество сообщений +- Репутация + +--- + +## 🔐 Команды супер-админа + +**Супер-админ ID:** `1414975197269987471` + +### Генерация рейтингов +``` +!generateratings +``` +**Описание:** Генерирует все rating значения для всех участников сервера. + +**Генерируемые рейтинги:** +- Ship (0-100%) +- Gay (0-100%) +- IQ (0-200) +- PP (0-30 см) +- Mustache (0-100, редко 1000-1500) +- Fat (0-100%) +- Boykisser (0-100%) + +**Особенности:** +- Не перезаписывает существующие значения +- Пропускает ботов +- Генерирует для всех участников сервера + --- ## 🗄️ База данных @@ -143,15 +188,15 @@ gradle run 2. **Настройка:** ``` -/voice-setup trigger #создать-канал -/voice-setup category Личные каналы -/voice-setup text-channel #управление +!voice-setup trigger #создать-канал +!voice-setup category Личные каналы +!voice-setup text-channel #управление -/counter-setup channel #счетчик -/counter-setup format 【👥】Участники: {count} +!counter-setup channel #счетчик +!counter-setup format 【👥】Участники: {count} -/log-setup moderation #логи -/log-setup levelup #level-up +!log-setup moderation #логи +!log-setup levelup #level-up ``` 3. **Готово!** Зайди в канал-триггер → создастся личный канал diff --git a/README.md b/README.md index 95667ff..0ad8203 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,61 @@ # Запуск gradle run -# Настройка через Discord -/voice-setup trigger #создать-канал -/counter-setup channel #счетчик -/log-setup moderation #логи +# Настройка через Discord (префикс по умолчанию «!») +!voice-setup trigger #создать-канал +!counter-setup channel #счетчик +!log-setup moderation #логи ``` +## 🔧 Административные команды + +### Сброс уровней (одноразовая команда) +``` +!resetlevels +``` +**Описание:** Сбрасывает уровни и XP всех пользователей на сервере. Сохраняет время в войсе и количество сообщений. + +**Требования:** +- Права администратора +- Команда работает только один раз +- Требует подтверждения через кнопки + +**Что сбрасывается:** +- Уровень → 0 +- XP → 0 + +**Что сохраняется:** +- Время в голосовых каналах +- Количество сообщений +- Репутация + +--- + +### Генерация рейтингов (супер-админ) +``` +!generateratings +``` +**Описание:** Генерирует все rating значения (ship, gay, iq, pp, mustache, fat, boykisser) для всех участников сервера. + +**Требования:** +- Только для супер-админа (ID: `1414975197269987471`) +- Не перезаписывает существующие значения +- Сохраняет в SQLite базу данных + +**Генерируемые рейтинги:** +- Ship (0-100%) +- Gay (0-100%) +- IQ (0-200) +- PP (0-30 см) +- Mustache (0-100, редко 1000-1500) +- Fat (0-100%) +- Boykisser (0-100%) + +**Особенности:** +- Хранится в таблице `ratings` в SQLite +- Пропускает ботов +- Показывает статистику генерации + ## 📚 Документация - **[FEATURES.md](FEATURES.md)** - Полный список функций @@ -41,6 +90,10 @@ gradle run - Java 21+ - Gradle 8.x +## ❤️ Health (HTTP) + +В `.env` можно задать **`HEALTH_HTTP_PORT`** (0–65535, **0** = не поднимать сервер). После готовности JDA слушает **`GET /health`** и отдаёт JSON (uptime, счётчики команд, версия из манифеста или `dev`). + --- **Версия:** 2.0 | **Лицензия:** MIT diff --git a/TODO.md b/TODO.md index 2d84041..deac198 100644 --- a/TODO.md +++ b/TODO.md @@ -1,746 +1,254 @@ -# TODO - План реализации функций - -## Правила разработки - -### Архитектура и код: -- KISS, DRY, SOLID принципы -- Файлы максимум 150-200 строк, стараться делать 100 строк -- В одной папке не больше 6 файлов -- Не создавать новые MD файлы без необходимости -- Разделение по доменам, а не по типам артефактов - -### Принципы структуры: -- Пакет = домен/фича, а не тип файла (не "команды/листенеры", а "profile/level/voice") -- Каждая фича содержит свои команды, сервисы, репозитории -- Единый слой персистентности через repository/ -- Интеграции в отдельном пакете integration/ - ---- - -## Рефакторинг структуры проекта - -### Целевая оценка: 7/10 - -**Проблемы:** -1. `util/` - мусорная корзина (CooldownManager, RateLimiter, NekosLifeApi) -2. Два механизма персистентности (data/ и database/) без абстракции -3. `command/impl/entertainment/` - 23 файла в одном пакете -4. `exception/` - избыточный пакет для 2 файлов -5. `embed/` - presentation layer оторван от domain -6. `listener/` - смешаны разные ответственности -7. Техническое разделение вместо доменного -8. **LevelSystem - God Class (5/10):** - - Нарушение SRP: хранение + кэширование + формулы + лидерборды + прогресс-бары - - Прямая зависимость от JsonDataStore (нет интерфейса Repository) - - O(n) на каждое сообщение (читает/пишет весь JSON) - - Кэш messageTimestamps не персистентный (теряется при рестарте) - - Рассогласование формул: комментарий говорит 200, код использует 100 - - LevelUpResult и LevelProgress - вложенные классы вместо отдельных - - getAllStats() возвращает mutable map (можно сломать консистентность) -9. **UserStats - анемичная модель (6/10):** - - Сеттеры позволяют нарушить инварианты (setLevel(999) без XP) - - incrementMessages() скрыто меняет lastMessageTime (side effect) - - Поля lastMessageTime и lastVoiceJoin не используются системой - - Нет equals/hashCode для использования в коллекциях - -### Целевая структура (по доменам): - -``` -src/main/java/creator/loki/ -├── App.java # Точка входа (временно, потом в bootstrap/) -│ -├── core/ # Ядро системы -│ ├── BotInitializer -│ ├── EventDispatcher -│ ├── registry/ # Регистрация команд -│ │ ├── CommandRegistry -│ │ ├── SlashCommandRegistry -│ │ ├── PrefixCommandRegistrar -│ │ └── SlashCommandRegistrar -│ ├── security/ # Безопасность и ограничения -│ │ ├── RateLimiter # (из util/) -│ │ └── CooldownManager # (из util/) -│ └── exception/ # Глобальная обработка ошибок -│ └── CommandExceptionHandler -│ -├── command/ # Базовые интерфейсы команд -│ ├── Command.java -│ ├── CommandContext.java -│ ├── CommandException.java # (из exception/) -│ ├── SlashCommand.java -│ ├── moderation/ # Модерация (3 файла) -│ │ ├── BanCommand -│ │ ├── KickCommand -│ │ └── ClearCommand -│ ├── entertainment/ # Развлечения -│ │ ├── games/ # Игры (12 файлов) -│ │ └── social/ # Взаимодействия (11 файлов) -│ ├── utility/ # Утилиты (5 файлов) -│ │ ├── HelpCommand -│ │ ├── PingCommand -│ │ ├── InfoCommand -│ │ ├── AvatarCommand -│ │ └── PollCommand -│ └── system/ # Системные команды -│ └── MetricsCommand # (из utility/) -│ -├── feature/ # Крупные фичи/домены -│ ├── level/ # Система уровней -│ │ ├── LevelSystem -│ │ ├── UserStats -│ │ ├── LevelUtils # (из profile/) -│ │ ├── RankCommand # (из command/stats/) -│ │ ├── StatsCommand # (из command/stats/) -│ │ ├── LevelUpNotifier # (из listener/) -│ │ └── VoiceTracker # (из listener/) -│ │ -│ ├── profile/ # Профили пользователей -│ │ ├── ProfileService -│ │ ├── ProfileGenerator -│ │ ├── ProfileData -│ │ ├── ProfileCommand # (из command/utility/) -│ │ └── ProfileEmbedFactory # (из embed/) -│ │ -│ ├── reputation/ # Система репутации -│ │ ├── ReputationService # (логика из RepCommand) -│ │ ├── RepCommand # (из command/stats/) -│ │ ├── MinusRepCommand # (из command/stats/) -│ │ └── RepTopCommand # (из command/stats/) -│ │ -│ ├── leaderboard/ # Таблица лидеров -│ │ ├── LeaderboardCommand # (NewLeaderboardCommand) -│ │ ├── LeaderboardService # (логика из команды) -│ │ ├── LeaderboardFormatter -│ │ ├── LeaderboardButtons -│ │ └── LeaderboardButtonListener -│ │ -│ ├── voice/ # Личные голосовые каналы -│ │ ├── VoiceChannelManager -│ │ ├── VoiceChannelData -│ │ ├── VoicePreset -│ │ ├── VoiceSetupCommand # (из command/setup/) -│ │ ├── VoiceChannelListener # (из listener/) -│ │ └── VoiceButtonHandler # (из listener/) -│ │ -│ ├── counter/ # Счетчик участников -│ │ ├── CounterManager -│ │ ├── DailyStats -│ │ ├── CounterSetupCommand # (из command/setup/) -│ │ └── MemberCounterListener # (из listener/) -│ │ -│ └── role/ # Кастомные роли -│ ├── CustomRoleManager -│ ├── CreateRoleCommand # (из command/roles/) -│ ├── DeleteRoleCommand # (из command/roles/) -│ ├── EditRoleCommand # (из command/roles/) -│ ├── MyRolesCommand # (из command/roles/) -│ └── RoleCommandValidator # (из command/roles/) -│ -├── event/ # Обработчики событий Discord -│ ├── ReadyListener # (из listener/) -│ ├── MessageListener # (из listener/) -│ ├── SlashCommandListener # (из listener/) -│ ├── HelpMenuHandler # (из listener/) -│ └── ModerationLogger # (из listener/) -│ -├── repository/ # Единый слой данных -│ ├── UserRepository # Интерфейс -│ ├── GuildConfigRepository # Интерфейс -│ └── impl/ -│ ├── JsonUserRepository # (из data/) -│ └── SqliteConfigRepository # (из database/) -│ -├── database/ # Инфраструктура БД -│ ├── Database -│ ├── DatabaseSchema -│ └── DatabaseMigration -│ -├── integration/ # Внешние API -│ └── nekos/ -│ └── NekosLifeApi # (из util/) -│ -├── config/ # Конфигурация -│ ├── BotConfig -│ └── ConfigLoader -│ -├── embed/ # Общие шаблоны embed -│ ├── EmbedFactory -│ └── EmbedTemplates -│ -├── metrics/ # Метрики -│ └── MetricsCollector -│ -└── di/ # Dependency Injection - └── BotModule -``` - -### План действий (этапы): - -#### Этап 1: Фундамент (делаем первым) - -**1. Создать repository/ (средний → высокий приоритет)** -- [x] Создать `repository/` -- [x] Создать интерфейс `repository/UserStatsRepository.java` -- [x] Создать `repository/impl/` -- [x] Создать `repository/impl/JsonUserStatsRepository.java` (обёртка над JsonDataStore) -- [ ] Создать интерфейс `repository/GuildConfigRepository.java` -- [ ] Рефакторить `database/ConfigRepository` → использовать интерфейс - -**2. Реорганизовать util/ (мусорная корзина)** -- [x] Создать `core/security/` -- [x] Переместить `util/CooldownManager` → `core/security/CooldownManager` -- [x] Переместить `util/GlobalRateLimiter` → `core/security/RateLimiter` -- [x] Создать `integration/nekos/` -- [x] Переместить `util/NekosLifeApi` → `integration/nekos/NekosLifeApi` -- [x] Удалить пустую папку `util/` -- [x] Обновить все импорты в командах -- [x] Добавить `UserStatsRepository` в `BotModule` - -**3. Удалить импорты AudioManager и мертвые ссылки** -- [x] Удалить `import creator.loki.audio.AudioManager` из `BotInitializer` -- [x] Удалить `import creator.loki.audio.AudioManager` из `PrefixCommandRegistrar` -- [x] Удалить `import creator.loki.audio.AudioManager` из `SlashCommandRegistrar` -- [x] Удалить параметр `AudioManager audioManager` из конструкторов -- [x] Удалить поля `private final AudioManager audioManager` -- [x] Удалить `registry.register(new TtsCommand(...))` из `PrefixCommandRegistrar` -- [x] Удалить `registry.register(new TtsSlashCommand(...))` из `SlashCommandRegistrar` -- [x] Исправить `registry.register(new NewLeaderboardCommand(...))` → использовать новый путь -- [x] Удалить все `registry.register(new *SlashCommand(...))` из `SlashCommandRegistrar` -- [x] Удалить `import creator.loki.command.slash.*` из `SlashCommandRegistrar` -- [x] Проверить компиляцию: `gradle build` - ---- - -#### Этап 2: feature/level/ (зависит от Этапа 1) - -**4. Создать feature/level/domain/** -- [x] Создать `feature/level/` -- [x] Создать `feature/level/domain/` -- [x] Создать `feature/level/domain/XpFormula.java` -- [x] Вынести `LevelUpResult` из `LevelSystem` → `feature/level/domain/LevelUpResult.java` -- [x] Вынести `LevelProgress` из `LevelSystem` → `feature/level/domain/LevelProgress.java` -- [x] Переместить `level/UserStats` → `feature/level/domain/UserStats.java` -- [x] **НЕ делать immutable сейчас** (Gson требует сеттеры, сделаем после Repository) -- [x] Добавить `equals()` и `hashCode()` в `UserStats` -- [x] Переименовать `incrementMessages()` → `recordMessage(long timestamp)` - -**5. Создать feature/level/service/** -- [x] Создать `feature/level/service/` -- [x] Создать `feature/level/service/MessageCooldownService.java` -- [x] Создать `feature/level/service/LeaderboardService.java` -- [x] Рефакторить `level/LevelSystem` → `feature/level/service/LevelService.java` -- [x] Использовать `UserStatsRepository` вместо `JsonDataStore` -- [x] Использовать `XpFormula` для вычислений -- [x] Использовать `MessageCooldownService` для кулдаунов -- [x] Убрать методы лидерборда (в `LeaderboardService`) -- [x] Обновить все импорты UserStats на новый путь -- [x] Удалить старый `level/UserStats.java` -- [x] Добавить сервисы в `BotModule` - -**6. Переместить команды и листенеры в feature/level/** -- [x] Переместить `command/impl/stats/RankCommand` → `feature/level/RankCommand` -- [x] Переместить `command/impl/stats/StatsCommand` → `feature/level/StatsCommand` -- [x] Переместить `listener/LevelUpNotifier` → `feature/level/LevelUpNotifier` -- [x] Переместить `listener/VoiceTracker` → `feature/level/VoiceTracker` -- [x] Обновить все импорты -- [x] Обновить `BotInitializer`, `BotModule`, `PrefixCommandRegistrar` -- [x] Обновить команды для использования `LevelService` вместо `LevelSystem` - -**7. Создать команду сброса уровней (миграция на новую систему)** -- [ ] Создать `feature/level/ResetLevelsCommand.java`: - - Только для администраторов - - Подтверждение через кнопки (Да/Нет) - - Сброс всех уровней и XP - - Очистка таблицы `user_stats` в БД - - Логирование действия - - Уведомление в канал логов - - Embed с предупреждением о необратимости -- [ ] Зарегистрировать команду в `PrefixCommandRegistrar` -- [ ] Добавить в категорию "Настройка" (только админы) - ---- - -#### Этап 3: Остальные feature/ (параллельно, независимо) - -**7. Создать feature/voice/ (голосовые каналы)** -- [ ] Создать `feature/voice/` -- [ ] Переместить `voice/VoiceChannelManager` → `feature/voice/` -- [ ] Переместить `voice/VoiceChannelData` → `feature/voice/` -- [ ] Переместить `voice/VoicePreset` → `feature/voice/` -- [ ] Переместить `command/impl/setup/VoiceSetupCommand` → `feature/voice/` -- [ ] Переместить `listener/VoiceChannelListener` → `feature/voice/` -- [ ] Переместить `listener/VoiceButtonHandler` → `feature/voice/` -- [ ] Удалить пустую папку `voice/` -- [ ] Обновить импорты - -**8. Создать feature/counter/ (счетчик участников)** -- [ ] Создать `feature/counter/` -- [ ] Переместить `counter/CounterManager` → `feature/counter/` -- [ ] Переместить `counter/DailyStats` → `feature/counter/` -- [ ] Переместить `command/impl/setup/CounterSetupCommand` → `feature/counter/` -- [ ] Переместить `listener/MemberCounterListener` → `feature/counter/` -- [ ] Удалить пустую папку `counter/` -- [ ] Обновить импорты - -**9. Создать feature/role/ (кастомные роли)** -- [ ] Создать `feature/role/` -- [ ] Переместить `role/CustomRoleManager` → `feature/role/` -- [ ] Переместить `command/impl/roles/CreateRoleCommand` → `feature/role/` -- [ ] Переместить `command/impl/roles/DeleteRoleCommand` → `feature/role/` -- [ ] Переместить `command/impl/roles/EditRoleCommand` → `feature/role/` -- [ ] Переместить `command/impl/roles/MyRolesCommand` → `feature/role/` -- [ ] Переместить `command/impl/roles/RoleCommandValidator` → `feature/role/` -- [ ] Удалить пустые папки `role/` и `command/impl/roles/` -- [ ] Обновить импорты - -**10. Создать feature/reputation/ (система репутации)** -- [ ] Создать `feature/reputation/` -- [ ] Создать `feature/reputation/ReputationService.java` (вынести логику из команд) -- [ ] Переместить `command/impl/stats/RepCommand` → `feature/reputation/` -- [ ] Переместить `command/impl/stats/MinusRepCommand` → `feature/reputation/` -- [ ] Переместить `command/impl/stats/RepTopCommand` → `feature/reputation/` -- [ ] Рефакторить команды: использовать `ReputationService` -- [ ] Обновить импорты - -**11. Создать feature/leaderboard/ (таблица лидеров)** -- [ ] Создать `feature/leaderboard/` -- [ ] Переместить `command/impl/stats/leaderboard/NewLeaderboardCommand` → `feature/leaderboard/LeaderboardCommand` -- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardFormatter` → `feature/leaderboard/` -- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardButtons` → `feature/leaderboard/` -- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardButtonListener` → `feature/leaderboard/` -- [ ] Создать `feature/leaderboard/LeaderboardService.java` (использовать из level/) -- [ ] Удалить пустую папку `command/impl/stats/` -- [ ] Обновить импорты - -**12. Создать feature/profile/ (профили пользователей)** -- [ ] Создать `feature/profile/` -- [ ] Переместить `profile/ProfileService` → `feature/profile/` -- [ ] Переместить `profile/ProfileGenerator` → `feature/profile/` -- [ ] Переместить `profile/ProfileData` → `feature/profile/` -- [ ] Переместить `command/impl/utility/ProfileCommand` → `feature/profile/` -- [ ] Создать `feature/profile/ProfileEmbedFactory.java` (вынести из команды) -- [ ] Удалить пустую папку `profile/` -- [ ] Обновить импорты - -**13. Создать feature/metrics/ (метрики системы)** -- [ ] Создать `feature/metrics/` -- [ ] Переместить `metrics/MetricsCollector` → `feature/metrics/` -- [ ] Переместить `command/impl/utility/MetricsCommand` → `feature/metrics/` -- [ ] Создать `feature/metrics/MetricsService.java` (если нужна логика) -- [ ] Удалить пустую папку `metrics/` -- [ ] Обновить импорты - ---- - -#### Этап 4: Инфраструктура (после feature/) - -**14. Реорганизовать event/ (listener)** -- [ ] Создать `event/` -- [ ] Создать `event/gateway/` (JDA Gateway events) -- [ ] Переместить `listener/ReadyListener` → `event/gateway/` -- [ ] Переместить `listener/MessageListener` → `event/gateway/` -- [ ] Создать `event/gateway/GuildListener.java` (member join/leave + moderation) -- [ ] Переместить `listener/ModerationLogger` → `event/gateway/GuildListener` (или отдельно) -- [ ] Создать `event/interaction/` (Button/Menu interactions) -- [ ] Переместить `listener/SlashCommandListener` → `event/interaction/` -- [ ] Переместить `listener/HelpMenuHandler` → `event/interaction/` -- [ ] Удалить пустую папку `listener/` -- [ ] Обновить импорты - -**15. Реорганизовать core/** -- [ ] Создать `core/registry/` -- [ ] Переместить `core/CommandRegistry` → `core/registry/` -- [ ] Переместить `core/SlashCommandRegistry` → `core/registry/` -- [ ] Переместить `core/PrefixCommandRegistrar` → `core/registry/` -- [ ] Переместить `core/SlashCommandRegistrar` → `core/registry/` -- [ ] Создать `core/exception/` -- [ ] Переместить `exception/CommandExceptionHandler` → `core/exception/` -- [ ] Переместить `exception/CommandException` → `command/CommandException` -- [ ] Удалить пустую папку `exception/` -- [ ] Обновить импорты - -**16. Почистить command/** -- [ ] Переименовать `command/impl/entertainment/interaction/` → `command/entertainment/social/` -- [ ] Удалить `command/impl/setup/` (команды перенесены в feature/) -- [ ] Удалить `command/impl/utility/SwapCommand.java` (не используется) -- [ ] Оставить в `command/` только: - - Интерфейсы: `Command.java`, `CommandContext.java`, `SlashCommand.java`, `CommandException.java` - - `command/moderation/` (если не выносим в feature/) - - `command/entertainment/` (кросс-доменные развлечения) - - `command/utility/` (HelpCommand, PingCommand, InfoCommand, AvatarCommand, PollCommand) - -**17. Вынести App.java из корня** -- [ ] Создать `bootstrap/` -- [ ] Создать `bootstrap/App.java` -- [ ] Переместить логику инициализации из корневого `App.java` -- [ ] Удалить корневой `App.java` -- [ ] Обновить `build.gradle` (mainClass) - ---- - -#### Этап 5: Финальная полировка (опционально) - -**18. UserStats immutable (после Repository)** -- [ ] Создать `UserStats.Builder` -- [ ] Убрать public сеттеры (оставить package-private для Gson) -- [ ] Добавить методы `addXP()`, `recordMessage()`, `addVoiceTime()` -- [ ] Добавить `getLevel(XpFormula)` вместо внешних вычислений -- [ ] Обновить `JsonUserStatsRepository` для работы с immutable - -**19. Обновить документацию** -- [ ] Обновить `FEATURES.md` (убрать TTS, добавить новые фичи) -- [ ] Обновить `README.md` (новая структура проекта) -- [ ] Создать `ARCHITECTURE.md` (описание доменов и feature/) -- [ ] Обновить комментарии в коде - -**20. Финальная проверка** -- [ ] `gradle build` - компиляция без ошибок -- [ ] Проверить все импорты -- [ ] Проверить что нет папок с >6 файлами -- [ ] Проверить что нет файлов >200 строк -- [ ] Запустить бота и протестировать основные команды - ---- - -## Рефакторинг кода (Code Quality) - -### Убрать if-else леса в rating командах - -**Проблема:** -Все rating команды (MustacheCommand, IqCommand, FatCommand, GayCommand, PpCommand, BoykisserCommand) содержат огромные if-else цепочки для определения уровня/комментария/цвета. - -**Пример плохого кода (MustacheCommand):** -```java -String comment; -Color color; -if (mustacheLevel >= 1000) { - comment = "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!"; - color = new Color(255, 215, 0); -} else if (mustacheLevel >= 80) { - comment = "🧔 Роскошные усы!"; - color = new Color(139, 69, 19); -} else if (mustacheLevel >= 60) { - comment = "👨 Хорошие усы"; - color = new Color(160, 82, 45); -} else if (mustacheLevel >= 40) { - comment = "😊 Усики растут"; - color = new Color(205, 133, 63); -} else if (mustacheLevel >= 20) { - comment = "🙂 Лёгкая щетина"; - color = new Color(210, 180, 140); -} else if (mustacheLevel > 0) { - comment = "😐 Почти нет усов"; - color = new Color(128, 128, 128); -} else { - comment = "🪒 Гладко выбрит"; - color = new Color(200, 200, 200); -} -``` - -**Решение:** -Создать общий класс `RatingLevel` с конфигурацией уровней: - -```java -public class RatingLevel { - private final int threshold; - private final String comment; - private final Color color; - - public static RatingLevel findLevel(int value, List levels) { - return levels.stream() - .filter(level -> value >= level.threshold) - .findFirst() - .orElse(levels.get(levels.size() - 1)); - } -} -``` - -**План действий:** -- [x] Создать `command/impl/entertainment/games/rating/RatingLevel.java` -- [x] Создать `command/impl/entertainment/games/rating/RatingConfig.java` с предустановленными конфигурациями -- [x] Рефакторить MustacheCommand - использовать RatingLevel -- [x] Рефакторить IqCommand - использовать RatingLevel -- [x] Рефакторить FatCommand - использовать RatingLevel -- [x] Рефакторить GayCommand - использовать RatingLevel (без уровней, только прогресс-бар) -- [x] Рефакторить PpCommand - использовать RatingLevel -- [x] Рефакторить BoykisserCommand - использовать RatingLevel -- [x] Проверить что все команды работают корректно -- [x] Удалить дублирующийся код - -**Приоритет:** ✅ **ГОТОВО** - ---- - -## Основные задачи - -### 1. ⚡ Переделать систему опыта (XP System Rework) - -**Текущая проблема:** -- Формула: `XP = 100 * level^1.5` (слишком сложная) -- XP за сообщение: 10 XP -- XP за минуту в войсе: 5 XP -- Cooldown на сообщения: 60 секунд - -**Новая система (из FEATURES.md):** -- ✅ **Формула прогрессии:** `XP для следующего уровня = 200 * текущий_уровень` - - Пример: уровень 1 → 2 требует 200 XP - - Уровень 5 → 6 требует 1000 XP - - Уровень 10 → 11 требует 2000 XP - -- ✅ **Голосовая активность:** - - 1 XP каждую минуту нахождения в голосовом канале - - Автоматическое отслеживание через `VoiceXpListener` - - Уведомление при повышении уровня в войс-канале - -**Файлы для изменения:** -- `src/main/java/creator/loki/level/LevelSystem.java` - изменить формулу -- `src/main/java/creator/loki/listener/VoiceTracker.java` - изменить начисление XP -- Добавить уведомления о level up в войсе - -**Статус:** ✅ **ГОТОВО** - ---- - -### 2. 🎤 Система личных голосовых каналов (Voice Management System) - -**Концепция:** -Пользователь заходит в специальный канал-триггер → бот автоматически создает личный войс-канал → перемещает пользователя туда → пишет сообщение в чат войса с настройками - -**Функционал:** - -#### 2.1 Автоматическое создание каналов -- ✅ Создать `VoiceChannelManager` класс -- ✅ Listener на вход в канал-триггер (например, "➕ Создать канал") -- ✅ Автоматическое создание канала с именем пользователя -- ✅ Перемещение пользователя в созданный канал -- ✅ Выдача прав управления владельцу канала -- ✅ Автоматическое удаление канала когда он пустой - -#### 2.2 Сообщение с настройками в чате войса -При создании канала бот отправляет embed с кнопками: -- 🔒 **Закрыть/Открыть** - переключение приватности -- 👥 **Лимит** - установить лимит пользователей (0-99) -- ✏️ **Переименовать** - изменить название канала -- 🎨 **Битрейт** - изменить качество звука -- 🚫 **Удалить** - удалить канал - -#### 2.3 Система пресетов -- ✅ Сохранение настроек как пресет -- ✅ Быстрое применение сохраненных настроек -- ✅ Хранение в `data/voice_presets.json` - -**Структура данных:** -```json -{ - "voice_channels": { - "channel_id": { - "owner_id": "user_id", - "created_at": 1234567890, - "settings": { - "name": "Канал пользователя", - "limit": 5, - "bitrate": 64000, - "locked": false - } - } - }, - "presets": { - "user_id": [ - { - "name": "Gaming", - "limit": 4, - "bitrate": 96000 - } - ] - } -} -``` - -**Файлы для создания:** -- `src/main/java/creator/loki/voice/VoiceChannelManager.java` -- `src/main/java/creator/loki/voice/VoiceChannelData.java` -- `src/main/java/creator/loki/voice/VoicePreset.java` -- `src/main/java/creator/loki/listener/VoiceChannelListener.java` -- `src/main/java/creator/loki/command/impl/voice/VoiceSettingsCommand.java` (для кнопок) - -**Статус:** ✅ **ГОТОВО** (базовая реализация, кнопки управления требуют обработчиков) - ---- - -### 3. 📊 Счетчик участников (Member Counter) - -**Функционал:** -- ✅ Обновление названия голосового канала с количеством участников -- ✅ Формат: `【👥】Участники: 123` -- ✅ Отслеживание дневной статистики: - - Количество присоединившихся за день - - Количество покинувших за день - - Разница за 24 часа -- ✅ Обновление каждые 5 минут -- ✅ Сохранение статистики в `data/counter_data.json` - -**Структура данных:** -```json -{ - "counter_channel_id": "1234567890", - "daily_stats": { - "date": "2026-05-11", - "joined": 15, - "left": 8, - "difference": 7 - }, - "history": [ - { - "date": "2026-05-10", - "joined": 20, - "left": 12, - "difference": 8 - } - ] -} -``` - -**Файлы для создания:** -- `src/main/java/creator/loki/listener/MemberCounterListener.java` -- `src/main/java/creator/loki/counter/CounterManager.java` -- `src/main/java/creator/loki/counter/DailyStats.java` - -**Статус:** 🔴 Не начато - ---- +# TODO — Loki Bot v2.1 ## ✅ Что уже сделано -### ✅ Система опыта переделана -- Новая формула: `XP для уровня N = 200 * N` -- 1 XP за минуту в войсе -- Обновление каждую минуту вместо 5 минут -- Метод `addVoiceTime` теперь возвращает `LevelUpResult` для уведомлений - -### ✅ Система личных войс-каналов (базовая) -- `VoiceChannelManager` - управление каналами -- `VoiceChannelListener` - автоматическое создание/удаление -- `VoiceChannelData` и `VoicePreset` - модели данных -- Автоматическое создание при входе в триггер-канал -- Автоматическое удаление пустых каналов -- Панель управления с кнопками (требуется обработчик кнопок) - -### ✅ Счетчик участников -- `CounterManager` - управление счетчиком -- `MemberCounterListener` - отслеживание входов/выходов -- `DailyStats` - дневная статистика -- Обновление каждые 5 минут -- История за 30 дней - -### ✅ SQLite база данных + автомиграция -- `Database` - менеджер SQLite -- `DatabaseMigration` - автоматическая миграция из JSON -- `ConfigRepository` - работа с конфигурацией -- Все таблицы созданы автоматически -- Миграция данных при первом запуске - -### ✅ Команды настройки -- `/voice-setup trigger` - установить канал-триггер -- `/voice-setup category` - установить категорию -- `/voice-setup text-channel` - установить текстовый канал -- `/voice-setup info` - показать конфигурацию -- `/counter-setup channel` - установить канал счетчика -- `/counter-setup format` - установить формат -- `/counter-setup info` - показать конфигурацию +| # | Статус | Проблема | Изменения | +|---|--------|----------|-----------| +| C1–C15 | ✅ | Предыдущие раунды | См. git history | +| J21 | ✅ | Java 17 → 21 | `build.gradle`: source/target 21 | +| A05 | ✅ | EventDispatcher: CachedThreadPool → VirtualThreadPool | `Executors.newVirtualThreadPerTaskExecutor()` | +| A06 | ✅ | SwapCommand + RepTopCommand: `new Thread()` → `Thread.startVirtualThread()` | | +| A25 | ✅ | CommandExceptionHandler: instanceof pattern matching | | +| A26 | ✅ | ClearCommand: instanceof pattern matching | | +| A27 | ✅ | DatabaseMigration: instanceof pattern matching | | +| A28 | ✅ | InfoCommand: switch → arrow-switch | | +| A29 | ✅ | RateCommand: `"⭐".repeat(rating)` + `"☆".repeat()` | | +| A32 | ✅ | .collect(Collectors.toList()) → .toList() | SwapCommand, RepTopCommand, ChoiceOptionParser | +| A33 | ✅ | HelpCategories: Map.ofEntries() вместо HashMap + unmodifiable | | +| A02 | ✅ | RankCardGenerator: блокировка Event Thread | Исправлено (команды на virtual threads) | +| A10 | ✅ | RankCardGenerator: утечка InputStream | try-with-resources в loadFont + loadBackground | +| A11 | ✅ | RankCardGenerator: шрифты не кэшировались | ConcurrentHashMap cache | +| A12 | ✅ | RankCardGenerator: SVG-иконки не работали | Заменены на геометрические фигуры (Java2D) | +| A23 | ✅ | RankCardGenerator: throws Exception | loadFont безопасный | +| A03 | ✅ | Database: race condition на connection | synchronized методы | +| A13 | ✅ | NekosLifeApi: Thread.sleep в catch | Сон вынесен в retry loop | +| A14 | ✅ | GenerateRatingsCommand: блокировка event-потока | `complete()` вместо `queue()` | +| A35 | ✅ | GenerateRatingsCommand: опечатка | `Пропускаемботов` → `Пропускаем ботов` | +| A04 | ✅ | PrefixCommandRegistrar: God Constructor (22 param) | Разбит на 4 per-feature CommandRegistrar, Multibinder, 1 param | +| A21 | ✅ | BotModule: путь к БД хардкодом | Вынесен в .env (`DB_PATH`) + BotConfig | +| A34 | ✅ | CommandCategories: мёртвый код | Уже удалён (старый пакет) | +| A37 | ✅ | ProfileCommand: регистрация в PrefixCommandRegistrar | LevelCommandRegistrar | --- -## 🔧 Дополнительные улучшения +## ♻️ Правила разработки -### 4. 🎨 Улучшение уведомлений о level up -- ✅ Красивые embed сообщения при повышении уровня -- ✅ Отправка в тот же канал где получен XP -- ✅ Для войса - отправка в текстовый канал связанный с войсом -- ✅ Показывать прогресс до следующего уровня - -**Статус:** 🔴 Не начато +- KISS, DRY, SOLID +- Файлы ≤150–200 строк (в идеале ≤100) +- В папке ≤6 файлов +- Пакет = домен/фича, а не тип файла (`feature/level/`, а не `commands/listeners/`) +- Каждая фича содержит свои команды, сервисы, репозитории +- Единый слой персистентности через `repository/` +- Интеграции в `integration/` --- -### 5. 🛠️ Команды для настройки систем (Admin Commands) +## 🔴 Проблемы (результаты аудита, которые ещё не исправлены) -#### Voice System Setup: -- `/voice-setup trigger ` - установить канал-триггер -- `/voice-setup category ` - установить категорию для личных каналов -- `/voice-setup text-channel ` - установить текстовый канал для уведомлений +### HIGH -#### Counter Setup: -- `/counter-setup channel ` - установить канал для счетчика -- `/counter-setup format ` - изменить формат отображения -- `/counter stats` - показать статистику за период +| # | Приоритет | Проблема | Статус | +|---|-----------|----------|--------| +| A07 | 🟠 **HIGH** | `HealthHttpServer` — single thread executor | ✅ Virtual threads | +| A08 | 🟠 **HIGH** | Тестовое покрытие ~5% (~34 теста на 149 продакшен-файлов). Нужно 20-30% | ⏳ 268 тестов в 22 файлах | +| A09 | 🟠 **HIGH** | Нет Docker / docker-compose | ✅ Dockerfile + docker-compose + .env.example | +| A01 | ⚪ **INFO** | `.env` с токеном в git-истории (локальный репозиторий) | ➖ | -**Статус:** 🔴 Не начато +### MEDIUM + +| # | Приоритет | Проблема | Статус | +|---|-----------|----------|--------| +| A15 | 🟡 **MEDIUM** | ~33 файла команд в `command/impl/` — не перенесены в `feature/*/commands/` | ✅ 33 файла перенесены | +| A16 | 🟡 **MEDIUM** | ~16 репозиториев в `database/` — не разнесены в `feature/*/repository/` | ✅ 13+3 репозитория перенесены | +| A18 | 🟡 **MEDIUM** | `DatabaseMigration` — устаревший код для JSON→SQLite. Нужно оценить | ✅ Harmless, idempotent — keep as-is | +| A19 | 🟡 **MEDIUM** | Нет статического анализа (checkstyle / spotbugs) | ✅ checkstyle.xml + spotbugs + 169 known violations | +| A20 | 🟡 **MEDIUM** | Нет версионного каталога (`libs.versions.toml`) | ✅ gradle/libs.versions.toml, build.gradle переписан | +| A22 | 🟡 **MEDIUM** | Нет пула соединений (HikariCP). Голый JDBC | ✅ HikariCP 5.1.0 + proxy-based prepare() | +| A24 | 🟡 **MEDIUM** | VoiceTracker, RateLimiter, CooldownManager, MuteScheduler — ScheduledExecutorService. virtual threads? | ✅ Virtual threads | +| A40 | 🟡 **MEDIUM** | `HelpMenuHandler` в `event/interaction/` — должен быть в `feature/help/` | ✅ Перенесён | + +### LOW + +| # | Приоритет | Проблема | Статус | +|---|-----------|----------|--------| +| A30 | 🟢 **LOW** | SlowmodeCommand: ручная проверка границ → Math.clamp (другая семантика) | ➖ | +| A31 | 🟢 **LOW** | ClearCommand: ручная проверка границ → Math.clamp (другая семантика) | ➖ | +| A39 | 🟢 **LOW** | EmbedFactory: `set*` вместо `with*` для builder | ✅ Renamed in 43 files | +| A36 | 🟢 **LOW** | RankCardGenerator: URI → URL, deprecated API | ✅ `URI.toURL()` → `new URL()` | --- -## 📝 Приоритеты реализации +## 📋 План рефакторинга -### Высокий приоритет (сделать в первую очередь): -1. ⚡ Переделать систему опыта (простая формула) -2. 🎤 Базовая система личных войс-каналов (создание/удаление) -3. 📊 Счетчик участников +### Этап 1: Java 21 ✅ (14 items) +- [x] EventDispatcher: VirtualThreadPool +- [x] instanceof pattern matching (3 файла) +- [x] arrow-switch (InfoCommand) +- [x] String.repeat() (RateCommand) +- [x] .toList() (3 файла) +- [x] Map.ofEntries() (HelpCategories) -### Средний приоритет: -4. 🎤 Настройки войс-каналов через кнопки -5. 🎨 Улучшение уведомлений о level up -6. 🎤 Система пресетов для войс-каналов +### Этап 2: Рефакторинг PrefixCommandRegistrar ✅ +- [x] Создан интерфейс `CommandRegistrar` +- [x] 4 per-feature регистратора +- [x] Guice Multibinder для сбора регистраторов +- [x] 22 параметра → 1 (`Set`) -### Низкий приоритет: -7. 🛠️ Админ команды для настройки +### Этап 3: Critical-баги ✅ +- [x] RankCardGenerator: async + try-with-resources + cache + SVG +- [x] Database: synchronized connection +- [x] NekosLifeApi: чистый retry loop +- [x] GenerateRatingsCommand: async через complete() +- [x] DB path: вынесен в .env + +### Этап 4: Перемещение файлов в feature-пакеты ✅ +- [x] `command/impl/utility/` → `feature/*/commands/` +- [x] `command/impl/entertainment/` → `feature/*/commands/` +- [x] `command/entertainment/social/` → `feature/social/commands/` +- [x] `database/*/` → `feature/*/repository/` +- [x] `event/interaction/HelpMenuHandler` → `feature/help/` + +### Этап 5: Тесты (цель: 20-30% покрытие) +- [x] Тесты на репозитории (in-memory SQLite) — Config, ShipPair, UserStats, Ratings, Warnings, ActiveMutes +- [x] Unit-тесты сервисов — MessageCooldownService, LevelService (был), XpFormula, ChoiceOptionParser +- [x] Unit-тесты value objects — UserStats (был), LevelProgress, LevelUpResult, BotConfig +- [x] Тесты embed — EmbedFactory (builder chaining), EmbedTemplates (factory methods) +- [x] Тесты форматирования — LeaderboardFormatter +- [ ] Unit-тесты на команды (с моками JDA) +- [ ] Smoke-тест: все команды регистрируются без коллизий +- [ ] Тесты на RankCardGenerator (изолированные, без сети) +- [ ] Тесты на EventDispatcher (маршрутизация, rate limiting) + +### Этап 6: Инфраструктура ✅ +- [x] Dockerfile (multi-stage) +- [x] docker-compose.yml +- [x] Статический анализ: checkstyle / spotbugs +- [x] Версионный каталог: `gradle/libs.versions.toml` +- [x] HikariCP вместо голого JDBC + +### Этап 7: Кодстайл +- [x] EmbedFactory: `set*` → `with*` для builder +- [ ] DatabaseMigration: оценить нужность +- [x] HelpMenuHandler: переместить в feature/help/ --- -## 🐛 Известные проблемы (Bugs to Fix) +## 🚀 Новые фичи (Roadmap) -### Критические: -- [ ] Проверить корректность начисления XP в войсе -- [ ] Убедиться что VoiceTracker правильно отслеживает время +## 🚀 Экономика — план реализации (в работе) -### Некритические: -- [ ] Удалить неиспользуемый logger в LevelSystem.java -- [ ] Убрать ненужный @SuppressWarnings в LevelSystem.java +### Модель данных ---- +```sql +CREATE TABLE IF NOT EXISTS economy ( + user_id TEXT PRIMARY KEY, + cash INTEGER NOT NULL DEFAULT 0, -- на руках + bank INTEGER NOT NULL DEFAULT 0, -- в банке + total_earned INTEGER NOT NULL DEFAULT 0, + total_spent INTEGER NOT NULL DEFAULT 0, + last_daily TEXT, -- ISO date + daily_streak INTEGER DEFAULT 0, + fishing_luck INTEGER DEFAULT 0, -- stat lvl 0-5 + efficiency INTEGER DEFAULT 0, -- stat lvl 0-5 + last_fish TEXT, -- ISO datetime + job TEXT, -- название работы + last_work TEXT -- ISO datetime +); +``` -## 📚 Технические детали +### Заработок -### Зависимости: -- JDA 5.0.0 (уже есть) -- Guice для DI (уже есть) -- Gson для JSON (уже есть) +| Механика | Доход | Кулдаун | +|---|---|---| +| Сообщение | 1–3 LC | 60с | +| Голос | 1 LC / 2 мин | 2 мин | +| Ежедневно | 50 LC + streak ×1.5 на 7й день | 24ч | +| +rep (полученная) | 10 LC | — | +| Рыбалка | base × speed_mul × (1 + fishing_luck×0.01) | 2ч | +| Работа | random(range) × (1 + efficiency×0.01) | 6ч | +| Блэкджек | ×1.5 при победе | — | +| Слоты | ×0–×50 | — | -### Конфигурация (добавить в config): -```yaml -voice: - trigger_channel_id: 1234567890 - category_id: 1234567890 - text_channel_id: 1234567890 - auto_delete_empty: true - -counter: - channel_id: 1234567890 - update_interval: 300 # секунды - format: "【👥】Участники: {count}" +### Миниигры + +**Рыбалка** (`!fish easy/normal/hard`) +- Сетка N×N (emoji + 🌊), бесплатно, неправильный ответ = 0 +- Множитель скорости: <50% времени = ×1.5, <30% = ×2.0 +- Стат `fishing_luck` (макс 5, стоимость 800×lvl) + +**Блэкджек** (`!bj `) +- Дилер стоит на 16, ничья = проигрыш, BJ = ×1.5 +- Кнопки Hit/Stand + +**Слоты** (`!slot `) +- 🍒🍒🍒 ×3 (10%), 🔔🔔🔔 ×5 (5%), 💎💎💎 ×10 (2%), 👑👑👑 ×15 (1%), ⚡⚡⚡ ×50 (0.1%) + +**Работы** (`!job list`, `!job join`, `!work`) +| Работа | Ур. | Доход | +|---|---|---| +| 🪓 Лесоруб | — | 15–30 | +| ⛏️ Шахтёр | — | 20–40 | +| 🎣 Рыбак | — | 25–50 | +| 🍳 Повар | 15 | 30–60 | +| 💻 Программист | 20 | 40–80 | +| 🧑‍🚀 Космонавт | 35 | 50–100 | + +Стат `efficiency` (макс 5, стоимость 800×lvl) + +**Rob** (`!rob @user`) +- 24ч кулдаун, 40% успех, крадёт до 15% cash (макс 10к) +- Провал: штраф 10% cash (макс 5к) → жертве +- Банк не трогается + +**Банк** (`!bank deposit/withdraw/balance`) +- Без процентов, защита от rob + +### Структура пакета + +``` +feature/economy/ + EconomyModule.java + EconomyCommandRegistrar.java + domain/ + EconomyAccount.java + EconomyTransaction.java + Job.java + SlotCombination.java + repository/ + EconomyRepository.java + SqlEconomyRepository.java + service/ + EconomyService.java + FishingService.java + BlackjackService.java + SlotService.java + JobService.java + command/ + core/ (Balance, Baltop, Pay, Daily) + minigame/ (Fish, Blackjack, Slot, Job, Work) + social/ (Rob) + bank/ (Bank) ``` --- -## ✅ Чеклист перед релизом +### Приоритет 1 +- [ ] Система достижений (Achievements) +- [x] Система экономики (Economy) — **в работе** -- [ ] Все функции протестированы -- [ ] Логирование работает корректно -- [ ] Нет утечек памяти (проверить scheduler'ы) -- [ ] Graceful shutdown для всех scheduler'ов -- [ ] Документация обновлена -- [ ] FEATURES.md актуализирован +### Приоритет 2 +- [x] Авто-модерация (3 варна = авто-мут на 1ч) +- [x] Музыка (lavaplayer: play, skip, stop, queue, np, volume, leave) +- [x] ~~Музыка — поиск с выбором (yt/sc/list + выбор номера)~~ +- [x] Server Stats (участники, каналы, роли, бусты) +- [ ] Level Roles (роли за уровни) +- [ ] Красивые level-up embed с прогресс-баром + +### Приоритет 3 +- [ ] Статистика сервера — графики, CSV export +- [ ] Система квестов +- [ ] Wiki по установке и командам --- -**Дата создания:** 2026-05-11 -**Версия:** 1.0 -**Автор:** Loki Bot Team +**Версия:** 2.2 | **Последнее обновление:** 2026-06-20 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy 🚧** diff --git a/build.gradle b/build.gradle index 57859aa..431bb12 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,8 @@ plugins { id 'java' id 'application' + id 'checkstyle' + alias(libs.plugins.spotbugs) } group = 'creator.loki' @@ -11,47 +13,33 @@ repositories { } dependencies { - // JDA - Discord API - implementation('net.dv8tion:JDA:5.0.0-beta.20') { + implementation(libs.jda) { exclude module: 'opus-java' } + implementation libs.lavaplayer + implementation libs.dotenv + implementation libs.slf4j.api + implementation libs.logback.classic + implementation libs.gson + implementation libs.caffeine + implementation libs.guice + implementation libs.sqlite.jdbc + implementation libs.hikaricp - // LavaPlayer для голосового TTS - implementation 'dev.arbjerg:lavaplayer:2.2.2' - - // dotenv для загрузки .env - implementation 'io.github.cdimascio:dotenv-java:3.0.0' - - // Logging - implementation 'org.slf4j:slf4j-api:2.0.12' - implementation 'ch.qos.logback:logback-classic:1.5.3' - - // JSON - implementation 'com.google.code.gson:gson:2.10.1' - - // Caffeine Cache - implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' - - // Google Guice - Dependency Injection - implementation 'com.google.inject:guice:7.0.0' - - // SQLite Database - implementation 'org.xerial:sqlite-jdbc:3.45.1.0' - - // Testing - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' - testImplementation 'org.mockito:mockito-core:5.11.0' - testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation libs.junit.jupiter + testImplementation libs.mockito.core + testImplementation libs.mockito.junit.jupiter + testRuntimeOnly libs.junit.platform.launcher } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } tasks.withType(JavaCompile) { options.encoding = 'UTF-8' + options.compilerArgs << '-Xlint:all' } test { @@ -59,13 +47,13 @@ test { } application { - mainClass = 'creator.loki.App' + mainClass = 'creator.loki.bootstrap.App' } jar { manifest { attributes( - 'Main-Class': 'creator.loki.App' + 'Main-Class': 'creator.loki.bootstrap.App' ) } duplicatesStrategy = DuplicatesStrategy.EXCLUDE @@ -73,3 +61,14 @@ jar { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } } + +checkstyle { + configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") + maxErrors = 999 + maxWarnings = 999 +} + +spotbugs { + excludeFilter = file("${rootDir}/config/spotbugs/exclude.xml") + ignoreFailures = true +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..52876fe --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml new file mode 100644 index 0000000..aeed21b --- /dev/null +++ b/config/spotbugs/exclude.xml @@ -0,0 +1,6 @@ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9863ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" + +services: + bot: + build: . + container_name: loki-bot + restart: unless-stopped + ports: + - "8712:8712" + environment: + DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN} + BOT_PREFIX: ${BOT_PREFIX:-!} + BOT_ACTIVITY: ${BOT_ACTIVITY:-Используй !help} + HEALTH_HTTP_PORT: ${HEALTH_HTTP_PORT:-8712} + SUPER_ADMIN_ID: ${SUPER_ADMIN_ID} + DB_PATH: /app/data/bot.db + volumes: + - bot-data:/app/data + +volumes: + bot-data: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..25da1ea --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +jda = "5.0.0-beta.20" +lavaplayer = "2.2.2" +dotenv = "3.0.0" +slf4j = "2.0.12" +logback = "1.5.3" +gson = "2.10.1" +caffeine = "3.1.8" +guice = "7.0.0" +sqlite = "3.45.1.0" +hikaricp = "5.1.0" +junit = "5.10.2" +mockito = "5.11.0" +junitPlatformLauncher = "1.10.2" +checkstyle = "10.21.4" +spotbugs = "6.1.7" +spotbugsPlugin = "6.1.7" + +[libraries] +jda = { module = "net.dv8tion:JDA", version.ref = "jda" } +lavaplayer = { module = "dev.arbjerg:lavaplayer", version.ref = "lavaplayer" } +dotenv = { module = "io.github.cdimascio:dotenv-java", version.ref = "dotenv" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +guice = { module = "com.google.inject:guice", version.ref = "guice" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } +hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikaricp" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } +checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" } +spotbugs = { module = "com.github.spotbugs:spotbugs", version.ref = "spotbugs" } +spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" } + +[plugins] +checkstyle = { id = "checkstyle", version = "" } +spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e09..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..af63146 --- /dev/null +++ b/gradlew @@ -0,0 +1,120 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://the current +# application classpath, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@" + +exec "$JAVACMD" "$@" diff --git a/settings.gradle b/settings.gradle index 3b2e351..4d89711 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,7 @@ rootProject.name = 'discord-bot' + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} diff --git a/src/main/java/creator/loki/App.java b/src/main/java/creator/loki/bootstrap/App.java similarity index 95% rename from src/main/java/creator/loki/App.java rename to src/main/java/creator/loki/bootstrap/App.java index b12d44a..5799951 100644 --- a/src/main/java/creator/loki/App.java +++ b/src/main/java/creator/loki/bootstrap/App.java @@ -1,50 +1,50 @@ -package creator.loki; - -import com.google.inject.Guice; -import com.google.inject.Injector; -import creator.loki.core.BotInitializer; -import creator.loki.di.BotModule; -import net.dv8tion.jda.api.JDA; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class App { - private static final Logger logger = LoggerFactory.getLogger(App.class); - private static volatile boolean shutdownRequested = false; - - public static void main(String[] args) { - // Регистрируем shutdown hook ДО создания бота - Thread shutdownHook = new Thread(() -> { - if (!shutdownRequested) { - shutdownRequested = true; - logger.info("========================================"); - logger.info("Получен сигнал завершения"); - logger.info("Выполняется graceful shutdown..."); - logger.info("========================================"); - } - }, "ShutdownHook"); - - Runtime.getRuntime().addShutdownHook(shutdownHook); - - try { - logger.info("Запуск Discord бота"); - - // Создаём Guice Injector с модулем конфигурации - Injector injector = Guice.createInjector(new BotModule()); - - // Получаем BotInitializer с автоматически внедрёнными зависимостями - BotInitializer initializer = injector.getInstance(BotInitializer.class); - - // Запускаем бота - JDA jda = initializer.start(); - - // Ждём полной загрузки - jda.awaitReady(); - logger.info("Бот полностью готов к работе!"); - - } catch (Exception e) { - logger.error("Критическая ошибка при запуске бота", e); - System.exit(1); - } - } -} +package creator.loki.bootstrap; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import creator.loki.core.BotInitializer; +import creator.loki.di.BotModule; +import net.dv8tion.jda.api.JDA; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class App { + private static final Logger logger = LoggerFactory.getLogger(App.class); + private static volatile boolean shutdownRequested = false; + + public static void main(String[] args) { + // Регистрируем shutdown hook ДО создания бота + Thread shutdownHook = new Thread(() -> { + if (!shutdownRequested) { + shutdownRequested = true; + logger.info("========================================"); + logger.info("Получен сигнал завершения"); + logger.info("Выполняется graceful shutdown..."); + logger.info("========================================"); + } + }, "ShutdownHook"); + + Runtime.getRuntime().addShutdownHook(shutdownHook); + + try { + logger.info("Запуск Discord бота"); + + // Создаём Guice Injector с модулем конфигурации + Injector injector = Guice.createInjector(new BotModule()); + + // Получаем BotInitializer с автоматически внедрёнными зависимостями + BotInitializer initializer = injector.getInstance(BotInitializer.class); + + // Запускаем бота + JDA jda = initializer.start(); + + // Ждём полной загрузки + jda.awaitReady(); + logger.info("Бот полностью готов к работе!"); + + } catch (Exception e) { + logger.error("Критическая ошибка при запуске бота", e); + System.exit(1); + } + } +} diff --git a/src/main/java/creator/loki/listener/ReadyListener.java b/src/main/java/creator/loki/bootstrap/ReadyListener.java similarity index 85% rename from src/main/java/creator/loki/listener/ReadyListener.java rename to src/main/java/creator/loki/bootstrap/ReadyListener.java index a18f6d6..e8edb80 100644 --- a/src/main/java/creator/loki/listener/ReadyListener.java +++ b/src/main/java/creator/loki/bootstrap/ReadyListener.java @@ -1,16 +1,18 @@ -package creator.loki.listener; - -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ReadyListener extends ListenerAdapter { - private static final Logger logger = LoggerFactory.getLogger(ReadyListener.class); - - @Override - public void onReady(ReadyEvent event) { - logger.info("Бот {} успешно запущен!", event.getJDA().getSelfUser().getName()); - logger.info("Подключен к {} серверам", event.getGuildTotalCount()); - } -} +package creator.loki.bootstrap; + +import com.google.inject.Singleton; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class ReadyListener extends ListenerAdapter { + private static final Logger logger = LoggerFactory.getLogger(ReadyListener.class); + + @Override + public void onReady(ReadyEvent event) { + logger.info("Бот {} успешно запущен!", event.getJDA().getSelfUser().getName()); + logger.info("Подключен к {} серверам", event.getGuildTotalCount()); + } +} diff --git a/src/main/java/creator/loki/command/Command.java b/src/main/java/creator/loki/command/Command.java index d515fca..e8cb727 100644 --- a/src/main/java/creator/loki/command/Command.java +++ b/src/main/java/creator/loki/command/Command.java @@ -1,16 +1,16 @@ -package creator.loki.command; - -public interface Command { - String getName(); - String getDescription(); - - default String getUsage() { - return getName(); - } - - default String getCategory() { - return "Общие"; - } - - void execute(CommandContext context); -} +package creator.loki.command; + +public interface Command { + String getName(); + String getDescription(); + + default String getUsage() { + return getName(); + } + + default String getCategory() { + return "Общие"; + } + + void execute(CommandContext context); +} diff --git a/src/main/java/creator/loki/command/CommandCategories.java b/src/main/java/creator/loki/command/CommandCategories.java new file mode 100644 index 0000000..9364c20 --- /dev/null +++ b/src/main/java/creator/loki/command/CommandCategories.java @@ -0,0 +1,12 @@ +package creator.loki.command; + +/** + * Имена категорий для {@link Command#getCategory()} (справка, селектор help). + */ +public final class CommandCategories { + + /** Команды с GIF (nekos.life): hug, slap, poke и т.п. */ + public static final String GIF_INTERACTIVE = "GIF-интерактив"; + + private CommandCategories() {} +} diff --git a/src/main/java/creator/loki/command/CommandContext.java b/src/main/java/creator/loki/command/CommandContext.java index 003314a..7b98152 100644 --- a/src/main/java/creator/loki/command/CommandContext.java +++ b/src/main/java/creator/loki/command/CommandContext.java @@ -1,44 +1,44 @@ -package creator.loki.command; - -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; - -import java.util.List; - -public class CommandContext { - private final Message message; - private final List args; - private final MessageChannel channel; - private final Member author; - private final Guild guild; - - public CommandContext(Message message, List args, MessageChannel channel, Member author, Guild guild) { - this.message = message; - this.args = args; - this.channel = channel; - this.author = author; - this.guild = guild; - } - - public Message getMessage() { - return message; - } - - public List getArgs() { - return args; - } - - public MessageChannel getChannel() { - return channel; - } - - public Member getAuthor() { - return author; - } - - public Guild getGuild() { - return guild; - } -} +package creator.loki.command; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +import java.util.List; + +public class CommandContext { + private final Message message; + private final List args; + private final MessageChannel channel; + private final Member author; + private final Guild guild; + + public CommandContext(Message message, List args, MessageChannel channel, Member author, Guild guild) { + this.message = message; + this.args = args; + this.channel = channel; + this.author = author; + this.guild = guild; + } + + public Message getMessage() { + return message; + } + + public List getArgs() { + return args; + } + + public MessageChannel getChannel() { + return channel; + } + + public Member getAuthor() { + return author; + } + + public Guild getGuild() { + return guild; + } +} diff --git a/src/main/java/creator/loki/command/SlashCommand.java b/src/main/java/creator/loki/command/SlashCommand.java deleted file mode 100644 index 0410640..0000000 --- a/src/main/java/creator/loki/command/SlashCommand.java +++ /dev/null @@ -1,29 +0,0 @@ -package creator.loki.command; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.CommandData; - -/** - * Интерфейс для slash команд - */ -public interface SlashCommand { - /** - * Выполняет slash команду - */ - void execute(SlashCommandInteractionEvent event); - - /** - * Возвращает данные команды для регистрации в Discord - */ - CommandData getCommandData(); - - /** - * Название команды - */ - String getName(); - - /** - * Описание команды - */ - String getDescription(); -} diff --git a/src/main/java/creator/loki/command/entertainment/social/negative/PokeCommand.java b/src/main/java/creator/loki/command/entertainment/social/negative/PokeCommand.java deleted file mode 100644 index bdf2f4c..0000000 --- a/src/main/java/creator/loki/command/entertainment/social/negative/PokeCommand.java +++ /dev/null @@ -1,78 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.negative; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Тыкнуть пользователя с GIF - */ -public class PokeCommand implements Command { - - private final CooldownManager cooldownManager; - - public PokeCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "poke"; - } - - @Override - public String getDescription() { - return "Тыкнуть пользователя"; - } - - @Override - public String getUsage() { - return "poke <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "poke")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "poke"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - cooldownManager.applyCooldown(context.getAuthor().getId(), "poke", 5); - - String gifUrl = NekosLifeApi.fetchImage("poke"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("👉 " + context.getAuthor().getAsMention() + " тыкает в " + target.getAsMention() + "! 😏") - .setImage(gifUrl) - .setColor(new Color(0, 128, 128)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/entertainment/social/negative/RoastCommand.java b/src/main/java/creator/loki/command/entertainment/social/negative/RoastCommand.java deleted file mode 100644 index b15d025..0000000 --- a/src/main/java/creator/loki/command/entertainment/social/negative/RoastCommand.java +++ /dev/null @@ -1,68 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -public class RoastCommand implements Command { - private final Random random = new Random(); - - private static final String[] ROASTS = { - "Ты как Internet Explorer — медленный и никому не нужный", - "Твой код работает? Это баг, исправь его", - "Ты пишешь код как будто тебе за каждую строку платят", - "Твоя логика как у JavaScript: непредсказуемая", - "Ты как HTML без CSS — работаешь, но выглядишь ужасно", - "Твой IQ как температура в Антарктиде — ниже нуля", - "Ты как баг в продакшене — неожиданный и нежеланный", - "Твои шутки как deprecated код — устарели", - "Ты как NULL pointer — бесполезный и вызываешь ошибки", - "Твой уровень как индекс массива в Python — может быть отрицательным" - }; - - @Override - public String getName() { - return "roast"; - } - - @Override - public String getDescription() { - return "Дружеский рофл над пользователем"; - } - - @Override - public String getUsage() { - return "roast <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - String roast = ROASTS[random.nextInt(ROASTS.length)]; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🔥 Roast") - .setDescription(target.getAsMention() + ", " + roast) - .setColor(new Color(231, 76, 60)) - .setFooter("Это шутка, не обижайся!") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/entertainment/social/negative/SlapCommand.java b/src/main/java/creator/loki/command/entertainment/social/negative/SlapCommand.java deleted file mode 100644 index bebb731..0000000 --- a/src/main/java/creator/loki/command/entertainment/social/negative/SlapCommand.java +++ /dev/null @@ -1,78 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.negative; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Шлепнуть пользователя с GIF - */ -public class SlapCommand implements Command { - - private final CooldownManager cooldownManager; - - public SlapCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "slap"; - } - - @Override - public String getDescription() { - return "Шлепнуть пользователя"; - } - - @Override - public String getUsage() { - return "slap <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "slap")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "slap"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - cooldownManager.applyCooldown(context.getAuthor().getId(), "slap", 5); - - String gifUrl = NekosLifeApi.fetchImage("slap"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("👋 " + context.getAuthor().getAsMention() + " шлепает " + target.getAsMention() + "! 😠") - .setImage(gifUrl) - .setColor(new Color(255, 165, 0)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/entertainment/social/positive/CuddleCommand.java b/src/main/java/creator/loki/command/entertainment/social/positive/CuddleCommand.java deleted file mode 100644 index a4c070d..0000000 --- a/src/main/java/creator/loki/command/entertainment/social/positive/CuddleCommand.java +++ /dev/null @@ -1,78 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.positive; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Обниматься с пользователем с GIF - */ -public class CuddleCommand implements Command { - - private final CooldownManager cooldownManager; - - public CuddleCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "cuddle"; - } - - @Override - public String getDescription() { - return "Обниматься с пользователем"; - } - - @Override - public String getUsage() { - return "cuddle <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "cuddle")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "cuddle"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - cooldownManager.applyCooldown(context.getAuthor().getId(), "cuddle", 5); - - String gifUrl = NekosLifeApi.fetchImage("cuddle"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("🥰 " + context.getAuthor().getAsMention() + " обнимается с " + target.getAsMention() + "! 💕") - .setImage(gifUrl) - .setColor(new Color(255, 0, 255)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/negative/TickleCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/negative/TickleCommand.java deleted file mode 100644 index 426d28f..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/negative/TickleCommand.java +++ /dev/null @@ -1,78 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.negative; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Пощекотать пользователя с GIF - */ -public class TickleCommand implements Command { - - private final CooldownManager cooldownManager; - - public TickleCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "tickle"; - } - - @Override - public String getDescription() { - return "Пощекотать пользователя"; - } - - @Override - public String getUsage() { - return "tickle <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "tickle")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "tickle"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - cooldownManager.applyCooldown(context.getAuthor().getId(), "tickle", 5); - - String gifUrl = NekosLifeApi.fetchImage("tickle"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("🤗 " + context.getAuthor().getAsMention() + " щекочет " + target.getAsMention() + "!") - .setImage(gifUrl) - .setColor(new Color(0, 255, 0)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/ComplimentCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/ComplimentCommand.java deleted file mode 100644 index 4e37cae..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/ComplimentCommand.java +++ /dev/null @@ -1,67 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -public class ComplimentCommand implements Command { - private final Random random = new Random(); - - private static final String[] COMPLIMENTS = { - "Ты как идеальный код — красивый и без багов", - "Твоя улыбка ярче, чем RGB подсветка", - "Ты как хороший коммит — делаешь всё лучше", - "Твой вайб как у Python — простой и приятный", - "Ты как успешный деплой — всё работает отлично", - "Твоя энергия как у SSD — быстрая и эффективная", - "Ты как open source проект — полезный для всех", - "Твоя харизма как у TypeScript — добавляет уверенности", - "Ты как хороший API — понятный и надёжный", - "Твоё присутствие как кофе для программиста — необходимо" - }; - - @Override - public String getName() { - return "compliment"; - } - - @Override - public String getDescription() { - return "Сделать комплимент пользователю"; - } - - @Override - public String getUsage() { - return "compliment <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - String compliment = COMPLIMENTS[random.nextInt(COMPLIMENTS.length)]; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("💖 Комплимент") - .setDescription(target.getAsMention() + ", " + compliment) - .setColor(new Color(255, 182, 193)) - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/FeedCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/FeedCommand.java deleted file mode 100644 index 1a267a3..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/FeedCommand.java +++ /dev/null @@ -1,78 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.positive; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Покормить пользователя с GIF - */ -public class FeedCommand implements Command { - - private final CooldownManager cooldownManager; - - public FeedCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "feed"; - } - - @Override - public String getDescription() { - return "Покормить пользователя"; - } - - @Override - public String getUsage() { - return "feed <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "feed")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "feed"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - cooldownManager.applyCooldown(context.getAuthor().getId(), "feed", 5); - - String gifUrl = NekosLifeApi.fetchImage("feed"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("🍽️ " + context.getAuthor().getAsMention() + " кормит " + target.getAsMention() + "! 😋") - .setImage(gifUrl) - .setColor(new Color(255, 200, 100)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/HugCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/HugCommand.java deleted file mode 100644 index 4558073..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/HugCommand.java +++ /dev/null @@ -1,61 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.positive; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -public class HugCommand implements Command { - - @Override - public String getName() { - return "hug"; - } - - @Override - public String getDescription() { - return "Обнять пользователя"; - } - - @Override - public String getUsage() { - return "hug <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - // Получаем GIF из nekos.life API - String gifUrl = NekosLifeApi.fetchImage("hug"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("🤗 " + context.getAuthor().getAsMention() + " обнял " + target.getAsMention() + " 💕") - .setImage(gifUrl) - .setColor(new Color(255, 182, 193)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/KissCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/KissCommand.java deleted file mode 100644 index 6a175f5..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/KissCommand.java +++ /dev/null @@ -1,92 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.positive; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import creator.loki.integration.nekos.NekosLifeApi; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; - -/** - * Поцелуй пользователя с GIF - */ -public class KissCommand implements Command { - - private final CooldownManager cooldownManager; - - public KissCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "kiss"; - } - - @Override - public String getDescription() { - return "Поцеловать пользователя"; - } - - @Override - public String getUsage() { - return "kiss <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "kiss")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "kiss"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи кого поцеловать! Использование: `!kiss @user`").queue(); - return; - } - - Member target = mentioned.get(0); - - if (target.getId().equals(context.getAuthor().getId())) { - context.getChannel().sendMessage("❌ Нельзя целовать самого себя!").queue(); - return; - } - - if (target.getUser().isBot()) { - context.getChannel().sendMessage("❌ Боты не умеют целоваться!").queue(); - return; - } - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "kiss", 5); - - // Получаем GIF из nekos.life API - String gifUrl = NekosLifeApi.fetchImage("kiss"); - - if (gifUrl == null) { - context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue(); - return; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setDescription("💋 " + context.getAuthor().getAsMention() + " целует " + target.getAsMention() + " ❤️") - .setImage(gifUrl) - .setColor(new Color(255, 105, 180)) - .setFooter("Powered by nekos.life") - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/PatCommand.java b/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/PatCommand.java deleted file mode 100644 index 955dd26..0000000 --- a/src/main/java/creator/loki/command/impl/entertainment/interaction/positive/PatCommand.java +++ /dev/null @@ -1,91 +0,0 @@ -package creator.loki.command.impl.entertainment.interaction.positive; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.util.List; - -public class PatCommand implements Command { - private static final int COOLDOWN_SECONDS = 5; - private final CooldownManager cooldownManager; - - public PatCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "pat"; - } - - @Override - public String getDescription() { - return "Погладить пользователя"; - } - - @Override - public String getUsage() { - return "pat <@user> <количество 1-7>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - String userId = context.getAuthor().getId(); - - // Проверка cooldown - if (cooldownManager.isOnCooldown(userId, "pat")) { - long remaining = cooldownManager.getRemainingSeconds(userId, "pat"); - context.getChannel().sendMessage( - "⏰ Подожди " + remaining + " сек." - ).queue(); - return; - } - - // Проверка аргументов - if (context.getArgs().size() < 2) { - context.getChannel().sendMessage( - "❌ Используй: `!pat <@user> <количество 1-7>`" - ).queue(); - return; - } - - // Получение упомянутого пользователя - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("❌ Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - // Парсинг количества - int count; - try { - count = Integer.parseInt(context.getArgs().get(1)); - } catch (NumberFormatException e) { - context.getChannel().sendMessage("❌ Укажи число от 1 до 7").queue(); - return; - } - - // Валидация количества - if (count < 1 || count > 7) { - context.getChannel().sendMessage("❌ Количество должно быть от 1 до 7").queue(); - return; - } - - // Применяем cooldown - cooldownManager.applyCooldown(userId, "pat", COOLDOWN_SECONDS); - - // Отправляем каждое "pat-pat" отдельным сообщением - for (int i = 0; i < count; i++) { - context.getChannel().sendMessage("pat-pat " + target.getAsMention()).queue(); - } - } -} diff --git a/src/main/java/creator/loki/command/impl/moderation/BanCommand.java b/src/main/java/creator/loki/command/impl/moderation/BanCommand.java deleted file mode 100644 index df3c95e..0000000 --- a/src/main/java/creator/loki/command/impl/moderation/BanCommand.java +++ /dev/null @@ -1,126 +0,0 @@ -package creator.loki.command.impl.moderation; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedTemplates; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; - -import java.util.List; - -public class BanCommand implements Command { - - @Override - public String getName() { - return "ban"; - } - - @Override - public String getDescription() { - return "Банит пользователя на сервере"; - } - - @Override - public String getUsage() { - return "ban <@user> [причина]"; - } - - @Override - public String getCategory() { - return "Модерация"; - } - - @Override - public void execute(CommandContext context) { - if (context.getGuild() == null) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Команда доступна только на сервере") - ).queue(); - return; - } - - // Проверка прав модератора - if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.BAN_MEMBERS)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "У вас нет прав на бан пользователей") - ).queue(); - return; - } - - // Проверка прав бота - if (!context.getGuild().getSelfMember().hasPermission(Permission.BAN_MEMBERS)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "У бота нет прав на бан пользователей") - ).queue(); - return; - } - - // Получение упомянутого пользователя - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Укажите пользователя через @") - ).queue(); - return; - } - - Member target = mentioned.get(0); - - // Нельзя забанить самого себя - if (target.getId().equals(context.getAuthor().getId())) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Вы не можете забанить самого себя") - ).queue(); - return; - } - - // Нельзя забанить бота - if (target.getUser().isBot() && target.getId().equals(context.getGuild().getSelfMember().getId())) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Я не могу забанить самого себя") - ).queue(); - return; - } - - // Проверка иерархии ролей - if (!context.getAuthor().canInteract(target)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "Вы не можете забанить этого пользователя (роль выше вашей)") - ).queue(); - return; - } - - if (!context.getGuild().getSelfMember().canInteract(target)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "Я не могу забанить этого пользователя (роль выше моей)") - ).queue(); - return; - } - - // Причина бана - String reason = context.getArgs().size() > 1 - ? String.join(" ", context.getArgs().subList(1, context.getArgs().size())) - : "Причина не указана"; - - // Бан - context.getGuild().ban(target, 0, java.util.concurrent.TimeUnit.DAYS) - .reason(reason) - .queue( - success -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.success( - "Пользователь забанен", - "**Пользователь:** " + target.getUser().getAsTag() + "\n" + - "**Причина:** " + reason + "\n" + - "**Модератор:** " + context.getAuthor().getAsMention() - ) - ).queue(); - }, - error -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Не удалось забанить: " + error.getMessage()) - ).queue(); - } - ); - } -} diff --git a/src/main/java/creator/loki/command/impl/moderation/ClearCommand.java b/src/main/java/creator/loki/command/impl/moderation/ClearCommand.java deleted file mode 100644 index 45ac930..0000000 --- a/src/main/java/creator/loki/command/impl/moderation/ClearCommand.java +++ /dev/null @@ -1,108 +0,0 @@ -package creator.loki.command.impl.moderation; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedTemplates; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; - -import java.util.concurrent.TimeUnit; - -public class ClearCommand implements Command { - - @Override - public String getName() { - return "clear"; - } - - @Override - public String getDescription() { - return "Удаляет указанное количество сообщений"; - } - - @Override - public String getUsage() { - return "clear <количество>"; - } - - @Override - public void execute(CommandContext context) { - // Проверка прав - if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.MESSAGE_MANAGE)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Недостаточно прав", - "У вас нет прав на управление сообщениями" - ) - ).queue(); - return; - } - - // Проверка аргументов - if (context.getArgs().isEmpty()) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Неверное количество", - "Укажите число от 1 до 100" - ) - ).queue(); - return; - } - - int amount; - try { - amount = Integer.parseInt(context.getArgs().get(0)); - } catch (NumberFormatException e) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Неверное количество", - "Укажите число от 1 до 100" - ) - ).queue(); - return; - } - - if (amount < 1 || amount > 100) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Неверное количество", - "Укажите число от 1 до 100" - ) - ).queue(); - return; - } - - // Удаление сообщений - if (!(context.getChannel() instanceof TextChannel)) { - return; - } - - TextChannel channel = (TextChannel) context.getChannel(); - - channel.getHistory().retrievePast(amount + 1).queue(messages -> { - channel.deleteMessages(messages).queue( - success -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.success( - "Сообщения удалены", - "Удалено сообщений: " + messages.size() - ) - ).queue(msg -> msg.delete().queueAfter(5, TimeUnit.SECONDS)); - }, - error -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Ошибка удаления", - "Не удалось удалить сообщения: " + error.getMessage() - ) - ).queue(); - } - ); - }); - } - - @Override - public String getCategory() { - return "Модерация"; - } -} diff --git a/src/main/java/creator/loki/command/impl/moderation/KickCommand.java b/src/main/java/creator/loki/command/impl/moderation/KickCommand.java deleted file mode 100644 index 4ad1d01..0000000 --- a/src/main/java/creator/loki/command/impl/moderation/KickCommand.java +++ /dev/null @@ -1,118 +0,0 @@ -package creator.loki.command.impl.moderation; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedTemplates; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; - -import java.util.List; - -public class KickCommand implements Command { - - @Override - public String getName() { - return "kick"; - } - - @Override - public String getDescription() { - return "Кикает пользователя с сервера"; - } - - @Override - public String getUsage() { - return "kick <@user> [причина]"; - } - - @Override - public String getCategory() { - return "Модерация"; - } - - @Override - public void execute(CommandContext context) { - if (context.getGuild() == null) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Команда доступна только на сервере") - ).queue(); - return; - } - - if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.KICK_MEMBERS)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "У вас нет прав на кик пользователей") - ).queue(); - return; - } - - if (!context.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "У бота нет прав на кик пользователей") - ).queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Укажите пользователя через @") - ).queue(); - return; - } - - Member target = mentioned.get(0); - - if (target.getId().equals(context.getAuthor().getId())) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Вы не можете кикнуть самого себя") - ).queue(); - return; - } - - if (target.getUser().isBot() && target.getId().equals(context.getGuild().getSelfMember().getId())) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Я не могу кикнуть самого себя") - ).queue(); - return; - } - - if (!context.getAuthor().canInteract(target)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "Вы не можете кикнуть этого пользователя") - ).queue(); - return; - } - - if (!context.getGuild().getSelfMember().canInteract(target)) { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Недостаточно прав", "Я не могу кикнуть этого пользователя") - ).queue(); - return; - } - - String reason = context.getArgs().size() > 1 - ? String.join(" ", context.getArgs().subList(1, context.getArgs().size())) - : "Причина не указана"; - - context.getGuild().kick(target) - .reason(reason) - .queue( - success -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.success( - "Пользователь кикнут", - "**Пользователь:** " + target.getUser().getAsTag() + "\n" + - "**Причина:** " + reason + "\n" + - "**Модератор:** " + context.getAuthor().getAsMention() - ) - ).queue(); - }, - error -> { - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Не удалось кикнуть: " + error.getMessage()) - ).queue(); - } - ); - } -} diff --git a/src/main/java/creator/loki/command/impl/setup/LogSetupCommand.java b/src/main/java/creator/loki/command/impl/setup/LogSetupCommand.java deleted file mode 100644 index a0b6b1c..0000000 --- a/src/main/java/creator/loki/command/impl/setup/LogSetupCommand.java +++ /dev/null @@ -1,122 +0,0 @@ -package creator.loki.command.impl.setup; - -import creator.loki.database.ConfigRepository; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.awt.Color; - -/** - * Команда настройки логирования - */ -public class LogSetupCommand extends ListenerAdapter { - private static final Logger logger = LoggerFactory.getLogger(LogSetupCommand.class); - private final ConfigRepository configRepo; - - public LogSetupCommand(ConfigRepository configRepo) { - this.configRepo = configRepo; - } - - @Override - public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { - if (!"log-setup".equals(event.getName())) { - return; - } - - Member member = event.getMember(); - if (member == null || !member.hasPermission(Permission.ADMINISTRATOR)) { - event.reply("❌ У вас нет прав для использования этой команды!").setEphemeral(true).queue(); - return; - } - - String subcommand = event.getSubcommandName(); - if (subcommand == null) { - return; - } - - switch (subcommand) { - case "moderation" -> handleModeration(event); - case "levelup" -> handleLevelUp(event); - case "info" -> handleInfo(event); - } - } - - private void handleModeration(SlashCommandInteractionEvent event) { - TextChannel channel = event.getOption("channel").getAsChannel().asTextChannel(); - - configRepo.set("moderation.log_channel_id", channel.getId()); - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("✅ Канал логов модерации установлен") - .setDescription("Логи модерации будут отправляться в " + channel.getAsMention()) - .addField("Что логируется", - "• Удаление сообщений\n" + - "• Баны/разбаны\n" + - "• Кики\n" + - "• Муты/размуты\n" + - "• Отключение микрофона/звука", false) - .setColor(Color.GREEN); - - event.replyEmbeds(embed.build()).queue(); - logger.info("Установлен канал логов модерации: {} ({})", channel.getName(), channel.getId()); - } - - private void handleLevelUp(SlashCommandInteractionEvent event) { - TextChannel channel = event.getOption("channel").getAsChannel().asTextChannel(); - - configRepo.set("levelup.notification_channel_id", channel.getId()); - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("✅ Канал уведомлений о level up установлен") - .setDescription("Уведомления о повышении уровня будут отправляться в " + channel.getAsMention()) - .setColor(Color.GREEN); - - event.replyEmbeds(embed.build()).queue(); - logger.info("Установлен канал уведомлений level up: {} ({})", channel.getName(), channel.getId()); - } - - private void handleInfo(SlashCommandInteractionEvent event) { - String moderationChannelId = configRepo.get("moderation.log_channel_id"); - String levelupChannelId = configRepo.get("levelup.notification_channel_id"); - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("📋 Конфигурация логирования") - .setColor(Color.BLUE); - - if (moderationChannelId != null) { - embed.addField("Логи модерации", "<#" + moderationChannelId + ">", false); - } else { - embed.addField("Логи модерации", "❌ Не установлен", false); - } - - if (levelupChannelId != null) { - embed.addField("Уведомления level up", "<#" + levelupChannelId + ">", false); - } else { - embed.addField("Уведомления level up", "❌ Не установлен", false); - } - - event.replyEmbeds(embed.build()).setEphemeral(true).queue(); - } - - public static void register(net.dv8tion.jda.api.JDA jda) { - jda.updateCommands().addCommands( - Commands.slash("log-setup", "Настройка логирования") - .addSubcommands( - new SubcommandData("moderation", "Установить канал для логов модерации") - .addOption(OptionType.CHANNEL, "channel", "Текстовый канал", true), - new SubcommandData("levelup", "Установить канал для уведомлений о level up") - .addOption(OptionType.CHANNEL, "channel", "Текстовый канал", true), - new SubcommandData("info", "Показать текущую конфигурацию") - ) - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/stats/RankCommand.java b/src/main/java/creator/loki/command/impl/stats/RankCommand.java deleted file mode 100644 index 98f995a..0000000 --- a/src/main/java/creator/loki/command/impl/stats/RankCommand.java +++ /dev/null @@ -1,89 +0,0 @@ -package creator.loki.command.impl.stats; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.feature.level.domain.LevelProgress; -import creator.loki.feature.level.domain.UserStats; -import creator.loki.level.LevelSystem; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; - -/** - * Команда для просмотра уровня и статистики - */ -public class RankCommand implements Command { - private final LevelSystem levelSystem; - - public RankCommand(LevelSystem levelSystem) { - this.levelSystem = levelSystem; - } - - @Override - public String getName() { - return "rank"; - } - - @Override - public String getDescription() { - return "Посмотреть свой уровень и статистику"; - } - - @Override - public String getUsage() { - return "rank [@user]"; - } - - @Override - public String getCategory() { - return "Статистика"; - } - - @Override - public void execute(CommandContext context) { - Member target; - - if (!context.getMessage().getMentions().getMembers().isEmpty()) { - target = context.getMessage().getMentions().getMembers().get(0); - } else { - target = context.getAuthor(); - } - - if (target == null) { - context.getChannel().sendMessage("❌ Пользователь не найден!").queue(); - return; - } - - String userId = target.getId(); - UserStats stats = levelSystem.getUserStats(userId); - LevelProgress progress = levelSystem.getProgress(userId); - - // Прогресс-бар - int barLength = 20; - int filled = (int) (progress.getPercentage() / 100.0 * barLength); - StringBuilder progressBar = new StringBuilder(); - for (int i = 0; i < barLength; i++) { - progressBar.append(i < filled ? "█" : "░"); - } - - long hours = stats.getVoiceMinutes() / 60; - long minutes = stats.getVoiceMinutes() % 60; - - var embed = EmbedFactory.create() - .setTitle("📊 Статистика " + target.getEffectiveName()) - .setThumbnail(target.getEffectiveAvatarUrl()) - .addField("🎯 Уровень", "**" + stats.getLevel() + "**", true) - .addField("⭐ Всего XP", "**" + stats.getTotalXP() + "**", true) - .addField("📈 Прогресс", - progressBar.toString() + " **" + String.format("%.1f", progress.getPercentage()) + "%**\n" + - progress.getCurrentLevelXP() + " / " + progress.getXpForNextLevel() + " XP до следующего уровня", - false) - .addField("💬 Сообщений", "**" + stats.getMessageCount() + "**", true) - .addField("🎤 Время в войсе", "**" + hours + "ч " + minutes + "м**", true) - .setColor(new Color(46, 204, 113)) - .build(); - - context.getChannel().sendMessageEmbeds(embed).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/stats/StatsCommand.java b/src/main/java/creator/loki/command/impl/stats/StatsCommand.java deleted file mode 100644 index a72ef83..0000000 --- a/src/main/java/creator/loki/command/impl/stats/StatsCommand.java +++ /dev/null @@ -1,75 +0,0 @@ -package creator.loki.command.impl.stats; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.OnlineStatus; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; - -public class StatsCommand implements Command { - - @Override - public String getName() { - return "stats"; - } - - @Override - public String getDescription() { - return "Статистика сервера"; - } - - @Override - public String getUsage() { - return "stats"; - } - - @Override - public String getCategory() { - return "Статистика"; - } - - @Override - public void execute(CommandContext context) { - if (context.getGuild() == null) { - context.getChannel().sendMessage("Команда доступна только на сервере").queue(); - return; - } - - Guild guild = context.getGuild(); - - // Подсчёт статистики - int totalMembers = guild.getMemberCount(); - long onlineMembers = guild.getMembers().stream() - .filter(m -> m.getOnlineStatus() == OnlineStatus.ONLINE) - .count(); - long inVoice = guild.getMembers().stream() - .filter(m -> m.getVoiceState() != null && m.getVoiceState().inAudioChannel()) - .count(); - int textChannels = guild.getTextChannels().size(); - int voiceChannels = guild.getVoiceChannels().size(); - int roles = guild.getRoles().size(); - int bots = (int) guild.getMembers().stream() - .filter(m -> m.getUser().isBot()) - .count(); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("📊 Статистика сервера") - .setThumbnail(guild.getIconUrl()) - .addField("👥 Участников", String.valueOf(totalMembers), true) - .addField("🟢 Онлайн", String.valueOf(onlineMembers), true) - .addField("🔊 В войсе", String.valueOf(inVoice), true) - .addField("💬 Текстовых каналов", String.valueOf(textChannels), true) - .addField("🎤 Голосовых каналов", String.valueOf(voiceChannels), true) - .addField("🎭 Ролей", String.valueOf(roles), true) - .addField("🤖 Ботов", String.valueOf(bots), true) - .addField("👤 Людей", String.valueOf(totalMembers - bots), true) - .setColor(new Color(52, 152, 219)) - .setFooter("Сервер: " + guild.getName()) - .build() - ).queue(); - } -} diff --git a/src/main/java/creator/loki/command/impl/utility/HelpCommand.java b/src/main/java/creator/loki/command/impl/utility/HelpCommand.java deleted file mode 100644 index 2660e94..0000000 --- a/src/main/java/creator/loki/command/impl/utility/HelpCommand.java +++ /dev/null @@ -1,159 +0,0 @@ -package creator.loki.command.impl.utility; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.config.BotConfig; -import creator.loki.core.registry.CommandRegistry; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; - -import java.awt.Color; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class HelpCommand implements Command { - private final CommandRegistry registry; - private final BotConfig config; - - private static final Map CATEGORY_EMOJIS = new HashMap<>(); - private static final Map CATEGORY_COLORS = new HashMap<>(); - private static final Map CATEGORY_DESCRIPTIONS = new HashMap<>(); - - static { - CATEGORY_EMOJIS.put("Общие", "📋"); - CATEGORY_EMOJIS.put("Роли", "🎭"); - CATEGORY_EMOJIS.put("Развлечения", "🎮"); - CATEGORY_EMOJIS.put("Модерация", "🛡️"); - CATEGORY_EMOJIS.put("Статистика", "📊"); - CATEGORY_EMOJIS.put("Утилиты", "🔧"); - CATEGORY_EMOJIS.put("Настройка", "⚙️"); - - CATEGORY_COLORS.put("Общие", new Color(52, 152, 219)); - CATEGORY_COLORS.put("Роли", new Color(155, 89, 182)); - CATEGORY_COLORS.put("Развлечения", new Color(241, 196, 15)); - CATEGORY_COLORS.put("Модерация", new Color(231, 76, 60)); - CATEGORY_COLORS.put("Статистика", new Color(46, 204, 113)); - CATEGORY_COLORS.put("Утилиты", new Color(52, 152, 219)); - CATEGORY_COLORS.put("Настройка", new Color(149, 165, 166)); - - CATEGORY_DESCRIPTIONS.put("Общие", "Основные команды бота"); - CATEGORY_DESCRIPTIONS.put("Роли", "Управление кастомными ролями"); - CATEGORY_DESCRIPTIONS.put("Развлечения", "Игры и взаимодействия"); - CATEGORY_DESCRIPTIONS.put("Модерация", "Команды модерации сервера"); - CATEGORY_DESCRIPTIONS.put("Статистика", "Уровни, репутация и статистика"); - CATEGORY_DESCRIPTIONS.put("Утилиты", "Полезные инструменты"); - CATEGORY_DESCRIPTIONS.put("Настройка", "Настройка систем бота (только админы)"); - } - - public HelpCommand(CommandRegistry registry, BotConfig config) { - this.registry = registry; - this.config = config; - } - - @Override - public String getName() { - return "help"; - } - - @Override - public String getDescription() { - return "Показывает список всех команд"; - } - - @Override - public void execute(CommandContext context) { - var commands = registry.getAllCommands(); - Map> commandsByCategory = commands.stream() - .collect(Collectors.groupingBy(Command::getCategory)); - - // Главная страница - EmbedFactory embed = createMainPage(context, commandsByCategory); - - // Создаём селектор категорий - StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("help:category") - .setPlaceholder("📂 Выберите категорию"); - - boolean isAdmin = context.getAuthor() != null && - context.getAuthor().hasPermission(Permission.ADMINISTRATOR); - - commandsByCategory.keySet().stream() - .sorted() - .forEach(category -> { - // Скрываем категорию "Настройка" для не-админов - if (category.equals("Настройка") && !isAdmin) { - return; - } - - String emoji = CATEGORY_EMOJIS.getOrDefault(category, "📌"); - String description = CATEGORY_DESCRIPTIONS.getOrDefault(category, "Команды категории"); - int count = commandsByCategory.get(category).size(); - - menuBuilder.addOption( - category, - "category:" + category, - description + " (" + count + " команд)", - net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji) - ); - }); - - context.getChannel() - .sendMessageEmbeds(embed.build()) - .addActionRow(menuBuilder.build()) - .queue(); - } - - private EmbedFactory createMainPage(CommandContext context, Map> commandsByCategory) { - int totalCommands = commandsByCategory.values().stream() - .mapToInt(List::size) - .sum(); - - boolean isAdmin = context.getAuthor() != null && - context.getAuthor().hasPermission(Permission.ADMINISTRATOR); - - StringBuilder categoriesText = new StringBuilder(); - commandsByCategory.keySet().stream() - .sorted() - .forEach(category -> { - // Скрываем категорию "Настройка" для не-админов - if (category.equals("Настройка") && !isAdmin) { - return; - } - - String emoji = CATEGORY_EMOJIS.getOrDefault(category, "📌"); - int count = commandsByCategory.get(category).size(); - categoriesText.append(emoji).append(" **").append(category) - .append("** — ").append(count).append(" команд\n"); - }); - - return EmbedFactory.create() - .setTitle("📚 Справка по командам Loki Bot") - .setDescription( - "Привет! Я многофункциональный Discord бот с системой уровней, " + - "личными голосовыми каналами и множеством развлекательных команд.\n\n" + - "**Префикс команд:** `" + config.getPrefix() + "`\n" + - "**Всего команд:** " + totalCommands + "\n\n" + - "**📂 Категории команд:**\n" + categoriesText.toString() + "\n" + - "Используй селектор ниже для просмотра команд категории." - ) - .setColor(new Color(88, 101, 242)) - .setThumbnail(context.getGuild() != null ? context.getGuild().getIconUrl() : null) - .addField("💡 Как использовать", - "Большинство команд используют префикс `!`\n" + - "Пример: `!rank`, `!ship @user1 @user2`\n\n" + - "Некоторые важные команды используют `/`\n" + - "Пример: `/voice-setup`, `/counter-setup`", - false) - .setFooter( - "Запросил: " + context.getAuthor().getUser().getName(), - context.getAuthor().getUser().getAvatarUrl() - ); - } - - @Override - public String getCategory() { - return "Утилиты"; - } -} diff --git a/src/main/java/creator/loki/command/impl/utility/ProfileCommand.java b/src/main/java/creator/loki/command/impl/utility/ProfileCommand.java deleted file mode 100644 index 950f36b..0000000 --- a/src/main/java/creator/loki/command/impl/utility/ProfileCommand.java +++ /dev/null @@ -1,88 +0,0 @@ -package creator.loki.command.impl.utility; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.data.JsonDataStore; -import creator.loki.profile.ProfileData; -import creator.loki.profile.ProfileGenerator; -import creator.loki.profile.ProfileService; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.utils.FileUpload; - -import java.util.List; - -/** - * Команда для отображения профиля пользователя - */ -public class ProfileCommand implements Command { - private final ProfileService profileService; - private final ProfileGenerator profileGenerator; - - public ProfileCommand(JsonDataStore dataStore) { - this.profileService = new ProfileService(dataStore); - this.profileGenerator = new ProfileGenerator(); - } - - @Override - public String getName() { - return "profile"; - } - - @Override - public String getDescription() { - return "Показать профиль пользователя"; - } - - @Override - public String getUsage() { - return "profile [@user]"; - } - - @Override - public String getCategory() { - return "Общие"; - } - - @Override - public void execute(CommandContext context) { - if (context.getGuild() == null) { - context.getChannel().sendMessage("Команда доступна только на сервере").queue(); - return; - } - - // Определяем целевого пользователя - Member target; - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - target = context.getAuthor(); - } else { - target = mentioned.get(0); - } - - if (target == null) { - context.getChannel().sendMessage("Пользователь не найден").queue(); - return; - } - - // Отправляем сообщение о генерации - context.getChannel().sendMessage("🎨 Генерирую профиль...").queue(loadingMsg -> { - try { - // Получаем данные профиля - ProfileData profileData = profileService.getProfileData(target, context.getGuild()); - - // Генерируем изображение - byte[] imageBytes = profileGenerator.generateProfile(profileData); - - // Отправляем изображение - loadingMsg.delete().queue(); - context.getChannel().sendFiles( - FileUpload.fromData(imageBytes, "profile.png") - ).queue(); - - } catch (Exception e) { - loadingMsg.editMessage("❌ Ошибка генерации профиля: " + e.getMessage()).queue(); - e.printStackTrace(); - } - }); - } -} diff --git a/src/main/java/creator/loki/command/impl/utility/system/MetricsCommand.java b/src/main/java/creator/loki/command/impl/utility/system/MetricsCommand.java deleted file mode 100644 index e57687e..0000000 --- a/src/main/java/creator/loki/command/impl/utility/system/MetricsCommand.java +++ /dev/null @@ -1,63 +0,0 @@ -package creator.loki.command.impl.utility.system; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.metrics.MetricsCollector; -import net.dv8tion.jda.api.Permission; - -import java.awt.Color; - -public class MetricsCommand implements Command { - private final MetricsCollector metrics; - - public MetricsCommand(MetricsCollector metrics) { - this.metrics = metrics; - } - - @Override - public void execute(CommandContext ctx) { - // Только администраторы могут видеть метрики - if (ctx.getAuthor() == null || !ctx.getAuthor().hasPermission(Permission.ADMINISTRATOR)) { - ctx.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("❌ Недостаточно прав") - .setDescription("Эта команда доступна только администраторам.") - .setColor(Color.RED) - .build() - ).queue(); - return; - } - - String stats = metrics.getFullStats(); - - ctx.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("📊 Метрики бота") - .setDescription("```\n" + stats + "\n```") - .setColor(new Color(88, 101, 242)) - .setFooter("Используй !metrics reset для сброса") - .build() - ).queue(); - } - - @Override - public String getName() { - return "metrics"; - } - - @Override - public String getDescription() { - return "Показывает метрики и статистику бота (только для администраторов)"; - } - - @Override - public String getUsage() { - return "metrics"; - } - - @Override - public String getCategory() { - return "Система"; - } -} diff --git a/src/main/java/creator/loki/config/BotConfig.java b/src/main/java/creator/loki/config/BotConfig.java index 7b9e544..a976455 100644 --- a/src/main/java/creator/loki/config/BotConfig.java +++ b/src/main/java/creator/loki/config/BotConfig.java @@ -1,25 +1,26 @@ -package creator.loki.config; - -public class BotConfig { - private final String token; - private final String prefix; - private final String activity; - - public BotConfig(String token, String prefix, String activity) { - this.token = token; - this.prefix = prefix; - this.activity = activity; - } - - public String getToken() { - return token; - } - - public String getPrefix() { - return prefix; - } - - public String getActivity() { - return activity; - } -} +package creator.loki.config; + +public class BotConfig { + private final String token; + private final String prefix; + private final String activity; + private final int healthHttpPort; + private final String superAdminId; + private final String dbPath; + + public BotConfig(String token, String prefix, String activity, int healthHttpPort, String superAdminId, String dbPath) { + this.token = token; + this.prefix = prefix; + this.activity = activity; + this.healthHttpPort = healthHttpPort; + this.superAdminId = superAdminId; + this.dbPath = dbPath; + } + + public String getToken() { return token; } + public String getPrefix() { return prefix; } + public String getActivity() { return activity; } + public int getHealthHttpPort() { return healthHttpPort; } + public String getSuperAdminId() { return superAdminId; } + public String getDbPath() { return dbPath; } +} diff --git a/src/main/java/creator/loki/config/ConfigLoader.java b/src/main/java/creator/loki/config/ConfigLoader.java index 37defaf..362b8e8 100644 --- a/src/main/java/creator/loki/config/ConfigLoader.java +++ b/src/main/java/creator/loki/config/ConfigLoader.java @@ -1,39 +1,67 @@ -package creator.loki.config; - -import io.github.cdimascio.dotenv.Dotenv; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public final class ConfigLoader { - private static final Logger logger = LoggerFactory.getLogger(ConfigLoader.class); - - private ConfigLoader() { - // Запрет создания экземпляров - } - - public static BotConfig load() { - Dotenv dotenv = Dotenv.configure() - .ignoreIfMissing() - .load(); - - String token = dotenv.get("DISCORD_BOT_TOKEN"); - if (token == null || token.isBlank()) { - throw new IllegalStateException( - "Токен бота не найден! Установите переменную окружения DISCORD_BOT_TOKEN или создайте .env файл" - ); - } - - String prefix = dotenv.get("BOT_PREFIX"); - if (prefix == null || prefix.isBlank()) { - prefix = "!"; - } - - String activity = dotenv.get("BOT_ACTIVITY"); - if (activity == null || activity.isBlank()) { - activity = "Используй !help"; - } - - logger.info("Конфигурация загружена успешно"); - return new BotConfig(token, prefix, activity); - } -} +package creator.loki.config; + +import io.github.cdimascio.dotenv.Dotenv; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ConfigLoader { + private static final Logger logger = LoggerFactory.getLogger(ConfigLoader.class); + + private ConfigLoader() { + } + + private static int parsePort(String raw, String key) { + if (raw == null || raw.isBlank()) return 0; + try { + int port = Integer.parseInt(raw.trim()); + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("порт вне диапазона 0..65535"); + } + return port; + } catch (NumberFormatException e) { + throw new IllegalStateException( + "Некорректный " + key + ": \"" + raw + "\". Ожидается число 0..65535.", + e + ); + } + } + + public static BotConfig load() { + Dotenv dotenv = Dotenv.configure() + .ignoreIfMissing() + .load(); + + String token = dotenv.get("DISCORD_BOT_TOKEN"); + if (token == null || token.isBlank()) { + throw new IllegalStateException( + "Токен бота не найден! Установите переменную окружения DISCORD_BOT_TOKEN или создайте .env файл" + ); + } + + String prefix = dotenv.get("BOT_PREFIX"); + if (prefix == null || prefix.isBlank()) { + prefix = "!"; + } + + String activity = dotenv.get("BOT_ACTIVITY"); + if (activity == null || activity.isBlank()) { + activity = "Используй !help"; + } + + int healthHttpPort = parsePort(dotenv.get("HEALTH_HTTP_PORT"), "HEALTH_HTTP_PORT"); + + String superAdminId = dotenv.get("SUPER_ADMIN_ID"); + if (superAdminId == null || superAdminId.isBlank()) { + superAdminId = "1414975197269987471"; + logger.warn("SUPER_ADMIN_ID не задан, используется значение по умолчанию"); + } + + String dbPath = dotenv.get("DB_PATH"); + if (dbPath == null || dbPath.isBlank()) { + dbPath = "data/bot.db"; + } + + logger.info("Конфигурация загружена успешно"); + return new BotConfig(token, prefix, activity, healthHttpPort, superAdminId, dbPath); + } +} diff --git a/src/main/java/creator/loki/core/BotInitializer.java b/src/main/java/creator/loki/core/BotInitializer.java index a3cfe97..2bca0ee 100644 --- a/src/main/java/creator/loki/core/BotInitializer.java +++ b/src/main/java/creator/loki/core/BotInitializer.java @@ -1,37 +1,11 @@ package creator.loki.core; import com.google.inject.Inject; - -import creator.loki.command.impl.setup.LogSetupCommand; -import creator.loki.command.impl.stats.leaderboard.LeaderboardButtonListener; import creator.loki.config.BotConfig; -import creator.loki.core.EventDispatcher; -import creator.loki.core.registry.CommandRegistry; import creator.loki.core.registry.PrefixCommandRegistrar; -import creator.loki.core.registry.SlashCommandRegistrar; -import creator.loki.core.registry.SlashCommandRegistry; -import creator.loki.feature.counter.CounterManager; -import creator.loki.data.JsonDataStore; -import creator.loki.database.ConfigRepository; -import creator.loki.database.Database; -import creator.loki.feature.counter.CounterSetupCommand; import creator.loki.feature.counter.MemberCounterListener; -import creator.loki.feature.level.LevelUpNotifier; -import creator.loki.feature.level.service.LevelService; -import creator.loki.feature.voice.VoiceButtonHandler; -import creator.loki.feature.voice.VoiceChannelListener; -import creator.loki.feature.voice.VoiceChannelManager; -import creator.loki.feature.voice.VoiceSetupCommand; -import creator.loki.feature.level.VoiceTracker; -import creator.loki.level.LevelSystem; -import creator.loki.listener.*; -import creator.loki.listener.interaction.SlashCommandListener; -import creator.loki.metrics.MetricsCollector; -import creator.loki.profile.ProfileService; -import creator.loki.feature.role.CustomRoleManager; -import creator.loki.core.security.CooldownManager; -import creator.loki.core.security.RateLimiter; -import creator.loki.feature.voice.VoiceChannelManager; +import creator.loki.feature.moderation.MuteScheduler; +import creator.loki.feature.metrics.HealthHttpServer; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.OnlineStatus; @@ -44,118 +18,52 @@ public class BotInitializer { private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class); private final BotConfig config; - private final CommandRegistry registry; - private final SlashCommandRegistry slashRegistry; - private final EventDispatcher dispatcher; - private final JsonDataStore dataStore; - private final CustomRoleManager roleManager; - private final CooldownManager cooldownManager; - private final RateLimiter rateLimiter; - private final MetricsCollector metrics; - private final LevelSystem levelSystem; - private final LevelService levelService; - private final VoiceTracker voiceTracker; - private final ProfileService profileService; - private final creator.loki.feature.profile.ProfileService newProfileService; - private final VoiceChannelManager voiceChannelManager; - private final CounterManager counterManager; - private final VoiceChannelListener voiceChannelListener; + private final PrefixCommandRegistrar registrar; + private final ListenerAggregator listenerAggregator; + private final MuteScheduler muteScheduler; private final MemberCounterListener memberCounterListener; - private final Database database; - private final ConfigRepository configRepo; - private final VoiceSetupCommand voiceSetupCommand; - private final CounterSetupCommand counterSetupCommand; - private final LogSetupCommand logSetupCommand; - private final VoiceButtonHandler voiceButtonHandler; - private final ModerationLogger moderationLogger; - private final LevelUpNotifier levelUpNotifier; - private final HelpMenuHandler helpMenuHandler; + private final HealthHttpServer healthHttpServer; + private final ShutdownManager shutdownManager; @Inject public BotInitializer( BotConfig config, - CommandRegistry registry, - SlashCommandRegistry slashRegistry, - EventDispatcher dispatcher, - JsonDataStore dataStore, - CustomRoleManager roleManager, - CooldownManager cooldownManager, - RateLimiter rateLimiter, - MetricsCollector metrics, - LevelSystem levelSystem, - LevelService levelService, - VoiceTracker voiceTracker, - ProfileService profileService, - creator.loki.feature.profile.ProfileService newProfileService, - VoiceChannelManager voiceChannelManager, - CounterManager counterManager, - Database database, - ConfigRepository configRepo, - LevelUpNotifier levelUpNotifier + PrefixCommandRegistrar registrar, + ListenerAggregator listenerAggregator, + MuteScheduler muteScheduler, + MemberCounterListener memberCounterListener, + HealthHttpServer healthHttpServer, + ShutdownManager shutdownManager ) { this.config = config; - this.registry = registry; - this.slashRegistry = slashRegistry; - this.dispatcher = dispatcher; - this.dataStore = dataStore; - this.roleManager = roleManager; - this.cooldownManager = cooldownManager; - this.rateLimiter = rateLimiter; - this.metrics = metrics; - this.levelSystem = levelSystem; - this.levelService = levelService; - this.voiceTracker = voiceTracker; - this.profileService = profileService; - this.newProfileService = newProfileService; - this.voiceChannelManager = voiceChannelManager; - this.counterManager = counterManager; - this.database = database; - this.configRepo = configRepo; - this.levelUpNotifier = levelUpNotifier; - this.voiceChannelListener = new VoiceChannelListener(voiceChannelManager); - this.memberCounterListener = new MemberCounterListener(counterManager); - this.voiceSetupCommand = new VoiceSetupCommand(configRepo); - this.counterSetupCommand = new CounterSetupCommand(configRepo); - this.logSetupCommand = new LogSetupCommand(configRepo); - this.voiceButtonHandler = new VoiceButtonHandler(voiceChannelManager); - this.moderationLogger = new ModerationLogger(configRepo); - this.helpMenuHandler = new HelpMenuHandler(registry, config); + this.registrar = registrar; + this.listenerAggregator = listenerAggregator; + this.muteScheduler = muteScheduler; + this.memberCounterListener = memberCounterListener; + this.healthHttpServer = healthHttpServer; + this.shutdownManager = shutdownManager; } public JDA start() { logger.info("Инициализация бота..."); - - // Регистрируем команды через специализированные регистраторы - registerCommands(); - registerSlashCommands(); - - // Создаём JDA + registrar.registerAll(); JDA jda = createJDA(); - - // Регистрируем slash команды в Discord - registerSlashCommandsInDiscord(jda); - - // Настраиваем graceful shutdown - setupShutdownHook(jda); - + try { + jda.awaitReady(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Бот прерван во время ожидания готовности", e); + } + jda.updateCommands().queue(); + muteScheduler.restorePending(jda); + for (var guild : jda.getGuilds()) { + memberCounterListener.startScheduler(guild); + } + healthHttpServer.start(); + registerShutdownHook(jda); return jda; } - private void registerCommands() { - PrefixCommandRegistrar registrar = new PrefixCommandRegistrar( - registry, config, dataStore, roleManager, cooldownManager, - levelSystem, levelService, newProfileService, metrics - ); - registrar.registerAll(); - } - - private void registerSlashCommands() { - SlashCommandRegistrar registrar = new SlashCommandRegistrar( - slashRegistry, dataStore, metrics - ); - registrar.registerAll(); - } - private JDA createJDA() { return JDABuilder.createDefault(config.getToken()) .setStatus(OnlineStatus.ONLINE) @@ -166,85 +74,12 @@ public class BotInitializer { GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_VOICE_STATES ) - .addEventListeners( - new ReadyListener(), - new MessageListener(dispatcher, dataStore, levelSystem), - new SlashCommandListener(slashRegistry, rateLimiter, metrics), - voiceTracker, - voiceChannelListener, - memberCounterListener, - voiceSetupCommand, - counterSetupCommand, - logSetupCommand, - voiceButtonHandler, - moderationLogger, - helpMenuHandler, - new LeaderboardButtonListener(levelSystem) - ) + .addEventListeners(listenerAggregator.getAll()) .build(); } - private void registerSlashCommandsInDiscord(JDA jda) { - try { - jda.awaitReady(); - slashRegistry.registerCommandsInDiscord(jda); - - // Регистрируем setup команды - VoiceSetupCommand.register(jda); - CounterSetupCommand.register(jda); - LogSetupCommand.register(jda); - - // Запускаем планировщик счетчика участников - if (!jda.getGuilds().isEmpty()) { - memberCounterListener.startScheduler(jda.getGuilds().get(0)); - } - } catch (InterruptedException e) { - logger.error("Прервано ожидание готовности JDA", e); - Thread.currentThread().interrupt(); - } - } - - private void setupShutdownHook(JDA jda) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - logger.info("========================================"); - logger.info("Получен сигнал завершения работы бота..."); - logger.info("========================================"); - - shutdownComponents(jda); - - logger.info("========================================"); - logger.info("Бот успешно завершён!"); - logger.info("========================================"); - }, "ShutdownHook")); - } - - private void shutdownComponents(JDA jda) { - try { - logger.info("Сохранение данных..."); - dataStore.flush(); - - logger.info("Остановка менеджеров..."); - cooldownManager.shutdown(); - rateLimiter.shutdown(); - voiceTracker.shutdown(); - memberCounterListener.shutdown(); - dispatcher.shutdown(); - - logger.info("Завершение JsonDataStore..."); - dataStore.shutdown(); - - logger.info("Закрытие базы данных..."); - database.close(); - - logger.info("Отключение JDA..."); - jda.shutdown(); - - if (!jda.awaitShutdown(java.time.Duration.ofSeconds(10))) { - logger.warn("JDA не завершился за 10 секунд, принудительная остановка"); - jda.shutdownNow(); - } - } catch (Exception e) { - logger.error("Ошибка при завершении работы бота", e); - } + private void registerShutdownHook(JDA jda) { + Runtime.getRuntime().addShutdownHook(new Thread( + () -> shutdownManager.shutdown(jda), "ShutdownHook")); } } diff --git a/src/main/java/creator/loki/core/EventDispatcher.java b/src/main/java/creator/loki/core/EventDispatcher.java index 42e103f..e69a8f1 100644 --- a/src/main/java/creator/loki/core/EventDispatcher.java +++ b/src/main/java/creator/loki/core/EventDispatcher.java @@ -1,130 +1,130 @@ -package creator.loki.core; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.config.BotConfig; -import creator.loki.core.registry.CommandRegistry; -import creator.loki.embed.EmbedTemplates; -import creator.loki.exception.CommandExceptionHandler; -import creator.loki.metrics.MetricsCollector; -import creator.loki.core.security.RateLimiter; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -public class EventDispatcher { - private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class); - private final BotConfig config; - private final CommandRegistry registry; - private final ExecutorService executorService; - private final RateLimiter rateLimiter; - private final MetricsCollector metrics; - - public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter, MetricsCollector metrics) { - this.config = config; - this.registry = registry; - this.executorService = Executors.newCachedThreadPool(); - this.rateLimiter = rateLimiter; - this.metrics = metrics; - } - - public void handleMessage(MessageReceivedEvent event) { - // Игнорируем ботов - if (event.getAuthor().isBot()) { - return; - } - - String content = event.getMessage().getContentRaw(); - if (!content.startsWith(config.getPrefix())) { - return; - } - - // Парсим команду и аргументы - String[] parts = content.substring(config.getPrefix().length()).trim().split("\\s+"); - if (parts.length == 0) { - return; - } - - String commandName = parts[0].toLowerCase(); - List args = parts.length > 1 - ? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length)) - : List.of(); - - Command command = registry.getCommand(commandName); - if (command == null) { - event.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Команда не найдена", - "Команда `" + commandName + "` не существует. Используй `" + config.getPrefix() + "help`" - ) - ).queue(); - return; - } - - // Проверяем глобальный rate limit - String userId = event.getAuthor().getId(); - String guildId = event.getGuild() != null ? event.getGuild().getId() : "DM"; - - if (!rateLimiter.allow(userId, guildId)) { - long waitTime = rateLimiter.getUserWaitTime(userId); - event.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "⏱️ Слишком быстро!", - "Ты отправляешь команды слишком часто. Подожди " + waitTime + " секунд." - ) - ).queue(); - logger.warn("Rate limit для пользователя {} в гильдии {}", userId, guildId); - return; - } - - // Выполняем команду асинхронно - executorService.submit(() -> { - long startTime = System.currentTimeMillis(); - try { - CommandContext context = new CommandContext( - event.getMessage(), - args, - event.getChannel(), - event.getMember(), - event.getGuild() - ); - command.execute(context); - - // Записываем метрики - long executionTime = System.currentTimeMillis() - startTime; - metrics.recordCommand(commandName, executionTime); - - logger.info("Выполнена команда: {} от {} за {} мс", - commandName, event.getAuthor().getName(), executionTime); - } catch (Exception e) { - // Записываем ошибку в метрики - metrics.recordError(commandName); - - // Используем централизованный обработчик ошибок - CommandExceptionHandler.handle(e, event.getChannel(), commandName); - } - }); - } - - public void shutdown() { - logger.info("Завершение работы EventDispatcher..."); - executorService.shutdown(); - try { - if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { - logger.warn("ExecutorService не завершился за 10 секунд, принудительная остановка"); - executorService.shutdownNow(); - } - } catch (InterruptedException e) { - logger.error("Прервано ожидание завершения ExecutorService", e); - executorService.shutdownNow(); - Thread.currentThread().interrupt(); - } - logger.info("EventDispatcher завершён"); - } -} +package creator.loki.core; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.embed.EmbedTemplates; +import creator.loki.core.exception.CommandExceptionHandler; +import creator.loki.feature.metrics.MetricsCollector; +import creator.loki.core.security.RateLimiter; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class EventDispatcher { + private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class); + private final BotConfig config; + private final CommandRegistry registry; + private final ExecutorService executorService; + private final RateLimiter rateLimiter; + private final MetricsCollector metrics; + + public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter, MetricsCollector metrics) { + this.config = config; + this.registry = registry; + this.executorService = Executors.newVirtualThreadPerTaskExecutor(); + this.rateLimiter = rateLimiter; + this.metrics = metrics; + } + + public void handleMessage(MessageReceivedEvent event) { + // Игнорируем ботов + if (event.getAuthor().isBot()) { + return; + } + + String content = event.getMessage().getContentRaw(); + if (!content.startsWith(config.getPrefix())) { + return; + } + + // Парсим команду и аргументы + String[] parts = content.substring(config.getPrefix().length()).trim().split("\\s+"); + if (parts.length == 0) { + return; + } + + String commandName = parts[0].toLowerCase(); + List args = parts.length > 1 + ? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length)) + : List.of(); + + Command command = registry.getCommand(commandName); + if (command == null) { + event.getChannel().sendMessageEmbeds( + EmbedTemplates.error( + "Команда не найдена", + "Команда `" + commandName + "` не существует. Используй `" + config.getPrefix() + "help`" + ) + ).queue(); + return; + } + + // Проверяем глобальный rate limit + String userId = event.getAuthor().getId(); + String guildId = event.getGuild() != null ? event.getGuild().getId() : "DM"; + + if (!rateLimiter.allow(userId, guildId)) { + long waitTime = rateLimiter.getUserWaitTime(userId); + event.getChannel().sendMessageEmbeds( + EmbedTemplates.error( + "⏱️ Слишком быстро!", + "Ты отправляешь команды слишком часто. Подожди " + waitTime + " секунд." + ) + ).queue(); + logger.warn("Rate limit для пользователя {} в гильдии {}", userId, guildId); + return; + } + + // Выполняем команду асинхронно + executorService.submit(() -> { + long startTime = System.currentTimeMillis(); + try { + CommandContext context = new CommandContext( + event.getMessage(), + args, + event.getChannel(), + event.getMember(), + event.getGuild() + ); + command.execute(context); + + // Записываем метрики + long executionTime = System.currentTimeMillis() - startTime; + metrics.recordCommand(commandName, executionTime); + + logger.info("Выполнена команда: {} от {} за {} мс", + commandName, event.getAuthor().getName(), executionTime); + } catch (Exception e) { + // Записываем ошибку в метрики + metrics.recordError(commandName); + + // Используем централизованный обработчик ошибок + CommandExceptionHandler.handle(e, event.getChannel(), commandName); + } + }); + } + + public void shutdown() { + logger.info("Завершение работы EventDispatcher..."); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + logger.warn("ExecutorService не завершился за 10 секунд, принудительная остановка"); + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + logger.error("Прервано ожидание завершения ExecutorService", e); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + logger.info("EventDispatcher завершён"); + } +} diff --git a/src/main/java/creator/loki/core/ListenerAggregator.java b/src/main/java/creator/loki/core/ListenerAggregator.java new file mode 100644 index 0000000..f5e9e10 --- /dev/null +++ b/src/main/java/creator/loki/core/ListenerAggregator.java @@ -0,0 +1,54 @@ +package creator.loki.core; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.feature.level.MessageListener; +import creator.loki.feature.moderation.ModerationLogger; +import creator.loki.bootstrap.ReadyListener; +import creator.loki.feature.help.HelpMenuHandler; +import creator.loki.feature.counter.MemberCounterListener; +import creator.loki.feature.leaderboard.LeaderboardButtonListener; +import creator.loki.feature.level.LevelUpNotifier; +import creator.loki.feature.level.ResetLevelsButtonHandler; +import creator.loki.feature.level.VoiceTracker; +import creator.loki.feature.voice.VoiceButtonHandler; +import creator.loki.feature.voice.VoiceChannelListener; +import creator.loki.feature.voice.VoiceModalHandler; +import creator.loki.feature.voice.VoiceSyncListener; +import creator.loki.feature.welcome.WelcomeListener; + +@Singleton +public class ListenerAggregator { + private final Object[] listeners; + + @Inject + public ListenerAggregator( + ReadyListener readyListener, + MessageListener messageListener, + VoiceTracker voiceTracker, + VoiceChannelListener voiceChannelListener, + MemberCounterListener memberCounterListener, + VoiceButtonHandler voiceButtonHandler, + VoiceModalHandler voiceModalHandler, + VoiceSyncListener voiceSyncListener, + WelcomeListener welcomeListener, + ModerationLogger moderationLogger, + HelpMenuHandler helpMenuHandler, + ResetLevelsButtonHandler resetLevelsButtonHandler, + LeaderboardButtonListener leaderboardButtonListener, + MessageCache messageCache + ) { + this.listeners = new Object[]{ + readyListener, messageListener, voiceTracker, + voiceChannelListener, memberCounterListener, + voiceButtonHandler, voiceModalHandler, voiceSyncListener, + welcomeListener, moderationLogger, helpMenuHandler, + resetLevelsButtonHandler, leaderboardButtonListener, + messageCache + }; + } + + public Object[] getAll() { + return listeners; + } +} diff --git a/src/main/java/creator/loki/core/MessageCache.java b/src/main/java/creator/loki/core/MessageCache.java new file mode 100644 index 0000000..3bc9566 --- /dev/null +++ b/src/main/java/creator/loki/core/MessageCache.java @@ -0,0 +1,32 @@ +package creator.loki.core; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + +import java.util.concurrent.TimeUnit; + +public class MessageCache extends ListenerAdapter { + private final Cache cache; + + public MessageCache() { + this.cache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + } + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (event.getAuthor().isBot()) return; + String content = event.getMessage().getContentRaw(); + if (!content.isBlank()) { + cache.put(event.getMessageId(), content); + } + } + + public String getContent(String messageId) { + return cache.getIfPresent(messageId); + } +} diff --git a/src/main/java/creator/loki/core/ShutdownManager.java b/src/main/java/creator/loki/core/ShutdownManager.java new file mode 100644 index 0000000..c6a35ab --- /dev/null +++ b/src/main/java/creator/loki/core/ShutdownManager.java @@ -0,0 +1,85 @@ +package creator.loki.core; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.database.Database; +import creator.loki.feature.counter.MemberCounterListener; +import creator.loki.feature.level.VoiceTracker; +import creator.loki.feature.moderation.MuteScheduler; +import creator.loki.feature.metrics.HealthHttpServer; +import creator.loki.core.security.CooldownManager; +import creator.loki.core.security.RateLimiter; +import net.dv8tion.jda.api.JDA; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; + +@Singleton +public class ShutdownManager { + private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class); + + private final HealthHttpServer healthHttpServer; + private final CooldownManager cooldownManager; + private final RateLimiter rateLimiter; + private final VoiceTracker voiceTracker; + private final MemberCounterListener memberCounterListener; + private final MuteScheduler muteScheduler; + private final EventDispatcher dispatcher; + private final Database database; + + @Inject + public ShutdownManager( + HealthHttpServer healthHttpServer, + CooldownManager cooldownManager, + RateLimiter rateLimiter, + VoiceTracker voiceTracker, + MemberCounterListener memberCounterListener, + MuteScheduler muteScheduler, + EventDispatcher dispatcher, + Database database + ) { + this.healthHttpServer = healthHttpServer; + this.cooldownManager = cooldownManager; + this.rateLimiter = rateLimiter; + this.voiceTracker = voiceTracker; + this.memberCounterListener = memberCounterListener; + this.muteScheduler = muteScheduler; + this.dispatcher = dispatcher; + this.database = database; + } + + public void shutdown(JDA jda) { + logger.info("========================================"); + logger.info("Получен сигнал завершения работы бота..."); + logger.info("========================================"); + + try { + healthHttpServer.stop(); + logger.info("Остановка менеджеров..."); + cooldownManager.shutdown(); + rateLimiter.shutdown(); + voiceTracker.shutdown(); + memberCounterListener.shutdown(); + muteScheduler.shutdown(); + dispatcher.shutdown(); + + logger.info("Закрытие базы данных..."); + database.close(); + + logger.info("Отключение JDA..."); + jda.shutdown(); + + if (!jda.awaitShutdown(Duration.ofSeconds(10))) { + logger.warn("JDA не завершился за 10 секунд, принудительная остановка"); + jda.shutdownNow(); + } + } catch (Exception e) { + logger.error("Ошибка при завершении работы бота", e); + } + + logger.info("========================================"); + logger.info("Бот успешно завершён!"); + logger.info("========================================"); + } +} diff --git a/src/main/java/creator/loki/exception/CommandException.java b/src/main/java/creator/loki/core/exception/CommandException.java similarity index 82% rename from src/main/java/creator/loki/exception/CommandException.java rename to src/main/java/creator/loki/core/exception/CommandException.java index 9b0ed46..a67e632 100644 --- a/src/main/java/creator/loki/exception/CommandException.java +++ b/src/main/java/creator/loki/core/exception/CommandException.java @@ -1,22 +1,23 @@ -package creator.loki.exception; - -/** - * Исключение для ошибок выполнения команд - */ -public class CommandException extends Exception { - private final String userMessage; - - public CommandException(String userMessage) { - super(userMessage); - this.userMessage = userMessage; - } - - public CommandException(String userMessage, Throwable cause) { - super(userMessage, cause); - this.userMessage = userMessage; - } - - public String getUserMessage() { - return userMessage; - } -} +package creator.loki.core.exception; + +/** + * Исключение для ошибок выполнения команд + */ +public class CommandException extends Exception { + private static final long serialVersionUID = 1L; + private final String userMessage; + + public CommandException(String userMessage) { + super(userMessage); + this.userMessage = userMessage; + } + + public CommandException(String userMessage, Throwable cause) { + super(userMessage, cause); + this.userMessage = userMessage; + } + + public String getUserMessage() { + return userMessage; + } +} diff --git a/src/main/java/creator/loki/exception/CommandExceptionHandler.java b/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java similarity index 68% rename from src/main/java/creator/loki/exception/CommandExceptionHandler.java rename to src/main/java/creator/loki/core/exception/CommandExceptionHandler.java index 38cac0c..1ee363c 100644 --- a/src/main/java/creator/loki/exception/CommandExceptionHandler.java +++ b/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java @@ -1,63 +1,63 @@ -package creator.loki.exception; - -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.awt.Color; - -/** - * Обработчик исключений для команд - */ -public class CommandExceptionHandler { - private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class); - - /** - * Обрабатывает исключение и отправляет сообщение пользователю - */ - public static void handle(Throwable throwable, MessageChannel channel, String commandName) { - logger.error("Ошибка при выполнении команды: {}", commandName, throwable); - - String userMessage; - if (throwable instanceof CommandException) { - userMessage = ((CommandException) throwable).getUserMessage(); - } else { - userMessage = "Произошла непредвиденная ошибка при выполнении команды."; - } - - MessageEmbed errorEmbed = EmbedFactory.create() - .setTitle("❌ Ошибка") - .setDescription(userMessage) - .setColor(Color.RED) - .addField("Команда", commandName, true) - .setFooter("Если ошибка повторяется, обратитесь к администратору") - .build(); - - channel.sendMessageEmbeds(errorEmbed).queue( - success -> {}, - error -> logger.error("Не удалось отправить сообщение об ошибке", error) - ); - } - - /** - * Обрабатывает исключение с кастомным сообщением - */ - public static void handle(Throwable throwable, MessageChannel channel, String commandName, String customMessage) { - logger.error("Ошибка при выполнении команды: {}", commandName, throwable); - - MessageEmbed errorEmbed = EmbedFactory.create() - .setTitle("❌ Ошибка") - .setDescription(customMessage) - .setColor(Color.RED) - .addField("Команда", commandName, true) - .setFooter("Если ошибка повторяется, обратитесь к администратору") - .build(); - - channel.sendMessageEmbeds(errorEmbed).queue( - success -> {}, - error -> logger.error("Не удалось отправить сообщение об ошибке", error) - ); - } -} +package creator.loki.core.exception; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; + +/** + * Обработчик исключений для команд + */ +public class CommandExceptionHandler { + private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class); + + /** + * Обрабатывает исключение и отправляет сообщение пользователю + */ + public static void handle(Throwable throwable, MessageChannel channel, String commandName) { + logger.error("Ошибка при выполнении команды: {}", commandName, throwable); + + String userMessage; + if (throwable instanceof CommandException ce) { + userMessage = ce.getUserMessage(); + } else { + userMessage = "Не удалось выполнить команду. Попробуйте позже или обратитесь к администратору."; + } + + MessageEmbed errorEmbed = EmbedFactory.create() + .withTitle("❌ Ошибка") + .withDescription(userMessage) + .withColor(Color.RED) + .addField("Команда", commandName, true) + .withFooter("Если ошибка повторяется, обратитесь к администратору") + .build(); + + channel.sendMessageEmbeds(errorEmbed).queue( + success -> {}, + error -> logger.error("Не удалось отправить сообщение об ошибке", error) + ); + } + + /** + * Обрабатывает исключение с кастомным сообщением + */ + public static void handle(Throwable throwable, MessageChannel channel, String commandName, String customMessage) { + logger.error("Ошибка при выполнении команды: {}", commandName, throwable); + + MessageEmbed errorEmbed = EmbedFactory.create() + .withTitle("❌ Ошибка") + .withDescription(customMessage) + .withColor(Color.RED) + .addField("Команда", commandName, true) + .withFooter("Если ошибка повторяется, обратитесь к администратору") + .build(); + + channel.sendMessageEmbeds(errorEmbed).queue( + success -> {}, + error -> logger.error("Не удалось отправить сообщение об ошибке", error) + ); + } +} diff --git a/src/main/java/creator/loki/core/registry/CommandRegistrar.java b/src/main/java/creator/loki/core/registry/CommandRegistrar.java new file mode 100644 index 0000000..be1ca11 --- /dev/null +++ b/src/main/java/creator/loki/core/registry/CommandRegistrar.java @@ -0,0 +1,6 @@ +package creator.loki.core.registry; + +@FunctionalInterface +public interface CommandRegistrar { + void register(CommandRegistry registry); +} diff --git a/src/main/java/creator/loki/core/registry/CommandRegistry.java b/src/main/java/creator/loki/core/registry/CommandRegistry.java index 86f68ef..444f328 100644 --- a/src/main/java/creator/loki/core/registry/CommandRegistry.java +++ b/src/main/java/creator/loki/core/registry/CommandRegistry.java @@ -1,32 +1,38 @@ -package creator.loki.core.registry; - -import creator.loki.command.Command; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class CommandRegistry { - private static final Logger logger = LoggerFactory.getLogger(CommandRegistry.class); - private final Map commands = new HashMap<>(); - - public void register(Command command) { - commands.put(command.getName().toLowerCase(), command); - logger.debug("Зарегистрирована команда: {}", command.getName()); - } - - public Command getCommand(String name) { - return commands.get(name.toLowerCase()); - } - - public Collection getAllCommands() { - return Collections.unmodifiableCollection(commands.values()); - } - - public int getCommandCount() { - return commands.size(); - } -} +package creator.loki.core.registry; + +import creator.loki.command.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class CommandRegistry { + private static final Logger logger = LoggerFactory.getLogger(CommandRegistry.class); + private final Map commands = new HashMap<>(); + + public void register(Command command) { + String key = command.getName().toLowerCase(); + Command previous = commands.put(key, command); + if (previous != null) { + logger.warn("Имя команды «{}» уже занято: {} заменена на {}", + key, previous.getClass().getSimpleName(), command.getClass().getSimpleName()); + } else { + logger.debug("Зарегистрирована команда: {}", command.getName()); + } + } + + public Command getCommand(String name) { + return commands.get(name.toLowerCase()); + } + + public Collection getAllCommands() { + return Collections.unmodifiableCollection(commands.values()); + } + + public int getCommandCount() { + return commands.size(); + } +} diff --git a/src/main/java/creator/loki/core/registry/PrefixCommandRegistrar.java b/src/main/java/creator/loki/core/registry/PrefixCommandRegistrar.java index aa24d8d..885477d 100644 --- a/src/main/java/creator/loki/core/registry/PrefixCommandRegistrar.java +++ b/src/main/java/creator/loki/core/registry/PrefixCommandRegistrar.java @@ -1,156 +1,29 @@ package creator.loki.core.registry; -import creator.loki.command.impl.entertainment.games.*; -import creator.loki.command.impl.entertainment.games.random.CoinCommand; -import creator.loki.command.impl.entertainment.games.random.DiceCommand; -import creator.loki.command.impl.entertainment.games.random.GlazbobaCommand; -import creator.loki.command.impl.entertainment.games.random.MemeCommand; -import creator.loki.command.impl.entertainment.games.random.RateCommand; -import creator.loki.command.impl.entertainment.games.rating.BoykisserCommand; -import creator.loki.command.impl.entertainment.games.rating.FatCommand; -import creator.loki.command.impl.entertainment.games.rating.GayCommand; -import creator.loki.command.impl.entertainment.games.rating.IqCommand; -import creator.loki.command.impl.entertainment.games.rating.MustacheCommand; -import creator.loki.command.impl.entertainment.games.rating.PpCommand; -import creator.loki.command.impl.entertainment.interaction.*; -import creator.loki.command.impl.entertainment.interaction.negative.PokeCommand; -import creator.loki.command.impl.entertainment.interaction.negative.SlapCommand; -import creator.loki.command.impl.entertainment.interaction.negative.TickleCommand; -import creator.loki.command.impl.entertainment.interaction.positive.CuddleCommand; -import creator.loki.command.impl.entertainment.interaction.positive.FeedCommand; -import creator.loki.command.impl.entertainment.interaction.positive.HugCommand; -import creator.loki.command.impl.entertainment.interaction.positive.KissCommand; -import creator.loki.command.impl.entertainment.interaction.positive.PatCommand; -import creator.loki.command.impl.moderation.*; -import creator.loki.feature.role.*; -import creator.loki.command.impl.stats.*; -import creator.loki.command.impl.utility.*; -import creator.loki.command.impl.utility.info.*; -import creator.loki.command.impl.utility.system.*; -import creator.loki.config.BotConfig; -import creator.loki.data.JsonDataStore; -import creator.loki.feature.level.service.LevelService; -import creator.loki.feature.role.CreateRoleCommand; -import creator.loki.feature.role.DeleteRoleCommand; -import creator.loki.feature.role.EditRoleCommand; -import creator.loki.feature.role.MyRolesCommand; -import creator.loki.level.LevelSystem; -import creator.loki.metrics.MetricsCollector; -import creator.loki.profile.ProfileService; -import creator.loki.feature.role.CustomRoleManager; -import creator.loki.core.security.CooldownManager; +import com.google.inject.Inject; +import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Регистратор префиксных команд (!) - Single Responsibility - */ +import java.util.Set; + +@Singleton public class PrefixCommandRegistrar { private static final Logger logger = LoggerFactory.getLogger(PrefixCommandRegistrar.class); private final CommandRegistry registry; - private final BotConfig config; - private final JsonDataStore dataStore; - private final CustomRoleManager roleManager; - private final CooldownManager cooldownManager; - private final LevelSystem levelSystem; - private final LevelService levelService; - private final creator.loki.feature.profile.ProfileService newProfileService; - private final MetricsCollector metrics; + private final Set registrars; - public PrefixCommandRegistrar( - CommandRegistry registry, - BotConfig config, - JsonDataStore dataStore, - CustomRoleManager roleManager, - CooldownManager cooldownManager, - LevelSystem levelSystem, - LevelService levelService, - creator.loki.feature.profile.ProfileService newProfileService, - MetricsCollector metrics - ) { + @Inject + public PrefixCommandRegistrar(CommandRegistry registry, Set registrars) { this.registry = registry; - this.config = config; - this.dataStore = dataStore; - this.roleManager = roleManager; - this.cooldownManager = cooldownManager; - this.levelSystem = levelSystem; - this.levelService = levelService; - this.newProfileService = newProfileService; - this.metrics = metrics; + this.registrars = registrars; } public void registerAll() { - registerUtilityCommands(); - registerRoleCommands(); - registerInteractionCommands(); - registerGameCommands(); - registerStatsCommands(); - registerModerationCommands(); - + for (CommandRegistrar registrar : registrars) { + registrar.register(registry); + } logger.info("Зарегистрировано префиксных команд: {}", registry.getCommandCount()); } - - private void registerUtilityCommands() { - registry.register(new PingCommand()); - registry.register(new HelpCommand(registry, config)); - registry.register(new InfoCommand()); - registry.register(new ClearCommand()); - registry.register(new ProfileCommand(dataStore)); - registry.register(new creator.loki.feature.profile.ProfileCommand(newProfileService)); - registry.register(new MetricsCommand(metrics)); - registry.register(new AvatarCommand()); - registry.register(new PollCommand()); - registry.register(new SwapCommand()); - } - - private void registerRoleCommands() { - registry.register(new CreateRoleCommand(roleManager)); - registry.register(new DeleteRoleCommand(roleManager)); - registry.register(new MyRolesCommand(roleManager)); - registry.register(new EditRoleCommand(roleManager)); - } - - private void registerInteractionCommands() { - registry.register(new PatCommand(cooldownManager)); - registry.register(new HugCommand()); - registry.register(new KissCommand(cooldownManager)); - registry.register(new SlapCommand(cooldownManager)); - registry.register(new TickleCommand(cooldownManager)); - registry.register(new PokeCommand(cooldownManager)); - registry.register(new CuddleCommand(cooldownManager)); - registry.register(new FeedCommand(cooldownManager)); - } - - private void registerGameCommands() { - registry.register(new EightBallCommand()); - registry.register(new CoinCommand()); - registry.register(new DiceCommand()); - registry.register(new MemeCommand()); - registry.register(new ShipCommand(dataStore)); - registry.register(new RoastCommand()); - registry.register(new ComplimentCommand()); - registry.register(new GlazbobaCommand()); - registry.register(new IqCommand(cooldownManager)); - registry.register(new GayCommand(cooldownManager)); - registry.register(new PpCommand(cooldownManager)); - registry.register(new RateCommand(cooldownManager)); - registry.register(new BoykisserCommand(cooldownManager)); - registry.register(new MustacheCommand(cooldownManager)); - registry.register(new FatCommand(cooldownManager)); - } - - private void registerStatsCommands() { - registry.register(new creator.loki.feature.level.RankCommand(levelService)); - registry.register(new creator.loki.command.impl.stats.leaderboard.NewLeaderboardCommand(levelSystem)); - registry.register(new RepCommand(dataStore, cooldownManager)); - registry.register(new MinusRepCommand(dataStore, cooldownManager)); - registry.register(new RepTopCommand(dataStore)); - registry.register(new creator.loki.feature.level.StatsCommand()); - } - - private void registerModerationCommands() { - registry.register(new BanCommand()); - registry.register(new KickCommand()); - } } diff --git a/src/main/java/creator/loki/core/registry/SlashCommandRegistrar.java b/src/main/java/creator/loki/core/registry/SlashCommandRegistrar.java deleted file mode 100644 index 92f092c..0000000 --- a/src/main/java/creator/loki/core/registry/SlashCommandRegistrar.java +++ /dev/null @@ -1,34 +0,0 @@ -package creator.loki.core.registry; - -import creator.loki.data.JsonDataStore; -import creator.loki.metrics.MetricsCollector; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Регистратор slash команд (/) - Single Responsibility - */ -public class SlashCommandRegistrar { - private static final Logger logger = LoggerFactory.getLogger(SlashCommandRegistrar.class); - - private final SlashCommandRegistry registry; - private final JsonDataStore dataStore; - private final MetricsCollector metrics; - - public SlashCommandRegistrar( - SlashCommandRegistry registry, - JsonDataStore dataStore, - MetricsCollector metrics - ) { - this.registry = registry; - this.dataStore = dataStore; - this.metrics = metrics; - } - - public void registerAll() { - // Все slash команды удалены согласно рефакторингу - // Используются только prefix команды (!) - - logger.info("Зарегистрировано slash команд: {}", registry.getCommandCount()); - } -} diff --git a/src/main/java/creator/loki/core/registry/SlashCommandRegistry.java b/src/main/java/creator/loki/core/registry/SlashCommandRegistry.java deleted file mode 100644 index ee834ac..0000000 --- a/src/main/java/creator/loki/core/registry/SlashCommandRegistry.java +++ /dev/null @@ -1,67 +0,0 @@ -package creator.loki.core.registry; - -import creator.loki.command.SlashCommand; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.interactions.commands.build.CommandData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Реестр для slash команд - */ -public class SlashCommandRegistry { - private static final Logger logger = LoggerFactory.getLogger(SlashCommandRegistry.class); - private final Map commands = new HashMap<>(); - - /** - * Регистрирует slash команду - */ - public void register(SlashCommand command) { - commands.put(command.getName().toLowerCase(), command); - logger.debug("Зарегистрирована slash команда: {}", command.getName()); - } - - /** - * Получает slash команду по имени - */ - public SlashCommand getCommand(String name) { - return commands.get(name.toLowerCase()); - } - - /** - * Возвращает все slash команды - */ - public Collection getAllCommands() { - return Collections.unmodifiableCollection(commands.values()); - } - - /** - * Возвращает количество slash команд - */ - public int getCommandCount() { - return commands.size(); - } - - /** - * Регистрирует все slash команды в Discord - */ - public void registerCommandsInDiscord(JDA jda) { - List commandDataList = new ArrayList<>(); - - for (SlashCommand command : commands.values()) { - commandDataList.add(command.getCommandData()); - } - - jda.updateCommands().addCommands(commandDataList).queue( - success -> logger.info("Зарегистрировано {} slash команд в Discord", commandDataList.size()), - error -> logger.error("Ошибка при регистрации slash команд", error) - ); - } -} diff --git a/src/main/java/creator/loki/core/security/CooldownManager.java b/src/main/java/creator/loki/core/security/CooldownManager.java index cdbbad0..d950fbd 100644 --- a/src/main/java/creator/loki/core/security/CooldownManager.java +++ b/src/main/java/creator/loki/core/security/CooldownManager.java @@ -1,83 +1,83 @@ -package creator.loki.core.security; - -import java.time.Instant; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class CooldownManager { - private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); - private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); - - public CooldownManager() { - // Очистка истёкших cooldown'ов каждые 5 минут - cleanupExecutor.scheduleAtFixedRate(this::cleanup, 5, 5, TimeUnit.MINUTES); - } - - /** - * Проверяет, находится ли пользователь на cooldown для команды - */ - public boolean isOnCooldown(String userId, String commandName) { - String key = userId + ":" + commandName; - Instant cooldownEnd = cooldowns.get(key); - - if (cooldownEnd == null) { - return false; - } - - if (Instant.now().isAfter(cooldownEnd)) { - cooldowns.remove(key); - return false; - } - - return true; - } - - /** - * Возвращает оставшееся время cooldown в секундах - */ - public long getRemainingSeconds(String userId, String commandName) { - String key = userId + ":" + commandName; - Instant cooldownEnd = cooldowns.get(key); - - if (cooldownEnd == null) { - return 0; - } - - long remaining = cooldownEnd.getEpochSecond() - Instant.now().getEpochSecond(); - return Math.max(0, remaining); - } - - /** - * Применяет cooldown для пользователя на команду - */ - public void applyCooldown(String userId, String commandName, int seconds) { - String key = userId + ":" + commandName; - Instant cooldownEnd = Instant.now().plusSeconds(seconds); - cooldowns.put(key, cooldownEnd); - } - - /** - * Очищает истёкшие cooldown'ы - */ - public void cleanup() { - Instant now = Instant.now(); - cooldowns.entrySet().removeIf(entry -> now.isAfter(entry.getValue())); - } - - /** - * Завершает работу менеджера - */ - public void shutdown() { - cleanupExecutor.shutdown(); - try { - if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { - cleanupExecutor.shutdownNow(); - } - } catch (InterruptedException e) { - cleanupExecutor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} +package creator.loki.core.security; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class CooldownManager { + private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); + + @SuppressWarnings("this-escape") + public CooldownManager() { + cleanupExecutor.scheduleAtFixedRate(() -> Thread.startVirtualThread(this::cleanup), 5, 5, TimeUnit.MINUTES); + } + + /** + * Проверяет, находится ли пользователь на cooldown для команды + */ + public boolean isOnCooldown(String userId, String commandName) { + String key = userId + ":" + commandName; + Instant cooldownEnd = cooldowns.get(key); + + if (cooldownEnd == null) { + return false; + } + + if (Instant.now().isAfter(cooldownEnd)) { + cooldowns.remove(key); + return false; + } + + return true; + } + + /** + * Возвращает оставшееся время cooldown в секундах + */ + public long getRemainingSeconds(String userId, String commandName) { + String key = userId + ":" + commandName; + Instant cooldownEnd = cooldowns.get(key); + + if (cooldownEnd == null) { + return 0; + } + + long remaining = cooldownEnd.getEpochSecond() - Instant.now().getEpochSecond(); + return Math.max(0, remaining); + } + + /** + * Применяет cooldown для пользователя на команду + */ + public void applyCooldown(String userId, String commandName, int seconds) { + String key = userId + ":" + commandName; + Instant cooldownEnd = Instant.now().plusSeconds(seconds); + cooldowns.put(key, cooldownEnd); + } + + /** + * Очищает истёкшие cooldown'ы + */ + public void cleanup() { + Instant now = Instant.now(); + cooldowns.entrySet().removeIf(entry -> now.isAfter(entry.getValue())); + } + + /** + * Завершает работу менеджера + */ + public void shutdown() { + cleanupExecutor.shutdown(); + try { + if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/creator/loki/core/security/RateLimiter.java b/src/main/java/creator/loki/core/security/RateLimiter.java index 2af9792..b11adb5 100644 --- a/src/main/java/creator/loki/core/security/RateLimiter.java +++ b/src/main/java/creator/loki/core/security/RateLimiter.java @@ -1,157 +1,163 @@ -package creator.loki.core.security; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * Rate limiter для защиты от спама - * Использует алгоритм Token Bucket - */ -public class RateLimiter { - private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); - - // Лимиты по умолчанию - private static final int DEFAULT_USER_CAPACITY = 10; // Максимум токенов на пользователя - private static final int DEFAULT_USER_REFILL_RATE = 2; // Токенов в секунду - private static final int DEFAULT_GUILD_CAPACITY = 50; // Максимум токенов на гильдию - private static final int DEFAULT_GUILD_REFILL_RATE = 10; // Токенов в секунду - - private final ConcurrentHashMap userBuckets = new ConcurrentHashMap<>(); - private final ConcurrentHashMap guildBuckets = new ConcurrentHashMap<>(); - private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); - - public RateLimiter() { - // Очистка старых bucket'ов каждые 5 минут - cleanupExecutor.scheduleAtFixedRate(this::cleanup, 5, 5, TimeUnit.MINUTES); - logger.info("RateLimiter инициализирован"); - } - - /** - * Проверяет, может ли пользователь выполнить действие - */ - public boolean allowUser(String userId) { - TokenBucket bucket = userBuckets.computeIfAbsent( - userId, - k -> new TokenBucket(DEFAULT_USER_CAPACITY, DEFAULT_USER_REFILL_RATE) - ); - return bucket.tryConsume(); - } - - /** - * Проверяет, может ли гильдия выполнить действие - */ - public boolean allowGuild(String guildId) { - TokenBucket bucket = guildBuckets.computeIfAbsent( - guildId, - k -> new TokenBucket(DEFAULT_GUILD_CAPACITY, DEFAULT_GUILD_REFILL_RATE) - ); - return bucket.tryConsume(); - } - - /** - * Проверяет оба лимита: пользователя и гильдии - */ - public boolean allow(String userId, String guildId) { - return allowUser(userId) && allowGuild(guildId); - } - - /** - * Возвращает оставшееся время до восстановления для пользователя (в секундах) - */ - public long getUserWaitTime(String userId) { - TokenBucket bucket = userBuckets.get(userId); - if (bucket == null) { - return 0; - } - return bucket.getWaitTime(); - } - - /** - * Очищает неактивные bucket'ы - */ - private void cleanup() { - long now = System.currentTimeMillis(); - long threshold = now - TimeUnit.MINUTES.toMillis(10); - - userBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); - guildBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); - - logger.debug("Очистка rate limiter: осталось {} user buckets, {} guild buckets", - userBuckets.size(), guildBuckets.size()); - } - - /** - * Завершает работу rate limiter - */ - public void shutdown() { - logger.info("Завершение работы RateLimiter..."); - cleanupExecutor.shutdown(); - try { - if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { - cleanupExecutor.shutdownNow(); - } - } catch (InterruptedException e) { - cleanupExecutor.shutdownNow(); - Thread.currentThread().interrupt(); - } - logger.info("RateLimiter завершён"); - } - - /** - * Token Bucket для rate limiting - */ - private static class TokenBucket { - private final int capacity; - private final double refillRate; // токенов в секунду - private double tokens; - private long lastRefillTime; - private long lastAccessTime; - - public TokenBucket(int capacity, double refillRate) { - this.capacity = capacity; - this.refillRate = refillRate; - this.tokens = capacity; - this.lastRefillTime = System.currentTimeMillis(); - this.lastAccessTime = System.currentTimeMillis(); - } - - public synchronized boolean tryConsume() { - refill(); - lastAccessTime = System.currentTimeMillis(); - - if (tokens >= 1.0) { - tokens -= 1.0; - return true; - } - return false; - } - - private void refill() { - long now = System.currentTimeMillis(); - long timePassed = now - lastRefillTime; - double tokensToAdd = (timePassed / 1000.0) * refillRate; - - tokens = Math.min(capacity, tokens + tokensToAdd); - lastRefillTime = now; - } - - public synchronized long getWaitTime() { - refill(); - if (tokens >= 1.0) { - return 0; - } - double tokensNeeded = 1.0 - tokens; - return (long) Math.ceil(tokensNeeded / refillRate); - } - - public long getLastAccessTime() { - return lastAccessTime; - } - } -} +package creator.loki.core.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Rate limiter для защиты от спама + * Использует алгоритм Token Bucket + */ +public class RateLimiter { + private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); + + // Лимиты по умолчанию + private static final int DEFAULT_USER_CAPACITY = 10; // Максимум токенов на пользователя + private static final int DEFAULT_USER_REFILL_RATE = 2; // Токенов в секунду + private static final int DEFAULT_GUILD_CAPACITY = 50; // Максимум токенов на гильдию + private static final int DEFAULT_GUILD_REFILL_RATE = 10; // Токенов в секунду + + private final ConcurrentHashMap userBuckets = new ConcurrentHashMap<>(); + private final ConcurrentHashMap guildBuckets = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); + + public RateLimiter() { + // Очистка старых bucket'ов каждые 5 минут + cleanupExecutor.scheduleAtFixedRate(() -> Thread.startVirtualThread(this::cleanup), 5, 5, TimeUnit.MINUTES); + logger.info("RateLimiter инициализирован"); + } + + /** + * Проверяет, может ли пользователь выполнить действие + */ + public boolean allowUser(String userId) { + TokenBucket bucket = userBuckets.computeIfAbsent( + userId, + k -> new TokenBucket(DEFAULT_USER_CAPACITY, DEFAULT_USER_REFILL_RATE) + ); + return bucket.tryConsume(); + } + + /** + * Проверяет, может ли гильдия выполнить действие + */ + public boolean allowGuild(String guildId) { + TokenBucket bucket = guildBuckets.computeIfAbsent( + guildId, + k -> new TokenBucket(DEFAULT_GUILD_CAPACITY, DEFAULT_GUILD_REFILL_RATE) + ); + return bucket.tryConsume(); + } + + /** + * Проверяет оба лимита: пользователя и гильдии + */ + public boolean allow(String userId, String guildId) { + return allowUser(userId) && allowGuild(guildId); + } + + /** + * Возвращает оставшееся время до восстановления для пользователя (в секундах) + */ + public long getUserWaitTime(String userId) { + TokenBucket bucket = userBuckets.get(userId); + if (bucket == null) { + return 0; + } + return bucket.getWaitTime(); + } + + /** + * Очищает неактивные bucket'ы + */ + private void cleanup() { + long now = System.currentTimeMillis(); + long threshold = now - TimeUnit.MINUTES.toMillis(10); + + int usersBefore = userBuckets.size(); + int guildsBefore = guildBuckets.size(); + userBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); + guildBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); + + int removedUsers = usersBefore - userBuckets.size(); + int removedGuilds = guildsBefore - guildBuckets.size(); + if (removedUsers > 0 || removedGuilds > 0) { + logger.debug("Очистка rate limiter: удалено {} user / {} guild buckets, осталось {} / {}", + removedUsers, removedGuilds, userBuckets.size(), guildBuckets.size()); + } + } + + /** + * Завершает работу rate limiter + */ + public void shutdown() { + logger.info("Завершение работы RateLimiter..."); + cleanupExecutor.shutdown(); + try { + if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + logger.info("RateLimiter завершён"); + } + + /** + * Token Bucket для rate limiting + */ + private static class TokenBucket { + private final int capacity; + private final double refillRate; // токенов в секунду + private double tokens; + private long lastRefillTime; + private long lastAccessTime; + + public TokenBucket(int capacity, double refillRate) { + this.capacity = capacity; + this.refillRate = refillRate; + this.tokens = capacity; + this.lastRefillTime = System.currentTimeMillis(); + this.lastAccessTime = System.currentTimeMillis(); + } + + public synchronized boolean tryConsume() { + refill(); + lastAccessTime = System.currentTimeMillis(); + + if (tokens >= 1.0) { + tokens -= 1.0; + return true; + } + return false; + } + + private void refill() { + long now = System.currentTimeMillis(); + long timePassed = now - lastRefillTime; + double tokensToAdd = (timePassed / 1000.0) * refillRate; + + tokens = Math.min(capacity, tokens + tokensToAdd); + lastRefillTime = now; + } + + public synchronized long getWaitTime() { + refill(); + if (tokens >= 1.0) { + return 0; + } + double tokensNeeded = 1.0 - tokens; + return (long) Math.ceil(tokensNeeded / refillRate); + } + + public long getLastAccessTime() { + return lastAccessTime; + } + } +} diff --git a/src/main/java/creator/loki/database/Database.java b/src/main/java/creator/loki/database/Database.java index c9ba0fc..f376f26 100644 --- a/src/main/java/creator/loki/database/Database.java +++ b/src/main/java/creator/loki/database/Database.java @@ -1,62 +1,55 @@ package creator.loki.database; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.sql.*; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; -/** - * Менеджер SQLite базы данных - */ public class Database { private static final Logger logger = LoggerFactory.getLogger(Database.class); private final String dbPath; - private Connection connection; + private HikariDataSource dataSource; public Database(String dbPath) { this.dbPath = dbPath; } - /** - * Подключиться к базе данных - */ public void connect() { - try { - String url = "jdbc:sqlite:" + dbPath; - connection = DriverManager.getConnection(url); - logger.info("Подключение к базе данных: {}", dbPath); - - // Включаем foreign keys - try (Statement stmt = connection.createStatement()) { - stmt.execute("PRAGMA foreign_keys = ON"); - } + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:sqlite:" + dbPath); + config.setMaximumPoolSize(5); + config.setConnectionTimeout(10000); + config.setIdleTimeout(300000); + config.setMaxLifetime(600000); + config.setPoolName("LokiDB"); + + dataSource = new HikariDataSource(config); + logger.info("Подключение к базе данных: {} (pool size: {})", dbPath, config.getMaximumPoolSize()); + + try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) { + stmt.execute("PRAGMA foreign_keys = ON"); } catch (SQLException e) { - logger.error("Ошибка подключения к базе данных", e); + logger.error("Ошибка включения foreign keys", e); + } + } + + public Connection getConnection() { + try { + return dataSource.getConnection(); + } catch (SQLException e) { + logger.error("Ошибка получения соединения из пула", e); throw new RuntimeException(e); } } - /** - * Получить соединение - */ - public Connection getConnection() { - try { - if (connection == null || connection.isClosed()) { - connect(); - } - return connection; - } catch (SQLException e) { - logger.error("Ошибка проверки соединения", e); - connect(); - return connection; - } - } - - /** - * Выполнить SQL запрос - */ public void execute(String sql) { - try (Statement stmt = getConnection().createStatement()) { + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(sql); } catch (SQLException e) { logger.error("Ошибка выполнения SQL: {}", sql, e); @@ -64,29 +57,34 @@ public class Database { } } - /** - * Выполнить SQL запрос с параметрами - */ public PreparedStatement prepare(String sql) { try { - return getConnection().prepareStatement(sql); + Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql); + return (PreparedStatement) Proxy.newProxyInstance( + PreparedStatement.class.getClassLoader(), + new Class[]{PreparedStatement.class}, + (proxy, method, args) -> { + if (method.getName().equals("close")) { + try { + return method.invoke(stmt, args); + } finally { + conn.close(); + } + } + return method.invoke(stmt, args); + } + ); } catch (SQLException e) { logger.error("Ошибка подготовки SQL: {}", sql, e); throw new RuntimeException(e); } } - /** - * Закрыть соединение - */ public void close() { - try { - if (connection != null && !connection.isClosed()) { - connection.close(); - logger.info("Соединение с базой данных закрыто"); - } - } catch (SQLException e) { - logger.error("Ошибка закрытия соединения", e); + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + logger.info("Пул соединений закрыт"); } } } diff --git a/src/main/java/creator/loki/database/DatabaseMigration.java b/src/main/java/creator/loki/database/DatabaseMigration.java index cd25a85..bb2191f 100644 --- a/src/main/java/creator/loki/database/DatabaseMigration.java +++ b/src/main/java/creator/loki/database/DatabaseMigration.java @@ -1,191 +1,190 @@ -package creator.loki.database; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.FileReader; -import java.lang.reflect.Type; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Map; - -/** - * Миграция данных из JSON в SQLite - */ -public class DatabaseMigration { - private static final Logger logger = LoggerFactory.getLogger(DatabaseMigration.class); - private final Database database; - private final Gson gson = new Gson(); - - public DatabaseMigration(Database database) { - this.database = database; - } - - /** - * Мигрировать данные из JSON - */ - public void migrateFromJson(String jsonPath) { - logger.info("Начало миграции данных из JSON: {}", jsonPath); - - try (FileReader reader = new FileReader(jsonPath)) { - Type type = new TypeToken>(){}.getType(); - Map data = gson.fromJson(reader, type); - - if (data == null) { - logger.warn("JSON файл пустой или не существует"); - return; - } - - if (isMigrated()) { - logger.info("Данные уже мигрированы, пропускаем"); - return; - } - - migrateUserStats(data); - migrateReputation(data); - migrateShipPairs(data); - migrateCustomRoles(data); - markMigrated(); - - logger.info("Миграция завершена успешно"); - - } catch (Exception e) { - logger.error("Ошибка миграции данных", e); - } - } - - private boolean isMigrated() { - try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = 'migrated'")) { - ResultSet rs = stmt.executeQuery(); - return rs.next() && "true".equals(rs.getString("value")); - } catch (SQLException e) { - return false; - } - } - - private void markMigrated() { - try (PreparedStatement stmt = database.prepare( - "INSERT OR REPLACE INTO config (key, value) VALUES ('migrated', 'true')" - )) { - stmt.executeUpdate(); - } catch (SQLException e) { - logger.error("Ошибка сохранения статуса миграции", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateUserStats(Map data) { - Map userStats = (Map) data.get("userStats"); - if (userStats == null) return; - - logger.info("Миграция userStats: {} записей", userStats.size()); - - String sql = """ - INSERT OR REPLACE INTO user_stats - (user_id, level, total_xp, message_count, voice_minutes, last_message_time, last_voice_join) - VALUES (?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = database.prepare(sql)) { - for (Map.Entry entry : userStats.entrySet()) { - Map stats = (Map) entry.getValue(); - - stmt.setString(1, (String) stats.get("userId")); - stmt.setInt(2, ((Number) stats.get("level")).intValue()); - stmt.setInt(3, ((Number) stats.get("totalXP")).intValue()); - stmt.setInt(4, ((Number) stats.get("messageCount")).intValue()); - stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue()); - stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue()); - stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue()); - - stmt.executeUpdate(); - } - logger.info("userStats мигрированы"); - } catch (SQLException e) { - logger.error("Ошибка миграции userStats", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateReputation(Map data) { - Map reputation = (Map) data.get("reputation"); - if (reputation == null) return; - - logger.info("Миграция reputation: {} записей", reputation.size()); - - try (PreparedStatement stmt = database.prepare( - "INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)" - )) { - for (Map.Entry entry : reputation.entrySet()) { - stmt.setString(1, entry.getKey()); - stmt.setDouble(2, ((Number) entry.getValue()).doubleValue()); - stmt.executeUpdate(); - } - logger.info("reputation мигрирована"); - } catch (SQLException e) { - logger.error("Ошибка миграции reputation", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateShipPairs(Map data) { - Map shipPairs = (Map) data.get("shipPairs"); - if (shipPairs == null) return; - - logger.info("Миграция shipPairs: {} записей", shipPairs.size()); - - try (PreparedStatement stmt = database.prepare( - "INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)" - )) { - for (Map.Entry entry : shipPairs.entrySet()) { - String[] users = entry.getKey().split("-"); - if (users.length == 2) { - stmt.setString(1, users[0]); - stmt.setString(2, users[1]); - stmt.setDouble(3, ((Number) entry.getValue()).doubleValue()); - stmt.executeUpdate(); - } - } - logger.info("shipPairs мигрированы"); - } catch (SQLException e) { - logger.error("Ошибка миграции shipPairs", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateCustomRoles(Map data) { - Map customRoles = (Map) data.get("custom_roles"); - if (customRoles == null) return; - - logger.info("Миграция custom_roles"); - - try (PreparedStatement stmt = database.prepare( - "INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)" - )) { - for (Map.Entry guildEntry : customRoles.entrySet()) { - String guildId = guildEntry.getKey(); - Map users = (Map) guildEntry.getValue(); - - for (Map.Entry userEntry : users.entrySet()) { - String userId = userEntry.getKey(); - Object rolesObj = userEntry.getValue(); - - if (rolesObj instanceof java.util.List) { - for (Object roleId : (java.util.List) rolesObj) { - stmt.setString(1, guildId); - stmt.setString(2, userId); - stmt.setString(3, roleId.toString()); - stmt.executeUpdate(); - } - } - } - } - logger.info("custom_roles мигрированы"); - } catch (SQLException e) { - logger.error("Ошибка миграции custom_roles", e); - } - } -} +package creator.loki.database; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileReader; +import java.lang.reflect.Type; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; + +/** + * Миграция данных из JSON в SQLite + */ +public class DatabaseMigration { + private static final Logger logger = LoggerFactory.getLogger(DatabaseMigration.class); + private final Database database; + private final Gson gson = new Gson(); + + public DatabaseMigration(Database database) { + this.database = database; + } + + /** + * Мигрировать данные из JSON + */ + public void migrateFromJson(String jsonPath) { + logger.info("Начало миграции данных из JSON: {}", jsonPath); + + try (FileReader reader = new FileReader(jsonPath)) { + Type type = new TypeToken>(){}.getType(); + Map data = gson.fromJson(reader, type); + + if (data == null) { + logger.warn("JSON файл пустой или не существует"); + return; + } + + if (isMigrated()) { + logger.info("Данные уже мигрированы, пропускаем"); + return; + } + + migrateUserStats(data); + migrateReputation(data); + migrateShipPairs(data); + migrateCustomRoles(data); + markMigrated(); + + logger.info("Миграция завершена успешно"); + + } catch (Exception e) { + logger.error("Ошибка миграции данных", e); + } + } + + private boolean isMigrated() { + try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = 'migrated'")) { + ResultSet rs = stmt.executeQuery(); + return rs.next() && "true".equals(rs.getString("value")); + } catch (SQLException e) { + return false; + } + } + + private void markMigrated() { + try (PreparedStatement stmt = database.prepare( + "INSERT OR REPLACE INTO config (key, value) VALUES ('migrated', 'true')" + )) { + stmt.executeUpdate(); + } catch (SQLException e) { + logger.error("Ошибка сохранения статуса миграции", e); + } + } + + @SuppressWarnings("unchecked") + private void migrateUserStats(Map data) { + Map userStats = (Map) data.get("userStats"); + if (userStats == null) return; + + logger.info("Миграция userStats: {} записей", userStats.size()); + + String sql = """ + INSERT OR REPLACE INTO user_stats + (user_id, level, total_xp, message_count, voice_minutes, last_message_time, last_voice_join) + VALUES (?, ?, ?, ?, ?, ?, ?)"""; + + try (PreparedStatement stmt = database.prepare(sql)) { + for (Map.Entry entry : userStats.entrySet()) { + Map stats = (Map) entry.getValue(); + + stmt.setString(1, (String) stats.get("userId")); + stmt.setInt(2, ((Number) stats.get("level")).intValue()); + stmt.setInt(3, ((Number) stats.get("totalXP")).intValue()); + stmt.setInt(4, ((Number) stats.get("messageCount")).intValue()); + stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue()); + stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue()); + stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue()); + + stmt.executeUpdate(); + } + logger.info("userStats мигрированы"); + } catch (SQLException e) { + logger.error("Ошибка миграции userStats", e); + } + } + + @SuppressWarnings("unchecked") + private void migrateReputation(Map data) { + Map reputation = (Map) data.get("reputation"); + if (reputation == null) return; + + logger.info("Миграция reputation: {} записей", reputation.size()); + + try (PreparedStatement stmt = database.prepare( + "INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)" + )) { + for (Map.Entry entry : reputation.entrySet()) { + stmt.setString(1, entry.getKey()); + stmt.setDouble(2, ((Number) entry.getValue()).doubleValue()); + stmt.executeUpdate(); + } + logger.info("reputation мигрирована"); + } catch (SQLException e) { + logger.error("Ошибка миграции reputation", e); + } + } + + @SuppressWarnings("unchecked") + private void migrateShipPairs(Map data) { + Map shipPairs = (Map) data.get("shipPairs"); + if (shipPairs == null) return; + + logger.info("Миграция shipPairs: {} записей", shipPairs.size()); + + try (PreparedStatement stmt = database.prepare( + "INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)" + )) { + for (Map.Entry entry : shipPairs.entrySet()) { + String[] users = entry.getKey().split("-"); + if (users.length == 2) { + stmt.setString(1, users[0]); + stmt.setString(2, users[1]); + stmt.setDouble(3, ((Number) entry.getValue()).doubleValue()); + stmt.executeUpdate(); + } + } + logger.info("shipPairs мигрированы"); + } catch (SQLException e) { + logger.error("Ошибка миграции shipPairs", e); + } + } + + @SuppressWarnings("unchecked") + private void migrateCustomRoles(Map data) { + Map customRoles = (Map) data.get("custom_roles"); + if (customRoles == null) return; + + logger.info("Миграция custom_roles"); + + try (PreparedStatement stmt = database.prepare( + "INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)" + )) { + for (Map.Entry guildEntry : customRoles.entrySet()) { + String guildId = guildEntry.getKey(); + Map users = (Map) guildEntry.getValue(); + + for (Map.Entry userEntry : users.entrySet()) { + String userId = userEntry.getKey(); + Object rolesObj = userEntry.getValue(); + + if (rolesObj instanceof java.util.List rolesList) { + for (Object roleId : rolesList) { + stmt.setString(1, guildId); + stmt.setString(2, userId); + stmt.setString(3, roleId.toString()); + stmt.executeUpdate(); + } + } + } + } + logger.info("custom_roles мигрированы"); + } catch (SQLException e) { + logger.error("Ошибка миграции custom_roles", e); + } + } +} diff --git a/src/main/java/creator/loki/database/DatabaseSchema.java b/src/main/java/creator/loki/database/DatabaseSchema.java index 15c6ace..dea2989 100644 --- a/src/main/java/creator/loki/database/DatabaseSchema.java +++ b/src/main/java/creator/loki/database/DatabaseSchema.java @@ -1,112 +1,249 @@ -package creator.loki.database; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Схема базы данных - создание таблиц - */ -public class DatabaseSchema { - private static final Logger logger = LoggerFactory.getLogger(DatabaseSchema.class); - private final Database database; - - public DatabaseSchema(Database database) { - this.database = database; - } - - /** - * Создать все таблицы - */ - public void createTables() { - logger.info("Создание таблиц базы данных..."); - - createConfigTable(); - createUserStatsTable(); - createReputationTable(); - createShipPairsTable(); - createCustomRolesTable(); - createVoiceChannelsTable(); - createCounterStatsTable(); - - logger.info("Таблицы созданы успешно"); - } - - private void createConfigTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """); - } - - private void createUserStatsTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS user_stats ( - user_id TEXT PRIMARY KEY, - level INTEGER DEFAULT 0, - total_xp INTEGER DEFAULT 0, - message_count INTEGER DEFAULT 0, - voice_minutes INTEGER DEFAULT 0, - last_message_time INTEGER DEFAULT 0, - last_voice_join INTEGER DEFAULT 0 - ) - """); - } - - private void createReputationTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS reputation ( - user_id TEXT PRIMARY KEY, - reputation REAL DEFAULT 0.0 - ) - """); - } - - private void createShipPairsTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS ship_pairs ( - user1_id TEXT NOT NULL, - user2_id TEXT NOT NULL, - compatibility REAL NOT NULL, - PRIMARY KEY (user1_id, user2_id) - ) - """); - } - - private void createCustomRolesTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS custom_roles ( - guild_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role_id TEXT NOT NULL, - PRIMARY KEY (guild_id, user_id, role_id) - ) - """); - } - - private void createVoiceChannelsTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS voice_channels ( - channel_id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - name TEXT, - user_limit INTEGER DEFAULT 0, - bitrate INTEGER DEFAULT 64000, - locked INTEGER DEFAULT 0 - ) - """); - } - - private void createCounterStatsTable() { - database.execute(""" - CREATE TABLE IF NOT EXISTS counter_stats ( - date TEXT PRIMARY KEY, - joined INTEGER DEFAULT 0, - left_count INTEGER DEFAULT 0, - difference INTEGER DEFAULT 0 - ) - """); - } -} +package creator.loki.database; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Схема базы данных - создание таблиц + */ +public class DatabaseSchema { + private static final Logger logger = LoggerFactory.getLogger(DatabaseSchema.class); + private final Database database; + + public DatabaseSchema(Database database) { + this.database = database; + } + + /** + * Создать все таблицы + */ + public void createTables() { + logger.info("Создание таблиц базы данных..."); + + createConfigTable(); + createUserStatsTable(); + createReputationTable(); + createShipPairsTable(); + createCustomRolesTable(); + createVoiceChannelsTable(); + createVoicePresetsTable(); + createCounterStatsTable(); + createRatingsTable(); + createWarningsTable(); + createModerationCasesTable(); + createActiveMutesTable(); + createEconomyTable(); + + createIndexes(); + + logger.info("Таблицы созданы успешно"); + } + + private void createConfigTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """); + } + + private void createUserStatsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS user_stats ( + user_id TEXT PRIMARY KEY, + level INTEGER DEFAULT 0, + total_xp INTEGER DEFAULT 0, + message_count INTEGER DEFAULT 0, + voice_minutes INTEGER DEFAULT 0, + last_message_time INTEGER DEFAULT 0, + last_voice_join INTEGER DEFAULT 0 + ) + """); + } + + private void createReputationTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS reputation ( + user_id TEXT PRIMARY KEY, + reputation REAL DEFAULT 0.0 + ) + """); + } + + private void createShipPairsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS ship_pairs ( + user1_id TEXT NOT NULL, + user2_id TEXT NOT NULL, + compatibility REAL NOT NULL, + PRIMARY KEY (user1_id, user2_id) + ) + """); + } + + private void createCustomRolesTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS custom_roles ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY (guild_id, user_id, role_id) + ) + """); + } + + private void createVoiceChannelsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS voice_channels ( + channel_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + name TEXT, + user_limit INTEGER DEFAULT 0, + bitrate INTEGER DEFAULT 64000, + locked INTEGER DEFAULT 0, + text_channel_id TEXT + ) + """); + } + + private void createVoicePresetsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS voice_presets ( + user_id TEXT PRIMARY KEY, + name TEXT, + user_limit INTEGER DEFAULT 0, + bitrate INTEGER DEFAULT 64000, + region TEXT DEFAULT 'auto', + locked INTEGER DEFAULT 0, + allowed_role_id TEXT, + denied_role_id TEXT + ) + """); + } + + private void createCounterStatsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS counter_stats ( + date TEXT PRIMARY KEY, + joined INTEGER DEFAULT 0, + left_count INTEGER DEFAULT 0, + difference INTEGER DEFAULT 0 + ) + """); + } + + private void createRatingsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS ratings ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + rating_type TEXT NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, user_id, rating_type) + ) + """); + } + + private void createWarningsTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS warnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + moderator_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """); + } + + private void createModerationCasesTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS moderation_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + moderator_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL, + duration_sec INTEGER, + until_ms INTEGER + ) + """); + } + + private void createActiveMutesTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS active_mutes ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + ends_at_ms INTEGER NOT NULL, + case_id INTEGER NOT NULL, + PRIMARY KEY (guild_id, user_id) + ) + """); + } + + private void createEconomyTable() { + database.execute(""" + CREATE TABLE IF NOT EXISTS economy ( + user_id TEXT PRIMARY KEY, + cash INTEGER NOT NULL DEFAULT 0, + bank INTEGER NOT NULL DEFAULT 0, + total_earned INTEGER NOT NULL DEFAULT 0, + total_spent INTEGER NOT NULL DEFAULT 0, + last_daily TEXT, + daily_streak INTEGER DEFAULT 0, + fishing_luck INTEGER DEFAULT 0, + efficiency INTEGER DEFAULT 0, + last_fish TEXT, + job TEXT, + last_work TEXT + ) + """); + } + + /** + * Создать индексы для ускорения запросов + */ + private void createIndexes() { + logger.info("Создание индексов..."); + + // Индексы для user_stats + database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_level ON user_stats(level DESC)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_total_xp ON user_stats(total_xp DESC)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_message_count ON user_stats(message_count DESC)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_voice_minutes ON user_stats(voice_minutes DESC)"); + + // Индексы для reputation + database.execute("CREATE INDEX IF NOT EXISTS idx_reputation_value ON reputation(reputation DESC)"); + + // Индексы для ratings + database.execute("CREATE INDEX IF NOT EXISTS idx_ratings_guild_type ON ratings(guild_id, rating_type)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_ratings_value ON ratings(value DESC)"); + + // Индексы для voice_channels + database.execute("CREATE INDEX IF NOT EXISTS idx_voice_channels_owner ON voice_channels(owner_id)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_voice_channels_created ON voice_channels(created_at)"); + + // Индексы для custom_roles + database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_guild ON custom_roles(guild_id)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_user ON custom_roles(user_id)"); + + // Индексы для counter_stats + database.execute("CREATE INDEX IF NOT EXISTS idx_counter_stats_date ON counter_stats(date DESC)"); + + database.execute("CREATE INDEX IF NOT EXISTS idx_warnings_guild_user ON warnings(guild_id, user_id)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_warnings_guild_time ON warnings(guild_id, created_at DESC)"); + + database.execute("CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_id ON moderation_cases(guild_id, id DESC)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_active_mutes_ends ON active_mutes(ends_at_ms)"); + + database.execute("CREATE INDEX IF NOT EXISTS idx_economy_balance ON economy((cash + bank) DESC)"); + + logger.info("Индексы созданы успешно"); + } +} diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java index 2d82785..4e0e281 100644 --- a/src/main/java/creator/loki/di/BotModule.java +++ b/src/main/java/creator/loki/di/BotModule.java @@ -3,46 +3,78 @@ package creator.loki.di; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; import creator.loki.config.BotConfig; import creator.loki.config.ConfigLoader; import creator.loki.core.EventDispatcher; +import creator.loki.core.ListenerAggregator; +import creator.loki.core.MessageCache; +import creator.loki.core.ShutdownManager; +import creator.loki.core.registry.CommandRegistrar; import creator.loki.core.registry.CommandRegistry; -import creator.loki.core.registry.SlashCommandRegistry; +import creator.loki.core.registry.PrefixCommandRegistrar; import creator.loki.core.security.CooldownManager; import creator.loki.core.security.RateLimiter; -import creator.loki.feature.counter.CounterManager; -import creator.loki.data.JsonDataStore; -import creator.loki.database.ConfigRepository; import creator.loki.database.Database; import creator.loki.database.DatabaseMigration; import creator.loki.database.DatabaseSchema; -import creator.loki.feature.level.domain.XpFormula; -import creator.loki.feature.level.LevelUpNotifier; -import creator.loki.feature.level.service.LeaderboardService; -import creator.loki.feature.level.service.LevelService; -import creator.loki.feature.level.service.MessageCooldownService; -import creator.loki.feature.level.VoiceTracker; -import creator.loki.level.LevelSystem; -import creator.loki.metrics.MetricsCollector; -import creator.loki.profile.ProfileService; -import creator.loki.repository.UserStatsRepository; -import creator.loki.repository.impl.JsonUserStatsRepository; -import creator.loki.feature.role.CustomRoleManager; -import creator.loki.feature.voice.VoiceChannelManager; +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.feature.config.repository.PrefixedGuildConfigRepository; +import creator.loki.feature.rating.repository.RatingsRepository; +import creator.loki.feature.reputation.repository.ReputationRepository; +import creator.loki.feature.ship.repository.ShipPairRepository; +import creator.loki.feature.counter.CounterModule; +import creator.loki.feature.entertainment.EntertainmentCommandRegistrar; +import creator.loki.feature.level.LevelCommandRegistrar; +import creator.loki.feature.level.LevelModule; +import creator.loki.feature.metrics.MetricsCollector; +import creator.loki.feature.metrics.HealthHttpServer; +import creator.loki.feature.metrics.MetricsModule; +import creator.loki.feature.moderation.ModerationCommandRegistrar; +import creator.loki.feature.moderation.ModerationModule; +import creator.loki.feature.profile.ProfileModule; +import creator.loki.feature.role.RoleModule; +import creator.loki.feature.utility.UtilityCommandRegistrar; +import creator.loki.feature.music.MusicModule; +import creator.loki.feature.music.MusicCommandRegistrar; +import creator.loki.feature.voice.VoiceModule; +import creator.loki.feature.welcome.WelcomeModule; +import creator.loki.feature.config.repository.GuildConfigRepository; +import creator.loki.feature.economy.EconomyCommandRegistrar; +import creator.loki.feature.economy.EconomyModule; -/** - * Главный модуль Guice для конфигурации Dependency Injection - */ public class BotModule extends AbstractModule { @Override protected void configure() { - // Все основные компоненты - Singleton bind(CommandRegistry.class).in(Singleton.class); - bind(SlashCommandRegistry.class).in(Singleton.class); bind(CooldownManager.class).in(Singleton.class); bind(RateLimiter.class).in(Singleton.class); bind(MetricsCollector.class).in(Singleton.class); + bind(HealthHttpServer.class).in(Singleton.class); + bind(ListenerAggregator.class).in(Singleton.class); + bind(MessageCache.class).in(Singleton.class); + bind(ShutdownManager.class).in(Singleton.class); + bind(PrefixCommandRegistrar.class).in(Singleton.class); + + install(new VoiceModule()); + install(new MusicModule()); + install(new LevelModule()); + install(new ModerationModule()); + install(new RoleModule()); + install(new CounterModule()); + install(new ProfileModule()); + install(new MetricsModule()); + install(new WelcomeModule()); + install(new EconomyModule()); + + Multibinder commandBinder = Multibinder.newSetBinder(binder(), CommandRegistrar.class); + commandBinder.addBinding().to(LevelCommandRegistrar.class); + commandBinder.addBinding().to(ModerationCommandRegistrar.class); + commandBinder.addBinding().to(EntertainmentCommandRegistrar.class); + commandBinder.addBinding().to(UtilityCommandRegistrar.class); + commandBinder.addBinding().to(MusicCommandRegistrar.class); + commandBinder.addBinding().to(EconomyCommandRegistrar.class); } @Provides @@ -51,38 +83,6 @@ public class BotModule extends AbstractModule { return ConfigLoader.load(); } - @Provides - @Singleton - JsonDataStore provideJsonDataStore() { - JsonDataStore dataStore = new JsonDataStore("data/bot_data.json"); - dataStore.load(); - return dataStore; - } - - @Provides - @Singleton - CustomRoleManager provideCustomRoleManager(JsonDataStore dataStore) { - return new CustomRoleManager(dataStore); - } - - @Provides - @Singleton - LevelSystem provideLevelSystem(JsonDataStore dataStore) { - return new LevelSystem(dataStore); - } - - @Provides - @Singleton - VoiceTracker provideVoiceTracker(LevelService levelService) { - return new VoiceTracker(levelService); - } - - @Provides - @Singleton - ProfileService provideProfileService(JsonDataStore dataStore) { - return new ProfileService(dataStore); - } - @Provides @Singleton EventDispatcher provideEventDispatcher( @@ -96,18 +96,13 @@ public class BotModule extends AbstractModule { @Provides @Singleton - Database provideDatabase() { - Database database = new Database("data/bot.db"); + Database provideDatabase(BotConfig config) { + Database database = new Database(config.getDbPath()); database.connect(); - - // Создаем таблицы DatabaseSchema schema = new DatabaseSchema(database); schema.createTables(); - - // Мигрируем данные из JSON если нужно DatabaseMigration migration = new DatabaseMigration(database); migration.migrateFromJson("data/bot_data.json"); - return database; } @@ -119,69 +114,25 @@ public class BotModule extends AbstractModule { @Provides @Singleton - LevelUpNotifier provideLevelUpNotifier(ConfigRepository configRepo, LevelService levelService) { - return new LevelUpNotifier(configRepo, levelService); + GuildConfigRepository provideGuildConfigRepository(ConfigRepository configRepository) { + return new PrefixedGuildConfigRepository(configRepository); } @Provides @Singleton - VoiceChannelManager provideVoiceChannelManager(JsonDataStore dataStore, ConfigRepository configRepo) { - return new VoiceChannelManager(dataStore, configRepo); + RatingsRepository provideRatingsRepository(Database database) { + return new RatingsRepository(database); } @Provides @Singleton - UserStatsRepository provideUserStatsRepository(JsonDataStore dataStore) { - return new JsonUserStatsRepository(dataStore); + ReputationRepository provideReputationRepository(Database database) { + return new ReputationRepository(database); } @Provides @Singleton - CounterManager provideCounterManager(JsonDataStore dataStore, ConfigRepository configRepo) { - return new CounterManager(dataStore, configRepo); - } - - @Provides - @Singleton - XpFormula provideXpFormula() { - return new XpFormula(); - } - - @Provides - @Singleton - MessageCooldownService provideMessageCooldownService(UserStatsRepository repository) { - return new MessageCooldownService(repository); - } - - @Provides - @Singleton - LeaderboardService provideLeaderboardService(UserStatsRepository repository) { - return new LeaderboardService(repository); - } - - @Provides - @Singleton - LevelService provideLevelService( - UserStatsRepository repository, - XpFormula xpFormula, - MessageCooldownService cooldownService - ) { - return new LevelService(repository, xpFormula, cooldownService); - } - - @Provides - @Singleton - ProfileService provideOldProfileService(JsonDataStore dataStore) { - return new ProfileService(dataStore); - } - - @Provides - @Singleton - creator.loki.feature.profile.ProfileService provideNewProfileService( - UserStatsRepository repository, - LevelService levelService, - LeaderboardService leaderboardService - ) { - return new creator.loki.feature.profile.ProfileService(repository, levelService, leaderboardService); + ShipPairRepository provideShipPairRepository(Database database) { + return new ShipPairRepository(database); } } diff --git a/src/main/java/creator/loki/embed/EmbedFactory.java b/src/main/java/creator/loki/embed/EmbedFactory.java index 8266fea..5291da1 100644 --- a/src/main/java/creator/loki/embed/EmbedFactory.java +++ b/src/main/java/creator/loki/embed/EmbedFactory.java @@ -1,79 +1,79 @@ -package creator.loki.embed; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.MessageEmbed; - -import java.awt.Color; -import java.time.Instant; - -public class EmbedFactory { - private final EmbedBuilder builder; - - private EmbedFactory() { - this.builder = new EmbedBuilder(); - } - - public static EmbedFactory create() { - return new EmbedFactory(); - } - - public EmbedFactory setTitle(String title) { - builder.setTitle(title); - return this; - } - - public EmbedFactory setDescription(String description) { - builder.setDescription(description); - return this; - } - - public EmbedFactory setColor(Color color) { - builder.setColor(color); - return this; - } - - public EmbedFactory addField(String name, String value, boolean inline) { - builder.addField(name, value, inline); - return this; - } - - public EmbedFactory setFooter(String text) { - builder.setFooter(text); - return this; - } - - public EmbedFactory setFooter(String text, String iconUrl) { - builder.setFooter(text, iconUrl); - return this; - } - - public EmbedFactory setThumbnail(String url) { - builder.setThumbnail(url); - return this; - } - - public EmbedFactory setImage(String url) { - builder.setImage(url); - return this; - } - - public EmbedFactory setAuthor(String name) { - builder.setAuthor(name); - return this; - } - - public EmbedFactory setAuthor(String name, String url) { - builder.setAuthor(name, url); - return this; - } - - public EmbedFactory setAuthor(String name, String url, String iconUrl) { - builder.setAuthor(name, url, iconUrl); - return this; - } - - public MessageEmbed build() { - builder.setTimestamp(Instant.now()); - return builder.build(); - } -} +package creator.loki.embed; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; + +import java.awt.Color; +import java.time.Instant; + +public class EmbedFactory { + private final EmbedBuilder builder; + + private EmbedFactory() { + this.builder = new EmbedBuilder(); + } + + public static EmbedFactory create() { + return new EmbedFactory(); + } + + public EmbedFactory withTitle(String title) { + builder.setTitle(title); + return this; + } + + public EmbedFactory withDescription(String description) { + builder.setDescription(description); + return this; + } + + public EmbedFactory withColor(Color color) { + builder.setColor(color); + return this; + } + + public EmbedFactory addField(String name, String value, boolean inline) { + builder.addField(name, value, inline); + return this; + } + + public EmbedFactory withFooter(String text) { + builder.setFooter(text); + return this; + } + + public EmbedFactory withFooter(String text, String iconUrl) { + builder.setFooter(text, iconUrl); + return this; + } + + public EmbedFactory withThumbnail(String url) { + builder.setThumbnail(url); + return this; + } + + public EmbedFactory withImage(String url) { + builder.setImage(url); + return this; + } + + public EmbedFactory withAuthor(String name) { + builder.setAuthor(name); + return this; + } + + public EmbedFactory withAuthor(String name, String url) { + builder.setAuthor(name, url); + return this; + } + + public EmbedFactory withAuthor(String name, String url, String iconUrl) { + builder.setAuthor(name, url, iconUrl); + return this; + } + + public MessageEmbed build() { + builder.setTimestamp(Instant.now()); + return builder.build(); + } +} diff --git a/src/main/java/creator/loki/embed/EmbedTemplates.java b/src/main/java/creator/loki/embed/EmbedTemplates.java index 83d423e..3b32d64 100644 --- a/src/main/java/creator/loki/embed/EmbedTemplates.java +++ b/src/main/java/creator/loki/embed/EmbedTemplates.java @@ -1,50 +1,50 @@ -package creator.loki.embed; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.MessageEmbed; - -import java.awt.Color; -import java.time.Instant; - -public final class EmbedTemplates { - - private EmbedTemplates() { - // Запрет создания экземпляров - } - - public static MessageEmbed success(String title, String description) { - return new EmbedBuilder() - .setColor(Color.GREEN) - .setTitle("✅ " + title) - .setDescription(description) - .setTimestamp(Instant.now()) - .build(); - } - - public static MessageEmbed error(String title, String description) { - return new EmbedBuilder() - .setColor(Color.RED) - .setTitle("❌ " + title) - .setDescription(description) - .setTimestamp(Instant.now()) - .build(); - } - - public static MessageEmbed info(String title, String description) { - return new EmbedBuilder() - .setColor(Color.CYAN) - .setTitle("ℹ️ " + title) - .setDescription(description) - .setTimestamp(Instant.now()) - .build(); - } - - public static MessageEmbed warning(String title, String description) { - return new EmbedBuilder() - .setColor(Color.ORANGE) - .setTitle("⚠️ " + title) - .setDescription(description) - .setTimestamp(Instant.now()) - .build(); - } -} +package creator.loki.embed; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; + +import java.awt.Color; +import java.time.Instant; + +public final class EmbedTemplates { + + private EmbedTemplates() { + // Запрет создания экземпляров + } + + public static MessageEmbed success(String title, String description) { + return new EmbedBuilder() + .setColor(Color.GREEN) + .setTitle("✅ " + title) + .setDescription(description) + .setTimestamp(Instant.now()) + .build(); + } + + public static MessageEmbed error(String title, String description) { + return new EmbedBuilder() + .setColor(Color.RED) + .setTitle("❌ " + title) + .setDescription(description) + .setTimestamp(Instant.now()) + .build(); + } + + public static MessageEmbed info(String title, String description) { + return new EmbedBuilder() + .setColor(Color.CYAN) + .setTitle("ℹ️ " + title) + .setDescription(description) + .setTimestamp(Instant.now()) + .build(); + } + + public static MessageEmbed warning(String title, String description) { + return new EmbedBuilder() + .setColor(Color.ORANGE) + .setTitle("⚠️ " + title) + .setDescription(description) + .setTimestamp(Instant.now()) + .build(); + } +} diff --git a/src/main/java/creator/loki/embed/HelpCategories.java b/src/main/java/creator/loki/embed/HelpCategories.java new file mode 100644 index 0000000..32b9651 --- /dev/null +++ b/src/main/java/creator/loki/embed/HelpCategories.java @@ -0,0 +1,75 @@ +package creator.loki.embed; + +import creator.loki.command.CommandCategories; + +import java.awt.Color; +import java.util.Map; + +public final class HelpCategories { + private static final Map EMOJIS; + private static final Map COLORS; + private static final Map DESCRIPTIONS; + + static { + EMOJIS = Map.ofEntries( + Map.entry("Общие", "📋"), + Map.entry("Роли", "🎭"), + Map.entry("Развлечения", "🎮"), + Map.entry(CommandCategories.GIF_INTERACTIVE, "💞"), + Map.entry("Модерация", "🛡️"), + Map.entry("Статистика", "📊"), + Map.entry("Утилиты", "🔧"), + Map.entry("Система", "💠"), + Map.entry("Настройка", "⚙️"), + Map.entry("Супер-Админ", "🔐") + ); + + COLORS = Map.ofEntries( + Map.entry("Общие", new Color(52, 152, 219)), + Map.entry("Роли", new Color(155, 89, 182)), + Map.entry("Развлечения", new Color(241, 196, 15)), + Map.entry(CommandCategories.GIF_INTERACTIVE, new Color(233, 30, 99)), + Map.entry("Модерация", new Color(231, 76, 60)), + Map.entry("Статистика", new Color(46, 204, 113)), + Map.entry("Утилиты", new Color(52, 152, 219)), + Map.entry("Система", new Color(52, 73, 94)), + Map.entry("Настройка", new Color(149, 165, 166)), + Map.entry("Супер-Админ", new Color(113, 54, 138)) + ); + + DESCRIPTIONS = Map.ofEntries( + Map.entry("Общие", "Основные команды бота"), + Map.entry("Роли", "Управление кастомными ролями"), + Map.entry("Развлечения", "Игры, мемы, рейтинги, текст"), + Map.entry(CommandCategories.GIF_INTERACTIVE, "Действия с участником в GIF (nekos.life)"), + Map.entry("Модерация", "Команды модерации сервера"), + Map.entry("Статистика", "Уровни, репутация и статистика"), + Map.entry("Утилиты", "Полезные инструменты"), + Map.entry("Система", "Метрики и служебная информация для админов"), + Map.entry("Настройка", "Настройка систем бота (только админы)"), + Map.entry("Супер-Админ", "Сервисные команды владельца бота") + ); + } + + private HelpCategories() { + } + + public static String getEmoji(String category) { + return EMOJIS.getOrDefault(category, "📌"); + } + + public static Color getColor(String category) { + return COLORS.getOrDefault(category, new Color(88, 101, 242)); + } + + public static String getDescription(String category) { + return DESCRIPTIONS.getOrDefault(category, "Команды категории"); + } + + public static boolean isCategoryVisible(String category, boolean isAdmin, boolean isSuper) { + if ("Настройка".equals(category) && !isAdmin) { + return false; + } + return !"Супер-Админ".equals(category) || isSuper; + } +} diff --git a/src/main/java/creator/loki/feature/admin/commands/GenerateRatingsCommand.java b/src/main/java/creator/loki/feature/admin/commands/GenerateRatingsCommand.java new file mode 100644 index 0000000..72d7504 --- /dev/null +++ b/src/main/java/creator/loki/feature/admin/commands/GenerateRatingsCommand.java @@ -0,0 +1,158 @@ +package creator.loki.feature.admin.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.feature.rating.repository.RatingsRepository; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.Member; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.util.Random; + +/** + * Команда для генерации всех rating значений для всех участников сервера + * Доступна только супер-админу + */ +public class GenerateRatingsCommand implements Command { + private static final Logger logger = LoggerFactory.getLogger(GenerateRatingsCommand.class); + private final RatingsRepository ratingsRepository; + private final BotConfig config; + private final Random random = new Random(); + + public GenerateRatingsCommand(RatingsRepository ratingsRepository, BotConfig config) { + this.ratingsRepository = ratingsRepository; + this.config = config; + } + + @Override + public String getName() { + return "generateratings"; + } + + @Override + public String getDescription() { + return "Генерирует все rating значения для всех участников сервера (только для супер-админа)"; + } + + @Override + public String getUsage() { + return "generateratings"; + } + + @Override + public String getCategory() { + return "Супер-Админ"; + } + + @Override + public void execute(CommandContext context) { + if (!config.getSuperAdminId().equals(context.getAuthor().getUser().getId())) { + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("❌ Недостаточно прав") + .withDescription("Эта команда доступна только супер-администратору бота.") + .withColor(Color.RED) + .build() + ).queue(); + return; + } + + if (context.getGuild() == null) { + context.getChannel().sendMessage("❌ Команда работает только на сервере.").queue(); + return; + } + + var initial = context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("⏳ Генерация рейтингов...") + .withDescription("Начинаю генерацию всех rating значений для участников сервера...") + .withColor(Color.YELLOW) + .build() + ).complete(); + + try { + String guildId = context.getGuild().getId(); + int memberCount = 0; + int generatedCount = 0; + + for (Member member : context.getGuild().getMembers()) { + if (member.getUser().isBot()) { + continue; + } + + memberCount++; + String userId = member.getUser().getId(); + + generateRatingForUser(guildId, userId, "ship"); + generateRatingForUser(guildId, userId, "gay"); + generateRatingForUser(guildId, userId, "iq"); + generateRatingForUser(guildId, userId, "pp"); + generateRatingForUser(guildId, userId, "mustache"); + generateRatingForUser(guildId, userId, "fat"); + generateRatingForUser(guildId, userId, "boykisser"); + + generatedCount++; + } + + initial.editMessageEmbeds( + EmbedFactory.create() + .withTitle("✅ Генерация завершена") + .withDescription( + "Успешно сгенерированы все rating значения!\n\n" + + "**Статистика:**\n" + + "• Обработано участников: **" + memberCount + "**\n" + + "• Сгенерировано значений: **" + (generatedCount * 7) + "**\n" + + "• Типы рейтингов: ship, gay, iq, pp, mustache, fat, boykisser" + ) + .withColor(Color.GREEN) + .withFooter("Инициатор: " + context.getAuthor().getUser().getName(), context.getAuthor().getAvatarUrl()) + .build() + ).queue(); + + logger.info("Супер-админ {} сгенерировал рейтинги для {} участников", + context.getAuthor().getUser().getName(), memberCount); + + } catch (Exception e) { + logger.error("Ошибка при генерации рейтингов", e); + initial.editMessageEmbeds( + EmbedFactory.create() + .withTitle("❌ Ошибка") + .withDescription("Произошла ошибка при генерации рейтингов: " + e.getMessage()) + .withColor(Color.RED) + .build() + ).queue(); + } + } + + /** + * Генерирует rating значение для пользователя + */ + private void generateRatingForUser(String guildId, String userId, String ratingType) { + // Проверяем существует ли уже значение + if (ratingsRepository.hasRating(guildId, userId, ratingType)) { + return; // Не перезаписываем существующие значения + } + + // Генерируем значение в зависимости от типа + int value = switch (ratingType) { + case "iq" -> random.nextInt(201); // 0-200 + case "pp" -> random.nextInt(31); // 0-30 см + case "mustache" -> { + // Редкий шанс на легендарные усы + if (random.nextInt(100) < 1) { + yield 1000 + random.nextInt(500); // 1000-1500 + } + yield random.nextInt(101); // 0-100 + } + case "fat" -> random.nextInt(101); // 0-100% + case "boykisser" -> random.nextInt(101); // 0-100% + default -> random.nextInt(101); // ship, gay: 0-100% + }; + + // Сохраняем значение в БД + ratingsRepository.setRating(guildId, userId, ratingType, value); + } +} diff --git a/src/main/java/creator/loki/database/ConfigRepository.java b/src/main/java/creator/loki/feature/config/repository/ConfigRepository.java similarity index 91% rename from src/main/java/creator/loki/database/ConfigRepository.java rename to src/main/java/creator/loki/feature/config/repository/ConfigRepository.java index 175666e..05af403 100644 --- a/src/main/java/creator/loki/database/ConfigRepository.java +++ b/src/main/java/creator/loki/feature/config/repository/ConfigRepository.java @@ -1,5 +1,7 @@ -package creator.loki.database; +package creator.loki.feature.config.repository; +import creator.loki.database.Database; +import creator.loki.feature.config.repository.StringConfigStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,7 +12,7 @@ import java.sql.SQLException; /** * Репозиторий для работы с конфигурацией */ -public class ConfigRepository { +public class ConfigRepository implements StringConfigStore { private static final Logger logger = LoggerFactory.getLogger(ConfigRepository.class); private final Database database; @@ -25,7 +27,7 @@ public class ConfigRepository { try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = ?")) { stmt.setString(1, key); ResultSet rs = stmt.executeQuery(); - + if (rs.next()) { return rs.getString("value"); } diff --git a/src/main/java/creator/loki/feature/config/repository/GuildConfigRepository.java b/src/main/java/creator/loki/feature/config/repository/GuildConfigRepository.java new file mode 100644 index 0000000..220136a --- /dev/null +++ b/src/main/java/creator/loki/feature/config/repository/GuildConfigRepository.java @@ -0,0 +1,16 @@ +package creator.loki.feature.config.repository; + +/** + * Ключи конфигурации с привязкой к гильдии (хранение: {@code guildId + ":" + key} в общей таблице config). + */ +public interface GuildConfigRepository { + + /** + * @return значение или {@code null}, если нет записи + */ + String get(String guildId, String key); + + void set(String guildId, String key, String value); + + void delete(String guildId, String key); +} diff --git a/src/main/java/creator/loki/feature/config/repository/PrefixedGuildConfigRepository.java b/src/main/java/creator/loki/feature/config/repository/PrefixedGuildConfigRepository.java new file mode 100644 index 0000000..55a616f --- /dev/null +++ b/src/main/java/creator/loki/feature/config/repository/PrefixedGuildConfigRepository.java @@ -0,0 +1,35 @@ +package creator.loki.feature.config.repository; + +import creator.loki.feature.config.repository.GuildConfigRepository; +import creator.loki.feature.config.repository.StringConfigStore; + +/** + * Реализация {@link GuildConfigRepository} поверх {@link StringConfigStore} с префиксом {@code guildId:}. + */ +public final class PrefixedGuildConfigRepository implements GuildConfigRepository { + + private final StringConfigStore store; + + public PrefixedGuildConfigRepository(StringConfigStore store) { + this.store = store; + } + + private static String storageKey(String guildId, String key) { + return guildId + ":" + key; + } + + @Override + public String get(String guildId, String key) { + return store.get(storageKey(guildId, key)); + } + + @Override + public void set(String guildId, String key, String value) { + store.set(storageKey(guildId, key), value); + } + + @Override + public void delete(String guildId, String key) { + store.delete(storageKey(guildId, key)); + } +} diff --git a/src/main/java/creator/loki/feature/config/repository/StringConfigStore.java b/src/main/java/creator/loki/feature/config/repository/StringConfigStore.java new file mode 100644 index 0000000..d4d7704 --- /dev/null +++ b/src/main/java/creator/loki/feature/config/repository/StringConfigStore.java @@ -0,0 +1,16 @@ +package creator.loki.feature.config.repository; + +/** + * Плоское хранилище строковых ключей (глобальная таблица config и т.п.). + */ +public interface StringConfigStore { + + /** + * @return значение или {@code null} + */ + String get(String key); + + void set(String key, String value); + + void delete(String key); +} diff --git a/src/main/java/creator/loki/feature/counter/CounterManager.java b/src/main/java/creator/loki/feature/counter/CounterManager.java index d5dcd33..9053e34 100644 --- a/src/main/java/creator/loki/feature/counter/CounterManager.java +++ b/src/main/java/creator/loki/feature/counter/CounterManager.java @@ -1,226 +1,110 @@ -package creator.loki.feature.counter; - -import com.google.gson.reflect.TypeToken; -import creator.loki.data.JsonDataStore; -import creator.loki.database.ConfigRepository; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Type; -import java.time.LocalDate; -import java.util.*; - -/** - * Менеджер счетчика участников - */ -public class CounterManager { - private static final Logger logger = LoggerFactory.getLogger(CounterManager.class); - private final JsonDataStore dataStore; - private final ConfigRepository configRepo; - - private String counterChannelId; - private String format = "【👥】Участники: {count}"; - - public CounterManager(JsonDataStore dataStore, ConfigRepository configRepo) { - this.dataStore = dataStore; - this.configRepo = configRepo; - loadConfig(); - } - - /** - * Загрузить конфигурацию - */ - private void loadConfig() { - this.counterChannelId = configRepo.get("counter.channel_id"); - String savedFormat = configRepo.get("counter.format"); - if (savedFormat != null) { - this.format = savedFormat; - } - } - - /** - * Получить данные счетчика - */ - @SuppressWarnings("unchecked") - private Map getCounterData() { - Type type = new TypeToken>(){}.getType(); - Map data = dataStore.getData("counter_data", type); - return data != null ? data : new HashMap<>(); - } - - /** - * Сохранить данные счетчика - */ - private void saveCounterData(Map data) { - dataStore.setData("counter_data", data); - } - - /** - * Установить канал счетчика - */ - public void setCounterChannel(String channelId) { - configRepo.set("counter.channel_id", channelId); - this.counterChannelId = channelId; - logger.info("Установлен канал счетчика: {}", channelId); - } - - /** - * Установить формат отображения - */ - public void setFormat(String format) { - configRepo.set("counter.format", format); - this.format = format; - logger.info("Установлен формат: {}", format); - } - - /** - * Получить текущую дневную статистику - */ - @SuppressWarnings("unchecked") - public DailyStats getDailyStats() { - Map data = getCounterData(); - Map statsMap = (Map) data.get("daily_stats"); - - if (statsMap == null) { - return new DailyStats(); - } - - DailyStats stats = new DailyStats(); - stats.setDate((String) statsMap.getOrDefault("date", LocalDate.now().toString())); - stats.setJoined(((Number) statsMap.getOrDefault("joined", 0)).intValue()); - stats.setLeft(((Number) statsMap.getOrDefault("left", 0)).intValue()); - - // Если статистика не сегодняшняя, создаем новую - if (!stats.isToday()) { - // Сохраняем старую в историю - saveToHistory(stats); - stats = new DailyStats(); - } - - return stats; - } - - /** - * Сохранить статистику - */ - private void saveDailyStats(DailyStats stats) { - Map data = getCounterData(); - - Map statsMap = new HashMap<>(); - statsMap.put("date", stats.getDate()); - statsMap.put("joined", stats.getJoined()); - statsMap.put("left", stats.getLeft()); - statsMap.put("difference", stats.getDifference()); - - data.put("daily_stats", statsMap); - saveCounterData(data); - } - - /** - * Сохранить статистику в историю - */ - @SuppressWarnings("unchecked") - private void saveToHistory(DailyStats stats) { - Map data = getCounterData(); - List> history = (List>) data.get("history"); - - if (history == null) { - history = new ArrayList<>(); - } - - Map statsMap = new HashMap<>(); - statsMap.put("date", stats.getDate()); - statsMap.put("joined", stats.getJoined()); - statsMap.put("left", stats.getLeft()); - statsMap.put("difference", stats.getDifference()); - - history.add(statsMap); - - // Храним только последние 30 дней - if (history.size() > 30) { - history.remove(0); - } - - data.put("history", history); - saveCounterData(data); - } - - /** - * Зарегистрировать присоединение участника - */ - public void recordJoin() { - DailyStats stats = getDailyStats(); - stats.incrementJoined(); - saveDailyStats(stats); - logger.debug("Зарегистрировано присоединение. Всего за день: {}", stats.getJoined()); - } - - /** - * Зарегистрировать выход участника - */ - public void recordLeave() { - DailyStats stats = getDailyStats(); - stats.incrementLeft(); - saveDailyStats(stats); - logger.debug("Зарегистрирован выход. Всего за день: {}", stats.getLeft()); - } - - /** - * Обновить счетчик участников - */ - public void updateCounter(Guild guild) { - if (counterChannelId == null || counterChannelId.isEmpty()) { - return; - } - - VoiceChannel channel = guild.getVoiceChannelById(counterChannelId); - if (channel == null) { - logger.warn("Канал счетчика не найден: {}", counterChannelId); - return; - } - - int memberCount = guild.getMemberCount(); - String newName = format.replace("{count}", String.valueOf(memberCount)); - - // Обновляем название канала только если оно изменилось - if (!channel.getName().equals(newName)) { - channel.getManager().setName(newName).queue( - success -> logger.debug("Обновлен счетчик участников: {}", memberCount), - error -> logger.error("Ошибка при обновлении счетчика", error) - ); - } - } - - /** - * Получить историю статистики - */ - @SuppressWarnings("unchecked") - public List getHistory(int days) { - Map data = getCounterData(); - List> historyMaps = (List>) data.get("history"); - - if (historyMaps == null) { - return new ArrayList<>(); - } - - List history = new ArrayList<>(); - int start = Math.max(0, historyMaps.size() - days); - - for (int i = start; i < historyMaps.size(); i++) { - Map statsMap = historyMaps.get(i); - DailyStats stats = new DailyStats(); - stats.setDate((String) statsMap.get("date")); - stats.setJoined(((Number) statsMap.get("joined")).intValue()); - stats.setLeft(((Number) statsMap.get("left")).intValue()); - history.add(stats); - } - - return history; - } - - // Getters - public String getCounterChannelId() { return counterChannelId; } - public String getFormat() { return format; } -} +package creator.loki.feature.counter; + +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.feature.counter.repository.CounterStatsRepository; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * Менеджер счетчика участников + */ +public class CounterManager { + private static final Logger logger = LoggerFactory.getLogger(CounterManager.class); + private final CounterStatsRepository statsRepo; + private final ConfigRepository configRepo; + + private String counterChannelId; + private String format = "【👥】Участники: {count}"; + + public CounterManager(CounterStatsRepository statsRepo, ConfigRepository configRepo) { + this.statsRepo = statsRepo; + this.configRepo = configRepo; + loadConfig(); + } + + /** + * Загрузить конфигурацию + */ + private void loadConfig() { + this.counterChannelId = configRepo.get("counter.channel_id"); + String savedFormat = configRepo.get("counter.format"); + if (savedFormat != null) { + this.format = savedFormat; + } + } + + /** + * Получить текущую дневную статистику + */ + public DailyStats getDailyStats() { + return statsRepo.getTodayStats(); + } + + /** + * Сохранить статистику + */ + private void saveDailyStats(DailyStats stats) { + statsRepo.saveStats(stats); + } + + /** + * Зарегистрировать присоединение участника + */ + public void recordJoin() { + DailyStats stats = getDailyStats(); + stats.incrementJoined(); + saveDailyStats(stats); + logger.debug("Зарегистрировано присоединение. Всего за день: {}", stats.getJoined()); + } + + /** + * Зарегистрировать выход участника + */ + public void recordLeave() { + DailyStats stats = getDailyStats(); + stats.incrementLeft(); + saveDailyStats(stats); + logger.debug("Зарегистрирован выход. Всего за день: {}", stats.getLeft()); + } + + /** + * Обновить счетчик участников + */ + public void updateCounter(Guild guild) { + if (counterChannelId == null || counterChannelId.isEmpty()) { + return; + } + + VoiceChannel channel = guild.getVoiceChannelById(counterChannelId); + if (channel == null) { + logger.warn("Канал счетчика не найден: {}", counterChannelId); + return; + } + + int memberCount = guild.getMemberCount(); + String newName = format.replace("{count}", String.valueOf(memberCount)); + + // Обновляем название канала только если оно изменилось + if (!channel.getName().equals(newName)) { + channel.getManager().setName(newName).queue( + success -> logger.debug("Обновлен счетчик участников: {}", memberCount), + error -> logger.error("Ошибка при обновлении счетчика", error) + ); + } + } + + /** + * Получить историю статистики + */ + public List getHistory(int days) { + return statsRepo.getHistory(days); + } + + // Getters + public String getCounterChannelId() { return counterChannelId; } + public String getFormat() { return format; } +} diff --git a/src/main/java/creator/loki/feature/counter/CounterModule.java b/src/main/java/creator/loki/feature/counter/CounterModule.java new file mode 100644 index 0000000..2d8a649 --- /dev/null +++ b/src/main/java/creator/loki/feature/counter/CounterModule.java @@ -0,0 +1,27 @@ +package creator.loki.feature.counter; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import creator.loki.database.Database; +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.feature.counter.repository.CounterStatsRepository; + +public class CounterModule extends AbstractModule { + + @Override + protected void configure() { + } + + @Provides + @Singleton + CounterStatsRepository provideCounterStatsRepository(Database database) { + return new CounterStatsRepository(database); + } + + @Provides + @Singleton + CounterManager provideCounterManager(CounterStatsRepository statsRepo, ConfigRepository configRepo) { + return new CounterManager(statsRepo, configRepo); + } +} diff --git a/src/main/java/creator/loki/feature/counter/CounterSetupCommand.java b/src/main/java/creator/loki/feature/counter/CounterSetupCommand.java deleted file mode 100644 index d71a37d..0000000 --- a/src/main/java/creator/loki/feature/counter/CounterSetupCommand.java +++ /dev/null @@ -1,159 +0,0 @@ -package creator.loki.feature.counter; - -import creator.loki.database.ConfigRepository; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.awt.Color; - -/** - * Команда настройки счетчика участников - */ -public class CounterSetupCommand extends ListenerAdapter { - private static final Logger logger = LoggerFactory.getLogger(CounterSetupCommand.class); - private final ConfigRepository configRepo; - - public CounterSetupCommand(ConfigRepository configRepo) { - this.configRepo = configRepo; - } - - @Override - public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { - if (!"counter-setup".equals(event.getName())) { - return; - } - - // Проверка прав администратора - Member member = event.getMember(); - if (member == null || !member.hasPermission(Permission.ADMINISTRATOR)) { - event.reply("❌ У вас нет прав для использования этой команды!").setEphemeral(true).queue(); - return; - } - - String subcommand = event.getSubcommandName(); - if (subcommand == null) { - return; - } - - switch (subcommand) { - case "channel" -> handleChannel(event); - case "format" -> handleFormat(event); - case "info" -> handleInfo(event); - } - } - - /** - * Установить канал счетчика - */ - private void handleChannel(SlashCommandInteractionEvent event) { - VoiceChannel channel = event.getOption("channel").getAsChannel().asVoiceChannel(); - - configRepo.set("counter.channel_id", channel.getId()); - - // Сразу обновляем название канала - int memberCount = event.getGuild().getMemberCount(); - String format = configRepo.get("counter.format"); - if (format == null) { - format = "【👥】Участники: {count}"; - configRepo.set("counter.format", format); - } - - String newName = format.replace("{count}", String.valueOf(memberCount)); - channel.getManager().setName(newName).queue(); - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("✅ Канал счетчика установлен") - .setDescription("Канал " + channel.getAsMention() + " теперь отображает количество участников") - .addField("Текущее количество", String.valueOf(memberCount), false) - .setColor(Color.GREEN); - - event.replyEmbeds(embed.build()).queue(); - logger.info("Установлен канал счетчика: {} ({})", channel.getName(), channel.getId()); - } - - /** - * Установить формат отображения - */ - private void handleFormat(SlashCommandInteractionEvent event) { - String format = event.getOption("format").getAsString(); - - if (!format.contains("{count}")) { - event.reply("❌ Формат должен содержать `{count}`!").setEphemeral(true).queue(); - return; - } - - configRepo.set("counter.format", format); - - // Обновляем канал если он установлен - String channelId = configRepo.get("counter.channel_id"); - if (channelId != null) { - VoiceChannel channel = event.getGuild().getVoiceChannelById(channelId); - if (channel != null) { - int memberCount = event.getGuild().getMemberCount(); - String newName = format.replace("{count}", String.valueOf(memberCount)); - channel.getManager().setName(newName).queue(); - } - } - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("✅ Формат обновлен") - .setDescription("Новый формат: `" + format + "`") - .addField("Пример", format.replace("{count}", "123"), false) - .setColor(Color.GREEN); - - event.replyEmbeds(embed.build()).queue(); - logger.info("Установлен формат счетчика: {}", format); - } - - /** - * Показать текущую конфигурацию - */ - private void handleInfo(SlashCommandInteractionEvent event) { - String channelId = configRepo.get("counter.channel_id"); - String format = configRepo.get("counter.format"); - - EmbedBuilder embed = new EmbedBuilder() - .setTitle("📊 Конфигурация счетчика участников") - .setColor(Color.BLUE); - - if (channelId != null) { - embed.addField("Канал", "<#" + channelId + ">", false); - } else { - embed.addField("Канал", "❌ Не установлен", false); - } - - if (format != null) { - embed.addField("Формат", "`" + format + "`", false); - embed.addField("Пример", format.replace("{count}", String.valueOf(event.getGuild().getMemberCount())), false); - } else { - embed.addField("Формат", "❌ Не установлен", false); - } - - event.replyEmbeds(embed.build()).setEphemeral(true).queue(); - } - - /** - * Зарегистрировать команду в Discord - */ - public static void register(net.dv8tion.jda.api.JDA jda) { - jda.updateCommands().addCommands( - Commands.slash("counter-setup", "Настройка счетчика участников") - .addSubcommands( - new SubcommandData("channel", "Установить канал для счетчика") - .addOption(OptionType.CHANNEL, "channel", "Голосовой канал", true), - new SubcommandData("format", "Установить формат отображения") - .addOption(OptionType.STRING, "format", "Формат (используйте {count})", true), - new SubcommandData("info", "Показать текущую конфигурацию") - ) - ).queue(); - } -} diff --git a/src/main/java/creator/loki/feature/counter/CounterSetupPrefixCommand.java b/src/main/java/creator/loki/feature/counter/CounterSetupPrefixCommand.java new file mode 100644 index 0000000..5fe79ab --- /dev/null +++ b/src/main/java/creator/loki/feature/counter/CounterSetupPrefixCommand.java @@ -0,0 +1,186 @@ +package creator.loki.feature.counter; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.embed.EmbedFactory; +import creator.loki.util.GuildChannelResolve; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +import java.awt.Color; +import java.util.List; + +/** + * Prefix команда настройки счетчика участников + */ +public class CounterSetupPrefixCommand implements Command { + private final CounterManager counterManager; + private final ConfigRepository configRepo; + + public CounterSetupPrefixCommand(CounterManager counterManager, ConfigRepository configRepo) { + this.counterManager = counterManager; + this.configRepo = configRepo; + } + + @Override + public String getName() { + return "counter-setup"; + } + + @Override + public String getDescription() { + return "Настройка счетчика участников"; + } + + @Override + public String getUsage() { + return "counter-setup [значение]"; + } + + @Override + public String getCategory() { + return "Настройка"; + } + + @Override + public void execute(CommandContext context) { + // Проверка админ прав + if (!context.getAuthor().hasPermission(Permission.ADMINISTRATOR)) { + context.getChannel().sendMessage("❌ У вас нет прав администратора!").queue(); + return; + } + + List args = context.getArgs(); + if (args.isEmpty()) { + sendHelp(context); + return; + } + + String action = args.get(0).toLowerCase(); + + switch (action) { + case "channel" -> handleChannel(context, args); + case "format" -> handleFormat(context, args); + case "info" -> handleInfo(context); + default -> sendHelp(context); + } + } + + private void handleChannel(CommandContext context, List args) { + if (args.size() < 2) { + context.getChannel().sendMessage( + "❌ Укажите голосовой канал: `#канал`, `<#id>` или числовой id. Пример: `!counter-setup channel 123456789012345678`" + ).queue(); + return; + } + + VoiceChannel channel = GuildChannelResolve.voice(context.getGuild(), context.getMessage(), args, 1); + if (channel == null) { + context.getChannel().sendMessage( + "❌ Не найден голосовой канал. Укажите `#войс`, `<#id>` или id из 17-20 цифр." + ).queue(); + return; + } + configRepo.set("counter.channel_id", channel.getId()); + + // Сразу обновляем название канала + int memberCount = context.getGuild().getMemberCount(); + String format = configRepo.get("counter.format"); + if (format == null) { + format = "【👥】Участники: {count}"; + configRepo.set("counter.format", format); + } + + String newName = format.replace("{count}", String.valueOf(memberCount)); + channel.getManager().setName(newName).queue(); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("✅ Канал счетчика установлен") + .withDescription("Канал " + channel.getAsMention() + " теперь отображает количество участников") + .addField("Текущее количество", String.valueOf(memberCount), false) + .withColor(Color.GREEN) + .build() + ).queue(); + } + + private void handleFormat(CommandContext context, List args) { + if (args.size() < 2) { + context.getChannel().sendMessage("❌ Укажите формат! Пример: `!counter-setup format 【👥】Участники: {count}`").queue(); + return; + } + + // Объединяем все аргументы в одну строку + String format = String.join(" ", args.subList(1, args.size())); + + if (!format.contains("{count}")) { + context.getChannel().sendMessage("❌ Формат должен содержать `{count}`!").queue(); + return; + } + + configRepo.set("counter.format", format); + + // Обновляем канал если он установлен + String channelId = configRepo.get("counter.channel_id"); + if (channelId != null) { + VoiceChannel channel = context.getGuild().getVoiceChannelById(channelId); + if (channel != null) { + int memberCount = context.getGuild().getMemberCount(); + String newName = format.replace("{count}", String.valueOf(memberCount)); + channel.getManager().setName(newName).queue(); + } + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("✅ Формат обновлен") + .withDescription("Новый формат: `" + format + "`") + .addField("Пример", format.replace("{count}", "123"), false) + .withColor(Color.GREEN) + .build() + ).queue(); + } + + private void handleInfo(CommandContext context) { + String channelId = configRepo.get("counter.channel_id"); + String format = configRepo.get("counter.format"); + + var embed = EmbedFactory.create() + .withTitle("📊 Конфигурация счетчика участников") + .withColor(Color.BLUE); + + if (channelId != null) { + embed.addField("Канал", "<#" + channelId + ">", false); + } else { + embed.addField("Канал", "❌ Не установлен", false); + } + + if (format != null) { + embed.addField("Формат", "`" + format + "`", false); + embed.addField("Пример", format.replace("{count}", String.valueOf(context.getGuild().getMemberCount())), false); + } else { + embed.addField("Формат", "❌ Не установлен", false); + } + + context.getChannel().sendMessageEmbeds(embed.build()).queue(); + } + + private void sendHelp(CommandContext context) { + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("📊 Настройка счетчика участников") + .withDescription(""" + **Доступные команды:** + • `!counter-setup channel #войс` или `id` - канал счётчика + • `!counter-setup format 【👥】Участники: {count}` - Установить формат + • `!counter-setup info` - Показать текущую конфигурацию + + В формате используйте `{count}` для отображения количества. + Только для администраторов! + """) + .withColor(Color.BLUE) + .build() + ).queue(); + } +} \ No newline at end of file diff --git a/src/main/java/creator/loki/feature/counter/DailyStats.java b/src/main/java/creator/loki/feature/counter/DailyStats.java index a417b8b..63c8ebc 100644 --- a/src/main/java/creator/loki/feature/counter/DailyStats.java +++ b/src/main/java/creator/loki/feature/counter/DailyStats.java @@ -1,66 +1,66 @@ -package creator.loki.feature.counter; - -import java.time.LocalDate; - -/** - * Дневная статистика участников - */ -public class DailyStats { - private String date; - private int joined; - private int left; - private int difference; - - public DailyStats() { - this.date = LocalDate.now().toString(); - this.joined = 0; - this.left = 0; - this.difference = 0; - } - - public DailyStats(String date, int joined, int left) { - this.date = date; - this.joined = joined; - this.left = left; - this.difference = joined - left; - } - - // Getters - public String getDate() { return date; } - public int getJoined() { return joined; } - public int getLeft() { return left; } - public int getDifference() { return difference; } - - // Setters - public void setDate(String date) { this.date = date; } - public void setJoined(int joined) { - this.joined = joined; - updateDifference(); - } - public void setLeft(int left) { - this.left = left; - updateDifference(); - } - - // Методы - public void incrementJoined() { - this.joined++; - updateDifference(); - } - - public void incrementLeft() { - this.left++; - updateDifference(); - } - - private void updateDifference() { - this.difference = this.joined - this.left; - } - - /** - * Проверить является ли статистика сегодняшней - */ - public boolean isToday() { - return this.date.equals(LocalDate.now().toString()); - } -} +package creator.loki.feature.counter; + +import java.time.LocalDate; + +/** + * Дневная статистика участников + */ +public class DailyStats { + private String date; + private int joined; + private int left; + private int difference; + + public DailyStats() { + this.date = LocalDate.now().toString(); + this.joined = 0; + this.left = 0; + this.difference = 0; + } + + public DailyStats(String date, int joined, int left) { + this.date = date; + this.joined = joined; + this.left = left; + this.difference = joined - left; + } + + // Getters + public String getDate() { return date; } + public int getJoined() { return joined; } + public int getLeft() { return left; } + public int getDifference() { return difference; } + + // Setters + public void setDate(String date) { this.date = date; } + public void setJoined(int joined) { + this.joined = joined; + updateDifference(); + } + public void setLeft(int left) { + this.left = left; + updateDifference(); + } + + // Методы + public void incrementJoined() { + this.joined++; + updateDifference(); + } + + public void incrementLeft() { + this.left++; + updateDifference(); + } + + private void updateDifference() { + this.difference = this.joined - this.left; + } + + /** + * Проверить является ли статистика сегодняшней + */ + public boolean isToday() { + return this.date.equals(LocalDate.now().toString()); + } +} diff --git a/src/main/java/creator/loki/feature/counter/MemberCounterListener.java b/src/main/java/creator/loki/feature/counter/MemberCounterListener.java index a12f38b..0b11087 100644 --- a/src/main/java/creator/loki/feature/counter/MemberCounterListener.java +++ b/src/main/java/creator/loki/feature/counter/MemberCounterListener.java @@ -1,86 +1,86 @@ -package creator.loki.feature.counter; - -import creator.loki.feature.counter.CounterManager; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; -import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * Listener для счетчика участников - */ -public class MemberCounterListener extends ListenerAdapter { - private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class); - private final CounterManager counterManager; - private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - private Guild guild; - - public MemberCounterListener(CounterManager counterManager) { - this.counterManager = counterManager; - } - - /** - * Запустить периодическое обновление счетчика - */ - public void startScheduler(Guild guild) { - this.guild = guild; - - // Обновляем счетчик каждые 5 минут - scheduler.scheduleAtFixedRate(() -> { - try { - counterManager.updateCounter(guild); - } catch (Exception e) { - logger.error("Ошибка при обновлении счетчика", e); - } - }, 0, 5, TimeUnit.MINUTES); - - logger.info("Запущен планировщик обновления счетчика участников"); - } - - @Override - public void onGuildMemberJoin(GuildMemberJoinEvent event) { - // Игнорируем ботов - if (event.getUser().isBot()) { - return; - } - - counterManager.recordJoin(); - counterManager.updateCounter(event.getGuild()); - - logger.info("Участник {} присоединился к серверу", event.getUser().getName()); - } - - @Override - public void onGuildMemberRemove(GuildMemberRemoveEvent event) { - // Игнорируем ботов - if (event.getUser().isBot()) { - return; - } - - counterManager.recordLeave(); - counterManager.updateCounter(event.getGuild()); - - logger.info("Участник {} покинул сервер", event.getUser().getName()); - } - - /** - * Остановить планировщик - */ - public void shutdown() { - scheduler.shutdown(); - try { - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - scheduler.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} +package creator.loki.feature.counter; + +import creator.loki.feature.counter.CounterManager; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; +import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Listener для счетчика участников + */ +public class MemberCounterListener extends ListenerAdapter { + private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class); + private final CounterManager counterManager; + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private Guild guild; + + public MemberCounterListener(CounterManager counterManager) { + this.counterManager = counterManager; + } + + /** + * Запустить периодическое обновление счетчика + */ + public void startScheduler(Guild guild) { + this.guild = guild; + + // Обновляем счетчик каждые 5 минут + scheduler.scheduleAtFixedRate(() -> Thread.startVirtualThread(() -> { + try { + counterManager.updateCounter(guild); + } catch (Exception e) { + logger.error("Ошибка при обновлении счетчика", e); + } + }), 0, 5, TimeUnit.MINUTES); + + logger.info("Запущен планировщик обновления счетчика участников"); + } + + @Override + public void onGuildMemberJoin(GuildMemberJoinEvent event) { + // Игнорируем ботов + if (event.getUser().isBot()) { + return; + } + + counterManager.recordJoin(); + counterManager.updateCounter(event.getGuild()); + + logger.info("Участник {} присоединился к серверу", event.getUser().getName()); + } + + @Override + public void onGuildMemberRemove(GuildMemberRemoveEvent event) { + // Игнорируем ботов + if (event.getUser().isBot()) { + return; + } + + counterManager.recordLeave(); + counterManager.updateCounter(event.getGuild()); + + logger.info("Участник {} покинул сервер", event.getUser().getName()); + } + + /** + * Остановить планировщик + */ + public void shutdown() { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/creator/loki/feature/counter/repository/CounterStatsRepository.java b/src/main/java/creator/loki/feature/counter/repository/CounterStatsRepository.java new file mode 100644 index 0000000..72bc5bd --- /dev/null +++ b/src/main/java/creator/loki/feature/counter/repository/CounterStatsRepository.java @@ -0,0 +1,137 @@ +package creator.loki.feature.counter.repository; + +import creator.loki.database.Database; +import creator.loki.feature.counter.DailyStats; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Репозиторий для работы со статистикой счетчика участников + */ +public class CounterStatsRepository { + private static final Logger logger = LoggerFactory.getLogger(CounterStatsRepository.class); + private final Database database; + + public CounterStatsRepository(Database database) { + this.database = database; + } + + /** + * Получить статистику за дату + */ + public DailyStats getStats(String date) { + String sql = "SELECT * FROM counter_stats WHERE date = ?"; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setString(1, date); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + DailyStats stats = new DailyStats(); + stats.setDate(rs.getString("date")); + stats.setJoined(rs.getInt("joined")); + stats.setLeft(rs.getInt("left_count")); + return stats; + } + } + } catch (SQLException e) { + logger.error("Ошибка получения статистики за {}", date, e); + } + + return null; + } + + /** + * Получить статистику за сегодня + */ + public DailyStats getTodayStats() { + String today = LocalDate.now().toString(); + DailyStats stats = getStats(today); + + if (stats == null) { + stats = new DailyStats(); + stats.setDate(today); + } + + return stats; + } + + /** + * Сохранить статистику + */ + public void saveStats(DailyStats stats) { + String sql = """ + INSERT INTO counter_stats (date, joined, left_count, difference) + VALUES (?, ?, ?, ?) + ON CONFLICT(date) + DO UPDATE SET + joined = excluded.joined, + left_count = excluded.left_count, + difference = excluded.difference + """; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setString(1, stats.getDate()); + stmt.setInt(2, stats.getJoined()); + stmt.setInt(3, stats.getLeft()); + stmt.setInt(4, stats.getDifference()); + stmt.executeUpdate(); + } catch (SQLException e) { + logger.error("Ошибка сохранения статистики за {}", stats.getDate(), e); + } + } + + /** + * Получить историю за последние N дней + */ + public List getHistory(int days) { + List history = new ArrayList<>(); + String sql = "SELECT * FROM counter_stats ORDER BY date DESC LIMIT ?"; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setInt(1, days); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + DailyStats stats = new DailyStats(); + stats.setDate(rs.getString("date")); + stats.setJoined(rs.getInt("joined")); + stats.setLeft(rs.getInt("left_count")); + history.add(stats); + } + } + } catch (SQLException e) { + logger.error("Ошибка получения истории", e); + } + + java.util.Collections.reverse(history); + return history; + } + + /** + * Удалить старую статистику (старше N дней) + */ + public void cleanOldStats(int daysToKeep) { + String sql = """ + DELETE FROM counter_stats + WHERE date < date('now', '-' || ? || ' days') + """; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setInt(1, daysToKeep); + int deleted = stmt.executeUpdate(); + if (deleted > 0) { + logger.info("Удалено {} старых записей статистики", deleted); + } + } catch (SQLException e) { + logger.error("Ошибка очистки старой статистики", e); + } + } +} diff --git a/src/main/java/creator/loki/feature/economy/EconomyCommandRegistrar.java b/src/main/java/creator/loki/feature/economy/EconomyCommandRegistrar.java new file mode 100644 index 0000000..e835db3 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/EconomyCommandRegistrar.java @@ -0,0 +1,63 @@ +package creator.loki.feature.economy; + +import com.google.inject.Inject; +import creator.loki.core.registry.CommandRegistrar; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.core.security.CooldownManager; +import creator.loki.feature.economy.command.bank.BankCommand; +import creator.loki.feature.economy.command.core.BalanceCommand; +import creator.loki.feature.economy.command.core.BaltopCommand; +import creator.loki.feature.economy.command.core.DailyCommand; +import creator.loki.feature.economy.command.core.PayCommand; +import creator.loki.feature.economy.command.minigame.BlackjackCommand; +import creator.loki.feature.economy.command.minigame.FishCommand; +import creator.loki.feature.economy.command.minigame.JobCommand; +import creator.loki.feature.economy.command.minigame.SlotCommand; +import creator.loki.feature.economy.command.minigame.WorkCommand; +import creator.loki.feature.economy.command.social.RobCommand; +import creator.loki.feature.economy.service.BlackjackService; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.feature.economy.service.FishingService; +import creator.loki.feature.economy.service.JobService; +import creator.loki.feature.economy.service.SlotService; + +public class EconomyCommandRegistrar implements CommandRegistrar { + private final EconomyService economyService; + private final FishingService fishingService; + private final BlackjackService blackjackService; + private final SlotService slotService; + private final JobService jobService; + private final CooldownManager cooldownManager; + + @Inject + public EconomyCommandRegistrar( + EconomyService economyService, + FishingService fishingService, + BlackjackService blackjackService, + SlotService slotService, + JobService jobService, + CooldownManager cooldownManager + ) { + this.economyService = economyService; + this.fishingService = fishingService; + this.blackjackService = blackjackService; + this.slotService = slotService; + this.jobService = jobService; + this.cooldownManager = cooldownManager; + } + + @Override + public void register(CommandRegistry registry) { + registry.register(new BalanceCommand(economyService)); + registry.register(new BaltopCommand(economyService)); + registry.register(new PayCommand(economyService)); + registry.register(new DailyCommand(economyService)); + registry.register(new BankCommand(economyService)); + registry.register(new RobCommand(economyService, cooldownManager)); + registry.register(new FishCommand(fishingService)); + registry.register(new BlackjackCommand(blackjackService, economyService)); + registry.register(new SlotCommand(slotService, economyService)); + registry.register(new JobCommand(jobService)); + registry.register(new WorkCommand(jobService)); + } +} diff --git a/src/main/java/creator/loki/feature/economy/EconomyModule.java b/src/main/java/creator/loki/feature/economy/EconomyModule.java new file mode 100644 index 0000000..830b766 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/EconomyModule.java @@ -0,0 +1,57 @@ +package creator.loki.feature.economy; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import creator.loki.core.security.CooldownManager; +import creator.loki.database.Database; +import creator.loki.feature.economy.repository.EconomyRepository; +import creator.loki.feature.economy.repository.SqlEconomyRepository; +import creator.loki.feature.economy.service.BlackjackService; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.feature.economy.service.FishingService; +import creator.loki.feature.economy.service.JobService; +import creator.loki.feature.economy.service.SlotService; + +public class EconomyModule extends AbstractModule { + + @Override + protected void configure() { + } + + @Provides + @Singleton + EconomyRepository provideEconomyRepository(Database database) { + return new SqlEconomyRepository(database); + } + + @Provides + @Singleton + EconomyService provideEconomyService(EconomyRepository repository, CooldownManager cooldownManager) { + return new EconomyService(repository, cooldownManager); + } + + @Provides + @Singleton + FishingService provideFishingService(EconomyRepository repository) { + return new FishingService(repository); + } + + @Provides + @Singleton + BlackjackService provideBlackjackService(EconomyRepository repository) { + return new BlackjackService(repository); + } + + @Provides + @Singleton + SlotService provideSlotService(EconomyRepository repository) { + return new SlotService(repository); + } + + @Provides + @Singleton + JobService provideJobService(EconomyRepository repository) { + return new JobService(repository); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java b/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java new file mode 100644 index 0000000..8adbf75 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java @@ -0,0 +1,93 @@ +package creator.loki.feature.economy.command.bank; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; +import java.util.List; + +public class BankCommand implements Command { + private final EconomyService economyService; + + public BankCommand(EconomyService economyService) { + this.economyService = economyService; + } + + @Override + public String getName() { return "bank"; } + + @Override + public String getDescription() { return "Управление банковским счётом"; } + + @Override + public String getUsage() { return "bank [deposit|withdraw|balance] "; } + + @Override + public String getCategory() { return "Экономика"; } + + @Override + public void execute(CommandContext ctx) { + List args = ctx.getArgs(); + String uid = ctx.getAuthor().getId(); + if (args.isEmpty() || args.get(0).equalsIgnoreCase("balance")) { + EconomyAccount acc = economyService.getAccount(uid); + ctx.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🏦 Банк — " + ctx.getAuthor().getEffectiveName()) + .withColor(new Color(52, 152, 219)) + .addField("Наличные", "**" + acc.getCash() + "** LC", true) + .addField("Банк", "**" + acc.getBank() + "** LC", true) + .addField("Всего", "**" + acc.getTotalBalance() + "** LC", true) + .build() + ).queue(); + return; + } + String sub = args.get(0).toLowerCase(); + if (!sub.equals("deposit") && !sub.equals("withdraw")) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Неизвестная подкоманда. Используй: " + getUsage()) + ).queue(); + return; + } + if (args.size() < 2) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажите сумму: bank " + sub + " ") + ).queue(); + return; + } + long amount; + try { + amount = Long.parseLong(args.get(1)); + } catch (NumberFormatException e) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Сумма должна быть числом!") + ).queue(); + return; + } + if (amount <= 0) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Сумма должна быть положительной!") + ).queue(); + return; + } + EconomyAccount acc = economyService.getAccount(uid); + boolean isDep = sub.equals("deposit"); + long balance = isDep ? acc.getCash() : acc.getBank(); + if (balance < amount) { + ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", + "Недостаточно " + (isDep ? "наличных" : "в банке") + "! У вас **" + balance + "** LC") + ).queue(); + return; + } + if (isDep) economyService.deposit(uid, amount); + else economyService.withdraw(uid, amount); + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.success(isDep ? "Депозит" : "Снятие", + "Ты " + (isDep ? "положил" : "снял") + " **" + amount + "** LC " + (isDep ? "в банк!" : "из банка!")) + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/core/BalanceCommand.java b/src/main/java/creator/loki/feature/economy/command/core/BalanceCommand.java new file mode 100644 index 0000000..9431a1b --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/core/BalanceCommand.java @@ -0,0 +1,57 @@ +package creator.loki.feature.economy.command.core; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; + +public class BalanceCommand implements Command { + private final EconomyService economyService; + + public BalanceCommand(EconomyService economyService) { + this.economyService = economyService; + } + + @Override + public String getName() { + return "balance"; + } + + @Override + public String getDescription() { + return "Показать баланс пользователя"; + } + + @Override + public String getUsage() { + return "balance [@user]"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + EconomyAccount account = economyService.getAccount(target.getId()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("💰 Баланс — " + target.getEffectiveName()) + .withColor(new Color(255, 215, 0)) + .addField("Наличные", "**" + account.getCash() + "** LC", true) + .addField("Банк", "**" + account.getBank() + "** LC", true) + .addField("Всего", "**" + account.getTotalBalance() + "** LC", true) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/core/BaltopCommand.java b/src/main/java/creator/loki/feature/economy/command/core/BaltopCommand.java new file mode 100644 index 0000000..1c5b19c --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/core/BaltopCommand.java @@ -0,0 +1,57 @@ +package creator.loki.feature.economy.command.core; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; +import java.util.List; + +public class BaltopCommand implements Command { + private final EconomyService economyService; + + public BaltopCommand(EconomyService economyService) { + this.economyService = economyService; + } + + @Override + public String getName() { + return "baltop"; + } + + @Override + public String getDescription() { + return "Топ 10 богатейших пользователей"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + List top = economyService.getTopBalances(10); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < top.size(); i++) { + EconomyAccount acc = top.get(i); + String name = context.getGuild().getMemberById(acc.getUserId()) != null + ? context.getGuild().getMemberById(acc.getUserId()).getEffectiveName() + : acc.getUserId(); + sb.append("**").append(i + 1).append(".** ") + .append(name) + .append(" — **").append(acc.getTotalBalance()).append("** LC\n"); + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🏆 Топ богатейших") + .withDescription(sb.toString()) + .withColor(new Color(255, 215, 0)) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/core/DailyCommand.java b/src/main/java/creator/loki/feature/economy/command/core/DailyCommand.java new file mode 100644 index 0000000..4de9517 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/core/DailyCommand.java @@ -0,0 +1,55 @@ +package creator.loki.feature.economy.command.core; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; + +public class DailyCommand implements Command { + private final EconomyService economyService; + + public DailyCommand(EconomyService economyService) { + this.economyService = economyService; + } + + @Override + public String getName() { + return "daily"; + } + + @Override + public String getDescription() { + return "Получить ежедневную награду"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + EconomyService.DailyResult result = economyService.claimDaily(context.getAuthor().getId()); + + if (result == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Ежедневная награда", + "Ты уже получил награду сегодня! Возвращайся завтра.") + ).queue(); + return; + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🎁 Ежедневная награда") + .withDescription("Ты получил **" + result.amount() + "** LC!") + .withColor(new Color(46, 204, 113)) + .addField("Стрик", "**" + result.streak() + "** дней", true) + .addField("Баланс", "**" + result.newCash() + "** LC", true) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/core/PayCommand.java b/src/main/java/creator/loki/feature/economy/command/core/PayCommand.java new file mode 100644 index 0000000..7f818db --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/core/PayCommand.java @@ -0,0 +1,96 @@ +package creator.loki.feature.economy.command.core; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Member; + +import java.util.List; + +public class PayCommand implements Command { + private final EconomyService economyService; + + public PayCommand(EconomyService economyService) { + this.economyService = economyService; + } + + @Override + public String getName() { + return "pay"; + } + + @Override + public String getDescription() { + return "Перевести деньги другому пользователю"; + } + + @Override + public String getUsage() { + return "pay <@user> "; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + List mentioned = context.getMessage().getMentions().getMembers(); + if (mentioned.isEmpty()) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажите пользователя: " + getUsage()) + ).queue(); + return; + } + + Member target = mentioned.get(0); + if (target.getId().equals(context.getAuthor().getId())) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Нельзя переводить деньги самому себе!") + ).queue(); + return; + } + + List args = context.getArgs(); + if (args.size() < 2) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажите сумму: " + getUsage()) + ).queue(); + return; + } + + long amount; + try { + amount = Long.parseLong(args.get(1)); + } catch (NumberFormatException e) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Сумма должна быть числом!") + ).queue(); + return; + } + + if (amount <= 0) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Сумма должна быть положительной!") + ).queue(); + return; + } + + var senderAccount = economyService.getAccount(context.getAuthor().getId()); + if (senderAccount.getCash() < amount) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Недостаточно средств! У вас **" + senderAccount.getCash() + "** LC") + ).queue(); + return; + } + + economyService.transfer(context.getAuthor().getId(), target.getId(), amount); + + context.getChannel().sendMessageEmbeds( + EmbedTemplates.success("Перевод выполнен", + context.getAuthor().getAsMention() + " перевёл **" + amount + "** LC " + target.getAsMention()) + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java new file mode 100644 index 0000000..27fd3e4 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java @@ -0,0 +1,148 @@ +package creator.loki.feature.economy.command.minigame; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.BlackjackService; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import net.dv8tion.jda.api.entities.MessageEmbed; + +import java.awt.Color; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class BlackjackCommand implements Command { + + private final BlackjackService blackjackService; + private final EconomyService economyService; + private final Set activeGames = ConcurrentHashMap.newKeySet(); + + public BlackjackCommand(BlackjackService blackjackService, EconomyService economyService) { + this.blackjackService = blackjackService; + this.economyService = economyService; + } + + @Override + public String getName() { + return "blackjack"; + } + + @Override + public String getDescription() { + return "Блэкджек — играй против дилера"; + } + + @Override + public String getUsage() { + return "bj <ставка> — начать игру\nbj hit — взять карту\nbj stand — остановиться"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + var args = context.getArgs(); + var userId = context.getAuthor().getId(); + if (args.isEmpty()) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Блэкджек", "Используй: `!bj <ставка>` чтобы начать") + ).queue(); + return; + } + var action = args.get(0).toLowerCase(); + if (action.equals("hit")) { + handleAction(context, userId, true); + } else if (action.equals("stand")) { + handleAction(context, userId, false); + } else { + handleStart(context, userId, action); + } + } + + private void handleStart(CommandContext context, String userId, String betStr) { + if (activeGames.contains(userId)) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "У тебя уже есть активная игра! Используй `!bj hit` или `!bj stand`") + ).queue(); + return; + } + long bet; + try { + bet = Long.parseLong(betStr); + if (bet <= 0) throw new NumberFormatException(); + } catch (NumberFormatException e) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажи положительное число в качестве ставки") + ).queue(); + return; + } + var account = economyService.getAccount(userId); + if (account.getCash() < bet) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Недостаточно средств! Твой баланс: " + account.getCash() + " LC") + ).queue(); + return; + } + BlackjackService.BlackjackState state = blackjackService.startGame(userId, bet); + if (state == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Не удалось начать игру.") + ).queue(); + return; + } + if (!state.isOver()) activeGames.add(userId); + context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue(); + } + + private void handleAction(CommandContext context, String userId, boolean isHit) { + if (!activeGames.contains(userId)) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Нет активной игры! Начни с `!bj <ставка>`") + ).queue(); + return; + } + BlackjackService.BlackjackState state = isHit + ? blackjackService.hit(userId) + : blackjackService.stand(userId); + if (state.isOver()) activeGames.remove(userId); + context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue(); + } + + private MessageEmbed buildGameEmbed(BlackjackService.BlackjackState state) { + var embed = EmbedFactory.create() + .withTitle("🃏 Блэкджек") + .withColor(new Color(34, 139, 34)) + .addField("Твои карты", formatCards(state.playerCards(), state.playerSum()), true); + if (state.isOver()) { + embed.addField("Карты дилера", formatCards(state.dealerCards(), state.dealerSum()), true) + .addField("Результат", formatResult(state), false); + } else { + embed.addField("Карты дилера", formatHiddenDealerCards(state.dealerCards()), true) + .withFooter("!bj hit — взять карту | !bj stand — остановиться"); + } + return embed.addField("Ставка", state.bet() + " LC", false).build(); + } + + private String formatCards(String[] cards, int sum) { + return String.join(" + ", cards) + " = " + sum; + } + + private String formatHiddenDealerCards(String[] cards) { + var sb = new StringBuilder("?"); + for (int i = 1; i < cards.length; i++) sb.append(" + ").append(cards[i]); + return sb.toString(); + } + + private String formatResult(BlackjackService.BlackjackState state) { + long payout = state.payout(); + if (payout > state.bet()) return "🎉 Блэкджек! +" + payout + " LC"; + if (payout > 0) return "✅ Ты выиграл +" + payout + " LC"; + if (payout == state.bet()) return "🤝 Ничья. Возврат " + payout + " LC"; + return "💔 Ты проиграл " + state.bet() + " LC"; + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/FishCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/FishCommand.java new file mode 100644 index 0000000..9ee6237 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/FishCommand.java @@ -0,0 +1,106 @@ +package creator.loki.feature.economy.command.minigame; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.FishingService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; +import java.util.concurrent.ConcurrentHashMap; + +public class FishCommand implements Command { + + private static record FishSession(String difficulty, long startTime) {} + + private final FishingService fishingService; + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + public FishCommand(FishingService fishingService) { + this.fishingService = fishingService; + } + + @Override + public String getName() { + return "fish"; + } + + @Override + public String getDescription() { + return "Рыбалка — угадай количество рыбы в пруду"; + } + + @Override + public String getUsage() { + return "fish — начать рыбалку\nfish <число> — ответить"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + var args = context.getArgs(); + var userId = context.getAuthor().getId(); + + if (args.isEmpty()) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Рыбалка", "Используй: `!fish easy/normal/hard`") + ).queue(); + return; + } + + var first = args.get(0).toLowerCase(); + + if (first.equals("easy") || first.equals("normal") || first.equals("hard")) { + FishingService.FishResult result = fishingService.startFishing(userId, first); + sessions.put(userId, new FishSession(first, System.currentTimeMillis())); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🎣 Рыбалка") + .withDescription("```\n" + String.join("\n", result.grid()) + "\n```") + .withColor(new Color(0, 150, 255)) + .addField("Вопрос", "Сколько **" + result.targetFish() + "** ты видишь?", false) + .withFooter("Напиши !fish <число>, чтобы ответить") + .build() + ).queue(); + return; + } + + try { + int answer = Integer.parseInt(first); + FishSession session = sessions.remove(userId); + if (session == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Нет активной рыбалки! Начни с `!fish easy/normal/hard`") + ).queue(); + return; + } + + FishingService.FishResult result = fishingService.checkAnswer(userId, answer, session.startTime(), session.difficulty()); + var embed = EmbedFactory.create() + .withTitle("🎣 Рыбалка — Результат") + .withDescription("```\n" + String.join("\n", result.grid()) + "\n```") + .withColor(new Color(255, 215, 0)); + + if (result.reward() > 0) { + embed.addField("Твой ответ", String.valueOf(answer), true) + .addField("Правильный ответ", String.valueOf(result.count()), true) + .addField("Награда", "+" + result.reward() + " LC", true) + .addField("Новый баланс", result.cashAfter() + " LC", true); + } else { + embed.withDescription("❌ Неверно! Правильный ответ: **" + result.count() + "**\n\n```\n" + + String.join("\n", result.grid()) + "\n```"); + } + + context.getChannel().sendMessageEmbeds(embed.build()).queue(); + } catch (NumberFormatException e) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Неверный аргумент. Используй `!fish easy/normal/hard` или `!fish <число>`") + ).queue(); + } + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/JobCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/JobCommand.java new file mode 100644 index 0000000..257ecb6 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/JobCommand.java @@ -0,0 +1,146 @@ +package creator.loki.feature.economy.command.minigame; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.JobService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; + +public class JobCommand implements Command { + + private final JobService jobService; + + public JobCommand(JobService jobService) { + this.jobService = jobService; + } + + @Override + public String getName() { + return "job"; + } + + @Override + public String getDescription() { + return "Управление работой — список, присоединиться"; + } + + @Override + public String getUsage() { + return "job — текущая работа\njob list — список работ\njob join <название> — устроиться"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + var args = context.getArgs(); + var userId = context.getAuthor().getId(); + + if (args.isEmpty()) { + handleCurrent(context, userId); + } else if (args.get(0).equalsIgnoreCase("list")) { + handleList(context); + } else if (args.get(0).equalsIgnoreCase("join")) { + if (args.size() < 2) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажи название работы: `!job join <название>`") + ).queue(); + return; + } + handleJoin(context, userId, args.get(1)); + } else { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Работа", "Используй: `!job`, `!job list`, `!job join <название>`") + ).queue(); + } + } + + private void handleCurrent(CommandContext context, String userId) { + String current = jobService.getCurrentJob(userId); + if (current == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.info("Работа", "У тебя нет работы. Используй `!job list` чтобы посмотреть доступные.") + ).queue(); + return; + } + + JobService.JobInfo info = findJob(current); + if (info == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Работа", "Текущая работа: **" + current + "**") + ).queue(); + return; + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("💼 Твоя работа") + .withColor(new Color(100, 200, 255)) + .addField("Должность", info.emoji() + " " + info.displayName(), false) + .addField("Зарплата", info.minPayout() + " – " + info.maxPayout() + " LC", true) + .addField("Требуемый уровень", String.valueOf(info.requiredLevel()), true) + .build() + ).queue(); + } + + private void handleList(CommandContext context) { + var jobs = jobService.listJobs(); + var desc = new StringBuilder(); + + for (var job : jobs) { + desc.append(job.emoji()).append(" **").append(job.displayName()) + .append("** — ").append(job.minPayout()).append("–").append(job.maxPayout()) + .append(" LC (ур. ").append(job.requiredLevel()).append(")\n"); + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("📋 Доступные работы") + .withDescription(desc.toString()) + .withColor(new Color(100, 200, 255)) + .withFooter("!job join <название> — устроиться на работу") + .build() + ).queue(); + } + + private void handleJoin(CommandContext context, String userId, String name) { + var jobs = jobService.listJobs(); + JobService.JobInfo target = null; + + for (var job : jobs) { + if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { + target = job; + break; + } + } + + if (target == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Работа **" + name + "** не найдена. Используй `!job list`") + ).queue(); + return; + } + + jobService.setJob(userId, target.name()); + + context.getChannel().sendMessageEmbeds( + EmbedTemplates.success("Устройство на работу", + "Ты устроился на работу **" + target.emoji() + " " + target.displayName() + "**!\n" + + "Используй `!work` чтобы начать работать.") + ).queue(); + } + + private JobService.JobInfo findJob(String name) { + for (var job : jobService.listJobs()) { + if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { + return job; + } + } + return null; + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/SlotCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/SlotCommand.java new file mode 100644 index 0000000..8581e16 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/SlotCommand.java @@ -0,0 +1,96 @@ +package creator.loki.feature.economy.command.minigame; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.feature.economy.service.SlotService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; + +public class SlotCommand implements Command { + + private final SlotService slotService; + private final EconomyService economyService; + + public SlotCommand(SlotService slotService, EconomyService economyService) { + this.slotService = slotService; + this.economyService = economyService; + } + + @Override + public String getName() { + return "slot"; + } + + @Override + public String getDescription() { + return "Крути слот-машину и выигрывай"; + } + + @Override + public String getUsage() { + return "slot <ставка>"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + var args = context.getArgs(); + var userId = context.getAuthor().getId(); + + if (args.isEmpty()) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Слоты", "Используй: `!slot <ставка>`") + ).queue(); + return; + } + + long bet; + try { + bet = Long.parseLong(args.get(0)); + if (bet <= 0) throw new NumberFormatException(); + } catch (NumberFormatException e) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Ставка должна быть положительным числом!") + ).queue(); + return; + } + + var account = economyService.getAccount(userId); + if (account.getCash() < bet) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Недостаточно средств! Твой баланс: " + account.getCash() + " LC") + ).queue(); + return; + } + + SlotService.SlotResult result = slotService.spin(userId, bet); + + var comboDisplay = result.emoji1() + " | " + result.emoji2() + " | " + result.emoji3(); + var desc = new StringBuilder("**" + comboDisplay + "**\n"); + + if (result.multiplier() > 0) { + desc.append("\n🎰 **").append(result.comboName()).append("!**") + .append(" x").append(result.multiplier()) + .append("\n\n+**").append(result.winAmount()).append("** LC"); + } else { + desc.append("\n\n😔 Повезёт в следующий раз!"); + } + + desc.append("\n\nБаланс: **").append(result.cashAfter()).append("** LC"); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🎰 Слот-машина") + .withDescription(desc.toString()) + .withColor(new Color(255, 215, 0)) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/WorkCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/WorkCommand.java new file mode 100644 index 0000000..a9894c0 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/WorkCommand.java @@ -0,0 +1,78 @@ +package creator.loki.feature.economy.command.minigame; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.economy.service.JobService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; + +import java.awt.Color; + +public class WorkCommand implements Command { + + private final JobService jobService; + + public WorkCommand(JobService jobService) { + this.jobService = jobService; + } + + @Override + public String getName() { + return "work"; + } + + @Override + public String getDescription() { + return "Работать и зарабатывать деньги"; + } + + @Override + public String getCategory() { + return "Экономика"; + } + + @Override + public void execute(CommandContext context) { + var userId = context.getAuthor().getId(); + + String currentJob = jobService.getCurrentJob(userId); + if (currentJob == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.warning("Работа", "У тебя нет работы! Используй `!job list` чтобы выбрать.") + ).queue(); + return; + } + + JobService.JobInfo info = findJob(currentJob); + JobService.WorkResult result = jobService.work(userId, 0); + + if (result == null) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "⏳ Ты уже работал сегодня! Отдохни и возвращайся завтра.") + ).queue(); + return; + } + + String emoji = info != null ? info.emoji() : "💼"; + String jobName = info != null ? info.displayName() : currentJob; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("💼 Работа завершена") + .withColor(new Color(100, 200, 255)) + .addField("Должность", emoji + " " + jobName, false) + .addField("Заработано", "+**" + result.amount() + "** LC", true) + .addField("Новый баланс", "**" + result.newCash() + "** LC", true) + .build() + ).queue(); + } + + private JobService.JobInfo findJob(String name) { + for (var job : jobService.listJobs()) { + if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { + return job; + } + } + return null; + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/social/RobCommand.java b/src/main/java/creator/loki/feature/economy/command/social/RobCommand.java new file mode 100644 index 0000000..f77ce0f --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/social/RobCommand.java @@ -0,0 +1,99 @@ +package creator.loki.feature.economy.command.social; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.core.security.CooldownManager; +import creator.loki.feature.economy.service.EconomyService; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +public class RobCommand implements Command { + private final EconomyService economyService; + private final CooldownManager cooldownManager; + private final Random random; + + public RobCommand(EconomyService economyService, CooldownManager cooldownManager) { + this.economyService = economyService; + this.cooldownManager = cooldownManager; + this.random = ThreadLocalRandom.current(); + } + + @Override + public String getName() { return "rob"; } + + @Override + public String getDescription() { return "Ограбить пользователя"; } + + @Override + public String getUsage() { return "rob <@user>"; } + + @Override + public String getCategory() { return "Экономика"; } + + @Override + public void execute(CommandContext ctx) { + List mentioned = ctx.getMessage().getMentions().getMembers(); + if (mentioned.isEmpty()) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Укажите пользователя: " + getUsage()) + ).queue(); + return; + } + Member target = mentioned.get(0); + String uid = ctx.getAuthor().getId(); + String tid = target.getId(); + if (tid.equals(uid)) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Нельзя ограбить самого себя!") + ).queue(); + return; + } + if (cooldownManager.isOnCooldown(uid, "rob")) { + long rem = cooldownManager.getRemainingSeconds(uid, "rob"); + ctx.getChannel().sendMessageEmbeds(EmbedTemplates.warning("Кдн", + "Подожди ещё **" + (rem / 3600) + "ч " + ((rem % 3600) / 60) + "м**") + ).queue(); + return; + } + cooldownManager.applyCooldown(uid, "rob", 86400); + var sender = economyService.getAccount(uid); + var victim = economyService.getAccount(tid); + if (sender.getCash() <= 0 || victim.getCash() <= 0) { + ctx.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "У одного из вас нет наличных!") + ).queue(); + return; + } + if (random.nextDouble() < 0.4) { + long steal = Math.min(Math.min((long) (victim.getCash() * 0.15), 10000L), victim.getCash()); + if (steal <= 0) { + ctx.getChannel().sendMessageEmbeds(EmbedTemplates.info("Ограбление", "Слишком мелкая цель...")).queue(); + return; + } + economyService.transfer(tid, uid, steal); + ctx.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🔫 Ограбление удалось!") + .withDescription("Ты украл **" + steal + "** LC у " + target.getAsMention()) + .withColor(new Color(46, 204, 113)) + .build() + ).queue(); + } else { + long penalty = Math.min(Math.min((long) (sender.getCash() * 0.10), 5000L), sender.getCash()); + if (penalty > 0) economyService.transfer(uid, tid, penalty); + ctx.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("👮 Ограбление провалено!") + .withDescription("Ты заплатил **" + penalty + "** LC " + target.getAsMention()) + .withColor(new Color(231, 76, 60)) + .build() + ).queue(); + } + } +} diff --git a/src/main/java/creator/loki/feature/economy/domain/EconomyAccount.java b/src/main/java/creator/loki/feature/economy/domain/EconomyAccount.java new file mode 100644 index 0000000..b13767e --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/domain/EconomyAccount.java @@ -0,0 +1,151 @@ +package creator.loki.feature.economy.domain; + +import java.util.Objects; + +public class EconomyAccount { + private final String userId; + private final long cash; + private final long bank; + private final long totalEarned; + private final long totalSpent; + private final String lastDaily; + private final int dailyStreak; + private final int fishingLuck; + private final int efficiency; + private final String lastFish; + private final String job; + private final String lastWork; + + public EconomyAccount(String userId) { + this.userId = userId; + this.cash = 0; + this.bank = 0; + this.totalEarned = 0; + this.totalSpent = 0; + this.lastDaily = null; + this.dailyStreak = 0; + this.fishingLuck = 0; + this.efficiency = 0; + this.lastFish = null; + this.job = null; + this.lastWork = null; + } + + private EconomyAccount(Builder builder) { + this.userId = builder.userId; + this.cash = builder.cash; + this.bank = builder.bank; + this.totalEarned = builder.totalEarned; + this.totalSpent = builder.totalSpent; + this.lastDaily = builder.lastDaily; + this.dailyStreak = builder.dailyStreak; + this.fishingLuck = builder.fishingLuck; + this.efficiency = builder.efficiency; + this.lastFish = builder.lastFish; + this.job = builder.job; + this.lastWork = builder.lastWork; + } + + public String getUserId() { return userId; } + public long getCash() { return cash; } + public long getBank() { return bank; } + public long getTotalEarned() { return totalEarned; } + public long getTotalSpent() { return totalSpent; } + public String getLastDaily() { return lastDaily; } + public int getDailyStreak() { return dailyStreak; } + public int getFishingLuck() { return fishingLuck; } + public int getEfficiency() { return efficiency; } + public String getLastFish() { return lastFish; } + public String getJob() { return job; } + public String getLastWork() { return lastWork; } + + public long getTotalBalance() { return cash + bank; } + + public EconomyAccount withCash(long cash) { + return new Builder(this).cash(cash).build(); + } + + public EconomyAccount withBank(long bank) { + return new Builder(this).bank(bank).build(); + } + + public EconomyAccount withAddedEarned(long amount) { + return new Builder(this).totalEarned(this.totalEarned + amount).build(); + } + + public EconomyAccount withAddedSpent(long amount) { + return new Builder(this).totalSpent(this.totalSpent + amount).build(); + } + + public EconomyAccount withFishingLuck(int level) { + return new Builder(this).fishingLuck(level).build(); + } + + public EconomyAccount withEfficiency(int level) { + return new Builder(this).efficiency(level).build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EconomyAccount that = (EconomyAccount) o; + return Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { return Objects.hash(userId); } + + @Override + public String toString() { + return String.format("EconomyAccount[%s: Cash %d, Bank %d]", + userId, cash, bank); + } + + public static class Builder { + private String userId; + private long cash; + private long bank; + private long totalEarned; + private long totalSpent; + private String lastDaily; + private int dailyStreak; + private int fishingLuck; + private int efficiency; + private String lastFish; + private String job; + private String lastWork; + + public Builder() {} + + public Builder(EconomyAccount existing) { + this.userId = existing.userId; + this.cash = existing.cash; + this.bank = existing.bank; + this.totalEarned = existing.totalEarned; + this.totalSpent = existing.totalSpent; + this.lastDaily = existing.lastDaily; + this.dailyStreak = existing.dailyStreak; + this.fishingLuck = existing.fishingLuck; + this.efficiency = existing.efficiency; + this.lastFish = existing.lastFish; + this.job = existing.job; + this.lastWork = existing.lastWork; + } + + public Builder userId(String userId) { this.userId = userId; return this; } + public Builder cash(long cash) { this.cash = cash; return this; } + public Builder bank(long bank) { this.bank = bank; return this; } + public Builder totalEarned(long totalEarned) { this.totalEarned = totalEarned; return this; } + public Builder totalSpent(long totalSpent) { this.totalSpent = totalSpent; return this; } + public Builder lastDaily(String lastDaily) { this.lastDaily = lastDaily; return this; } + public Builder dailyStreak(int dailyStreak) { this.dailyStreak = dailyStreak; return this; } + public Builder fishingLuck(int fishingLuck) { this.fishingLuck = fishingLuck; return this; } + public Builder efficiency(int efficiency) { this.efficiency = efficiency; return this; } + public Builder lastFish(String lastFish) { this.lastFish = lastFish; return this; } + public Builder job(String job) { this.job = job; return this; } + public Builder lastWork(String lastWork) { this.lastWork = lastWork; return this; } + + public EconomyAccount build() { return new EconomyAccount(this); } + } +} diff --git a/src/main/java/creator/loki/feature/economy/domain/EconomyTransaction.java b/src/main/java/creator/loki/feature/economy/domain/EconomyTransaction.java new file mode 100644 index 0000000..b13b1a3 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/domain/EconomyTransaction.java @@ -0,0 +1,9 @@ +package creator.loki.feature.economy.domain; + +public record EconomyTransaction( + String fromId, + String toId, + long amount, + String type, + long timestamp +) {} diff --git a/src/main/java/creator/loki/feature/economy/domain/Job.java b/src/main/java/creator/loki/feature/economy/domain/Job.java new file mode 100644 index 0000000..a80a41c --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/domain/Job.java @@ -0,0 +1,39 @@ +package creator.loki.feature.economy.domain; + +public enum Job { + WOODCUTTER("Лесоруб", 0, 15, 30, "\uD83E\uDE93"), + MINER("Шахтёр", 0, 20, 40, "\u26CF\uFE0F"), + FISHERMAN("Рыбак", 0, 25, 50, "\uD83C\uDFA3"), + COOK("Повар", 15, 30, 60, "\uD83C\uDF73"), + PROGRAMMER("Программист", 20, 40, 80, "\uD83D\uDCBB"), + ASTRONAUT("Космонавт", 35, 50, 100, "\uD83E\uDEB8"); + + private final String displayName; + private final int requiredLevel; + private final int minPayout; + private final int maxPayout; + private final String emoji; + + Job(String displayName, int requiredLevel, int minPayout, int maxPayout, String emoji) { + this.displayName = displayName; + this.requiredLevel = requiredLevel; + this.minPayout = minPayout; + this.maxPayout = maxPayout; + this.emoji = emoji; + } + + public String getDisplayName() { return displayName; } + public int getRequiredLevel() { return requiredLevel; } + public int getMinPayout() { return minPayout; } + public int getMaxPayout() { return maxPayout; } + public String getEmoji() { return emoji; } + + public static Job fromName(String name) { + for (Job j : values()) { + if (j.name().equalsIgnoreCase(name) || j.getDisplayName().equalsIgnoreCase(name)) { + return j; + } + } + return null; + } +} diff --git a/src/main/java/creator/loki/feature/economy/domain/SlotCombination.java b/src/main/java/creator/loki/feature/economy/domain/SlotCombination.java new file mode 100644 index 0000000..d5ce924 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/domain/SlotCombination.java @@ -0,0 +1,31 @@ +package creator.loki.feature.economy.domain; + +public enum SlotCombination { + CHERRY(new String[]{"\uD83C\uDF52", "\uD83C\uDF52", "\uD83C\uDF52"}, 3, 10.0), + BELL(new String[]{"\uD83D\uDD14", "\uD83D\uDD14", "\uD83D\uDD14"}, 5, 5.0), + DIAMOND(new String[]{"\uD83D\uDC8E", "\uD83D\uDC8E", "\uD83D\uDC8E"}, 10, 2.0), + CROWN(new String[]{"\uD83D\uDC51", "\uD83D\uDC51", "\uD83D\uDC51"}, 15, 1.0), + JACKPOT(new String[]{"\u26A1", "\u26A1", "\u26A1"}, 50, 0.1); + + private final String[] emojis; + private final int multiplier; + private final double chance; + + SlotCombination(String[] emojis, int multiplier, double chance) { + this.emojis = emojis; + this.multiplier = multiplier; + this.chance = chance; + } + + public String[] getEmojis() { return emojis; } + public int getMultiplier() { return multiplier; } + public double getChance() { return chance; } + + public static SlotCombination match(String e1, String e2, String e3) { + if (!e1.equals(e2) || !e2.equals(e3)) return null; + for (SlotCombination sc : values()) { + if (sc.emojis[0].equals(e1)) return sc; + } + return null; + } +} diff --git a/src/main/java/creator/loki/feature/economy/repository/EconomyRepository.java b/src/main/java/creator/loki/feature/economy/repository/EconomyRepository.java new file mode 100644 index 0000000..6a3f2fa --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/repository/EconomyRepository.java @@ -0,0 +1,17 @@ +package creator.loki.feature.economy.repository; + +import creator.loki.feature.economy.domain.EconomyAccount; + +import java.util.List; +import java.util.Optional; + +public interface EconomyRepository { + Optional findById(String userId); + void save(EconomyAccount account); + boolean exists(String userId); + List findTopByBalance(int limit); + + default EconomyAccount getAccount(String userId) { + return findById(userId).orElse(new EconomyAccount(userId)); + } +} diff --git a/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java b/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java new file mode 100644 index 0000000..501cc41 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java @@ -0,0 +1,165 @@ +package creator.loki.feature.economy.repository; + +import creator.loki.database.Database; +import creator.loki.feature.economy.domain.EconomyAccount; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public class SqlEconomyRepository implements EconomyRepository { + private static final Logger logger = LoggerFactory.getLogger(SqlEconomyRepository.class); + private static final int MAX_TOP = 100; + private final Database database; + + public SqlEconomyRepository(Database database) { + this.database = database; + } + + @Override + public Optional findById(String userId) { + String sql = """ + SELECT user_id, cash, bank, total_earned, total_spent, + last_daily, daily_streak, fishing_luck, efficiency, + last_fish, job, last_work + FROM economy + WHERE user_id = ? + """; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setString(1, userId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(mapResultSetToAccount(rs)); + } + } + } catch (SQLException e) { + logger.error("Error fetching economy account for {}", userId, e); + } + return Optional.empty(); + } + + @Override + public void save(EconomyAccount account) { + String sql = """ + INSERT INTO economy (user_id, cash, bank, total_earned, total_spent, + last_daily, daily_streak, fishing_luck, efficiency, + last_fish, job, last_work) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) + DO UPDATE SET + cash = excluded.cash, + bank = excluded.bank, + total_earned = excluded.total_earned, + total_spent = excluded.total_spent, + last_daily = excluded.last_daily, + daily_streak = excluded.daily_streak, + fishing_luck = excluded.fishing_luck, + efficiency = excluded.efficiency, + last_fish = excluded.last_fish, + job = excluded.job, + last_work = excluded.last_work + """; + + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setString(1, account.getUserId()); + stmt.setLong(2, account.getCash()); + stmt.setLong(3, account.getBank()); + stmt.setLong(4, account.getTotalEarned()); + stmt.setLong(5, account.getTotalSpent()); + if (account.getLastDaily() != null) { + stmt.setString(6, account.getLastDaily()); + } else { + stmt.setNull(6, java.sql.Types.VARCHAR); + } + stmt.setInt(7, account.getDailyStreak()); + stmt.setInt(8, account.getFishingLuck()); + stmt.setInt(9, account.getEfficiency()); + if (account.getLastFish() != null) { + stmt.setString(10, account.getLastFish()); + } else { + stmt.setNull(10, java.sql.Types.VARCHAR); + } + if (account.getJob() != null) { + stmt.setString(11, account.getJob()); + } else { + stmt.setNull(11, java.sql.Types.VARCHAR); + } + if (account.getLastWork() != null) { + stmt.setString(12, account.getLastWork()); + } else { + stmt.setNull(12, java.sql.Types.VARCHAR); + } + stmt.executeUpdate(); + } catch (SQLException e) { + logger.error("Error saving economy account for {}", account.getUserId(), e); + } + } + + @Override + public boolean exists(String userId) { + String sql = "SELECT 1 FROM economy WHERE user_id = ?"; + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setString(1, userId); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); + } + } catch (SQLException e) { + logger.error("Error checking economy existence for {}", userId, e); + } + return false; + } + + @Override + public List findTopByBalance(int limit) { + int lim = clampLimit(limit); + String sql = """ + SELECT user_id, cash, bank, total_earned, total_spent, + last_daily, daily_streak, fishing_luck, efficiency, + last_fish, job, last_work + FROM economy + ORDER BY (cash + bank) DESC, cash DESC, user_id ASC + LIMIT ? + """; + List list = new ArrayList<>(); + try (PreparedStatement stmt = database.prepare(sql)) { + stmt.setInt(1, lim); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + list.add(mapResultSetToAccount(rs)); + } + } + } catch (SQLException e) { + logger.error("Error fetching top by balance", e); + } + return Collections.unmodifiableList(list); + } + + private static int clampLimit(int limit) { + if (limit <= 0) return 1; + return Math.min(limit, MAX_TOP); + } + + private EconomyAccount mapResultSetToAccount(ResultSet rs) throws SQLException { + return new EconomyAccount.Builder() + .userId(rs.getString("user_id")) + .cash(rs.getLong("cash")) + .bank(rs.getLong("bank")) + .totalEarned(rs.getLong("total_earned")) + .totalSpent(rs.getLong("total_spent")) + .lastDaily(rs.getString("last_daily")) + .dailyStreak(rs.getInt("daily_streak")) + .fishingLuck(rs.getInt("fishing_luck")) + .efficiency(rs.getInt("efficiency")) + .lastFish(rs.getString("last_fish")) + .job(rs.getString("job")) + .lastWork(rs.getString("last_work")) + .build(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/service/BlackjackService.java b/src/main/java/creator/loki/feature/economy/service/BlackjackService.java new file mode 100644 index 0000000..69f32d5 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/BlackjackService.java @@ -0,0 +1,139 @@ +package creator.loki.feature.economy.service; + +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.repository.EconomyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +public class BlackjackService { + + private static final Logger logger = LoggerFactory.getLogger(BlackjackService.class); + + private static final String[] DECK = {"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"}; + + private final EconomyRepository repository; + private final ConcurrentHashMap activeGames = new ConcurrentHashMap<>(); + + public BlackjackService(EconomyRepository repository) { + this.repository = repository; + } + + private record ActiveBlackjack(List playerCards, List dealerCards, long bet, String userId) {} + + public BlackjackState startGame(String userId, long bet) { + if (bet <= 0 || activeGames.containsKey(userId)) return null; + EconomyAccount account = repository.getAccount(userId); + if (account.getCash() < bet) return null; + + repository.save(account.withCash(account.getCash() - bet)); + + List playerCards = new ArrayList<>(List.of(drawCard(), drawCard())); + List dealerCards = new ArrayList<>(List.of(drawCard(), drawCard())); + + int playerSum = calculateSum(playerCards); + int dealerSum = calculateSum(dealerCards); + boolean playerBj = playerSum == 21; + boolean dealerBj = dealerSum == 21; + + if (playerBj || dealerBj) { + long payout; + if (playerBj && !dealerBj) { + payout = (long) (bet * 2.5); + } else { + payout = 0; + } + repository.save(repository.getAccount(userId).withCash(repository.getAccount(userId).getCash() + payout)); + return new BlackjackState( + playerCards.toArray(String[]::new), dealerCards.toArray(String[]::new), + playerSum, dealerSum, bet, true, payout + ); + } + + ActiveBlackjack game = new ActiveBlackjack(playerCards, dealerCards, bet, userId); + activeGames.put(userId, game); + return new BlackjackState( + playerCards.toArray(String[]::new), dealerCards.toArray(String[]::new), + playerSum, dealerSum, bet, false, 0 + ); + } + + public BlackjackState hit(String userId) { + ActiveBlackjack game = activeGames.get(userId); + if (game == null) return null; + + game.playerCards().add(drawCard()); + int sum = calculateSum(game.playerCards()); + + if (sum > 21) { + activeGames.remove(userId); + return new BlackjackState( + game.playerCards().toArray(String[]::new), game.dealerCards().toArray(String[]::new), + sum, calculateSum(game.dealerCards()), game.bet(), true, 0 + ); + } + + return new BlackjackState( + game.playerCards().toArray(String[]::new), game.dealerCards().toArray(String[]::new), + sum, calculateSum(game.dealerCards()), game.bet(), false, 0 + ); + } + + public BlackjackState stand(String userId) { + ActiveBlackjack game = activeGames.remove(userId); + if (game == null) return null; + + List dealerCards = new ArrayList<>(game.dealerCards()); + while (calculateSum(dealerCards) < 16) { + dealerCards.add(drawCard()); + } + + int playerSum = calculateSum(game.playerCards()); + int dealerSum = calculateSum(dealerCards); + long payout; + + if (dealerSum > 21 || playerSum > dealerSum) { + payout = game.bet() * 2; + } else { + payout = 0; + } + + EconomyAccount account = repository.getAccount(userId); + repository.save(account.withCash(account.getCash() + payout)); + + return new BlackjackState( + game.playerCards().toArray(String[]::new), dealerCards.toArray(String[]::new), + playerSum, dealerSum, game.bet(), true, payout + ); + } + + private String drawCard() { + return DECK[ThreadLocalRandom.current().nextInt(DECK.length)]; + } + + private int calculateSum(List cards) { + int sum = 0; + int aces = 0; + for (String c : cards) { + switch (c) { + case "J", "Q", "K" -> sum += 10; + case "A" -> { sum += 11; aces++; } + default -> sum += Integer.parseInt(c); + } + } + while (sum > 21 && aces > 0) { + sum -= 10; + aces--; + } + return sum; + } + + public record BlackjackState(String[] playerCards, String[] dealerCards, int playerSum, int dealerSum, long bet, boolean isOver, long payout) {} + + public record BlackjackResult(boolean playerWon, long payout, String reason) {} + +} diff --git a/src/main/java/creator/loki/feature/economy/service/EconomyService.java b/src/main/java/creator/loki/feature/economy/service/EconomyService.java new file mode 100644 index 0000000..020ac09 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/EconomyService.java @@ -0,0 +1,118 @@ +package creator.loki.feature.economy.service; + +import creator.loki.core.security.CooldownManager; +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.repository.EconomyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; + +public class EconomyService { + + private static final Logger logger = LoggerFactory.getLogger(EconomyService.class); + + private final EconomyRepository repository; + private final CooldownManager cooldownManager; + + public EconomyService(EconomyRepository repository, CooldownManager cooldownManager) { + this.repository = repository; + this.cooldownManager = cooldownManager; + } + + public EconomyAccount getAccount(String userId) { + return repository.getAccount(userId); + } + + public boolean transfer(String fromId, String toId, long amount) { + if (amount <= 0) return false; + EconomyAccount from = repository.getAccount(fromId); + if (from.getCash() < amount) return false; + EconomyAccount to = repository.getAccount(toId); + repository.save(from.withCash(from.getCash() - amount)); + repository.save(to.withCash(to.getCash() + amount)); + return true; + } + + public boolean deposit(String userId, long amount) { + if (amount <= 0) return false; + EconomyAccount account = repository.getAccount(userId); + if (account.getCash() < amount) return false; + repository.save(account.withCash(account.getCash() - amount).withBank(account.getBank() + amount)); + return true; + } + + public boolean withdraw(String userId, long amount) { + if (amount <= 0) return false; + EconomyAccount account = repository.getAccount(userId); + if (account.getBank() < amount) return false; + repository.save(account.withBank(account.getBank() - amount).withCash(account.getCash() + amount)); + return true; + } + + public long earnCash(String userId, long amount) { + EconomyAccount account = repository.getAccount(userId); + EconomyAccount updated = account.withCash(account.getCash() + amount).withAddedEarned(amount); + repository.save(updated); + return updated.getCash(); + } + + public boolean spendCash(String userId, long amount) { + if (amount <= 0) return false; + EconomyAccount account = repository.getAccount(userId); + if (account.getCash() < amount) return false; + repository.save(account.withCash(account.getCash() - amount).withAddedSpent(amount)); + return true; + } + + public DailyResult claimDaily(String userId) { + EconomyAccount account = repository.getAccount(userId); + String today = LocalDate.now().toString(); + if (today.equals(account.getLastDaily())) return null; + String yesterday = LocalDate.now().minusDays(1).toString(); + int streak; + if (yesterday.equals(account.getLastDaily())) { + streak = account.getDailyStreak() + 1; + } else { + streak = 1; + } + long amount = streak >= 7 ? (long) (50 * 1.5) : 50; + long newCash = account.getCash() + amount; + EconomyAccount updated = new EconomyAccount.Builder(account) + .cash(newCash) + .totalEarned(account.getTotalEarned() + amount) + .lastDaily(today) + .dailyStreak(streak) + .build(); + repository.save(updated); + return new DailyResult(amount, streak, newCash); + } + + public int upgradeFishingLuck(String userId) { + EconomyAccount account = repository.getAccount(userId); + if (account.getFishingLuck() >= 5) return -1; + long cost = 800L * (account.getFishingLuck() + 1); + if (account.getCash() < cost) return -1; + int newLevel = account.getFishingLuck() + 1; + repository.save(account.withCash(account.getCash() - cost).withFishingLuck(newLevel).withAddedSpent(cost)); + return newLevel; + } + + public int upgradeEfficiency(String userId) { + EconomyAccount account = repository.getAccount(userId); + if (account.getEfficiency() >= 5) return -1; + long cost = 800L * (account.getEfficiency() + 1); + if (account.getCash() < cost) return -1; + int newLevel = account.getEfficiency() + 1; + repository.save(account.withCash(account.getCash() - cost).withEfficiency(newLevel).withAddedSpent(cost)); + return newLevel; + } + + public List getTopBalances(int limit) { + return repository.findTopByBalance(limit); + } + + public record DailyResult(long amount, int streak, long newCash) {} + +} diff --git a/src/main/java/creator/loki/feature/economy/service/FishingService.java b/src/main/java/creator/loki/feature/economy/service/FishingService.java new file mode 100644 index 0000000..5a94fbb --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/FishingService.java @@ -0,0 +1,120 @@ +package creator.loki.feature.economy.service; + +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.repository.EconomyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +public class FishingService { + + private static final Logger logger = LoggerFactory.getLogger(FishingService.class); + + private static final String[] FISH = {"\uD83D\uDC1F", "\uD83D\uDC20", "\uD83D\uDC21", "\uD83D\uDC19"}; + private static final String WAVE = "\uD83C\uDF0A"; + private static final long COOLDOWN_MS = 7200000; + + private final EconomyRepository repository; + private final ConcurrentHashMap activeFishing = new ConcurrentHashMap<>(); + + public FishingService(EconomyRepository repository) { + this.repository = repository; + } + + private record ActiveFish(String targetFish, int correctCount, long startTime, String difficulty, String[] grid) {} + + public FishResult startFishing(String userId, String difficulty) { + EconomyAccount account = repository.getAccount(userId); + if (account.getLastFish() != null) { + long elapsed = System.currentTimeMillis() - Long.parseLong(account.getLastFish()); + if (elapsed < COOLDOWN_MS) return null; + } + + int size; + int fishTypes; + int baseReward; + long timeLimit; + switch (difficulty.toLowerCase()) { + case "easy" -> { size = 5; fishTypes = 2; baseReward = 15; timeLimit = 30000; } + case "normal" -> { size = 7; fishTypes = 3; baseReward = 30; timeLimit = 45000; } + case "hard" -> { size = 9; fishTypes = 4; baseReward = 50; timeLimit = 60000; } + default -> { return null; } + } + + String[] available = Arrays.copyOf(FISH, fishTypes); + String[][] grid = new String[size][size]; + int[] counts = new int[fishTypes]; + var rng = ThreadLocalRandom.current(); + + for (int r = 0; r < size; r++) { + for (int c = 0; c < size; c++) { + if (rng.nextInt(100) < 40) { + grid[r][c] = WAVE; + } else { + int fi = rng.nextInt(fishTypes); + grid[r][c] = available[fi]; + counts[fi]++; + } + } + } + + int targetIdx; + do { + targetIdx = rng.nextInt(fishTypes); + } while (counts[targetIdx] == 0); + + String targetFish = available[targetIdx]; + int correctCount = counts[targetIdx]; + + String[] rows = new String[size]; + for (int r = 0; r < size; r++) { + rows[r] = String.join(" ", grid[r]); + } + + ActiveFish session = new ActiveFish(targetFish, correctCount, System.currentTimeMillis(), difficulty, rows); + activeFishing.put(userId, session); + + return new FishResult(rows, targetFish, correctCount, 0, account.getCash()); + } + + public FishResult checkAnswer(String userId, int answer, long startTime, String difficulty) { + ActiveFish session = activeFishing.remove(userId); + if (session == null) return null; + if (session.startTime() != startTime || !session.difficulty().equals(difficulty)) return null; + + EconomyAccount account = repository.getAccount(userId); + long elapsed = System.currentTimeMillis() - session.startTime(); + + int baseReward; + long timeLimit; + switch (difficulty.toLowerCase()) { + case "easy" -> { baseReward = 15; timeLimit = 30000; } + case "normal" -> { baseReward = 30; timeLimit = 45000; } + case "hard" -> { baseReward = 50; timeLimit = 60000; } + default -> { return null; } + } + + long reward = 0; + if (answer == session.correctCount() && elapsed <= timeLimit) { + double ratio = (double) elapsed / timeLimit; + double speedMult = ratio < 0.3 ? 2.0 : ratio < 0.5 ? 1.5 : 1.0; + reward = (long) (baseReward * speedMult * (1 + account.getFishingLuck() * 0.01)); + } + + long newCash = account.getCash() + reward; + EconomyAccount updated = new EconomyAccount.Builder(account) + .cash(newCash) + .totalEarned(account.getTotalEarned() + reward) + .lastFish(String.valueOf(System.currentTimeMillis())) + .build(); + repository.save(updated); + + return new FishResult(session.grid(), session.targetFish(), session.correctCount(), reward, newCash); + } + + public record FishResult(String[] grid, String targetFish, int count, long reward, long cashAfter) {} + +} diff --git a/src/main/java/creator/loki/feature/economy/service/JobService.java b/src/main/java/creator/loki/feature/economy/service/JobService.java new file mode 100644 index 0000000..576bf4a --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/JobService.java @@ -0,0 +1,91 @@ +package creator.loki.feature.economy.service; + +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.domain.Job; +import creator.loki.feature.economy.repository.EconomyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class JobService { + + private static final Logger logger = LoggerFactory.getLogger(JobService.class); + + private static final long WORK_COOLDOWN = 21600000; + + private final EconomyRepository repository; + + public JobService(EconomyRepository repository) { + this.repository = repository; + } + + public void setJob(String userId, String jobName) { + Job job = Job.fromName(jobName); + if (job == null) return; + EconomyAccount account = repository.getAccount(userId); + EconomyAccount updated = new EconomyAccount.Builder(account) + .job(job.name()) + .build(); + repository.save(updated); + } + + public WorkResult work(String userId, int userLevel) { + EconomyAccount account = repository.getAccount(userId); + String jobName = account.getJob(); + if (jobName == null) return null; + + Job job = Job.fromName(jobName); + if (job == null) return null; + if (userLevel < job.getRequiredLevel()) return null; + + if (account.getLastWork() != null) { + long elapsed = System.currentTimeMillis() - Long.parseLong(account.getLastWork()); + if (elapsed < WORK_COOLDOWN) return null; + } + + var rng = ThreadLocalRandom.current(); + long basePayout = rng.nextLong(job.getMinPayout(), job.getMaxPayout() + 1); + long amount = (long) (basePayout * (1 + account.getEfficiency() * 0.01)); + + long newCash = account.getCash() + amount; + EconomyAccount updated = new EconomyAccount.Builder(account) + .cash(newCash) + .totalEarned(account.getTotalEarned() + amount) + .lastWork(String.valueOf(System.currentTimeMillis())) + .build(); + repository.save(updated); + + return new WorkResult(amount, newCash); + } + + public List listJobs() { + List infos = new ArrayList<>(); + for (Job job : Job.values()) { + infos.add(new JobInfo( + job.name(), + job.getDisplayName(), + job.getRequiredLevel(), + job.getMinPayout(), + job.getMaxPayout(), + job.getEmoji() + )); + } + return infos; + } + + public String getCurrentJob(String userId) { + EconomyAccount account = repository.getAccount(userId); + String jobName = account.getJob(); + if (jobName == null) return null; + Job job = Job.fromName(jobName); + return job != null ? job.getDisplayName() : null; + } + + public record WorkResult(long amount, long newCash) {} + + public record JobInfo(String name, String displayName, int requiredLevel, int minPayout, int maxPayout, String emoji) {} + +} diff --git a/src/main/java/creator/loki/feature/economy/service/SlotService.java b/src/main/java/creator/loki/feature/economy/service/SlotService.java new file mode 100644 index 0000000..4cd58f4 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/SlotService.java @@ -0,0 +1,60 @@ +package creator.loki.feature.economy.service; + +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.domain.SlotCombination; +import creator.loki.feature.economy.repository.EconomyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ThreadLocalRandom; + +public class SlotService { + + private static final Logger logger = LoggerFactory.getLogger(SlotService.class); + + private static final String[] EMOJIS = {"\uD83C\uDF52", "\uD83D\uDD14", "\uD83D\uDC8E", "\uD83D\uDC51", "\u26A1"}; + private static final int[] WEIGHTS = {40, 25, 18, 12, 5}; + private static final int TOTAL_WEIGHT = 100; + + private final EconomyRepository repository; + + public SlotService(EconomyRepository repository) { + this.repository = repository; + } + + public SlotResult spin(String userId, long bet) { + if (bet <= 0) return null; + EconomyAccount account = repository.getAccount(userId); + if (account.getCash() < bet) return null; + + repository.save(account.withCash(account.getCash() - bet)); + + String e1 = spinEmoji(); + String e2 = spinEmoji(); + String e3 = spinEmoji(); + + SlotCombination combo = SlotCombination.match(e1, e2, e3); + long winAmount = combo != null ? bet * combo.getMultiplier() : 0; + + long cashAfter = account.getCash() - bet + winAmount; + repository.save(repository.getAccount(userId).withCash(cashAfter).withAddedEarned(winAmount)); + + return new SlotResult(e1, e2, e3, + combo != null ? combo.name() : null, + combo != null ? combo.getMultiplier() : 0, + winAmount, cashAfter); + } + + private String spinEmoji() { + int roll = ThreadLocalRandom.current().nextInt(TOTAL_WEIGHT); + int cumulative = 0; + for (int i = 0; i < EMOJIS.length; i++) { + cumulative += WEIGHTS[i]; + if (roll < cumulative) return EMOJIS[i]; + } + return EMOJIS[EMOJIS.length - 1]; + } + + public record SlotResult(String emoji1, String emoji2, String emoji3, String comboName, int multiplier, long winAmount, long cashAfter) {} + +} diff --git a/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java b/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java new file mode 100644 index 0000000..1248b48 --- /dev/null +++ b/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java @@ -0,0 +1,64 @@ +package creator.loki.feature.entertainment; + +import com.google.inject.Inject; +import creator.loki.feature.social.commands.*; +import creator.loki.feature.social.commands.*; +import creator.loki.feature.entertainment.commands.*; +import creator.loki.feature.entertainment.commands.*; +import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistrar; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.core.security.CooldownManager; +import creator.loki.feature.ship.repository.ShipPairRepository; +import creator.loki.feature.ship.ShipCommand; + +public class EntertainmentCommandRegistrar implements CommandRegistrar { + private final CooldownManager cooldownManager; + private final ShipPairRepository shipPairRepository; + private final BotConfig config; + + @Inject + public EntertainmentCommandRegistrar( + CooldownManager cooldownManager, + ShipPairRepository shipPairRepository, + BotConfig config + ) { + this.cooldownManager = cooldownManager; + this.shipPairRepository = shipPairRepository; + this.config = config; + } + + @Override + public void register(CommandRegistry registry) { + registry.register(new EightBallCommand()); + registry.register(new CoinCommand()); + registry.register(new DiceCommand()); + registry.register(new MemeCommand()); + registry.register(new ShipCommand(shipPairRepository)); + registry.register(new RoastCommand()); + registry.register(new ComplimentCommand()); + registry.register(new GlazbobaCommand()); + + registry.register(new IqCommand(cooldownManager)); + registry.register(new GayCommand(cooldownManager)); + registry.register(new PpCommand(cooldownManager)); + registry.register(new RateCommand(cooldownManager)); + registry.register(new BoykisserCommand(cooldownManager)); + registry.register(new MustacheCommand(cooldownManager)); + registry.register(new FatCommand(cooldownManager)); + + registry.register(new PatCommand(cooldownManager)); + registry.register(new HugCommand()); + registry.register(new KissCommand(cooldownManager)); + registry.register(new SlapCommand(cooldownManager)); + registry.register(new TickleCommand(cooldownManager)); + registry.register(new PokeCommand(cooldownManager)); + registry.register(new CuddleCommand(cooldownManager)); + registry.register(new FeedCommand(cooldownManager)); + + registry.register(new RandomChoiceCommand(config, "choose", RandomChoiceCommand.Flavor.DEFAULT)); + registry.register(new RandomChoiceCommand(config, "pick", RandomChoiceCommand.Flavor.DEFAULT)); + registry.register(new RandomChoiceCommand(config, "decide", RandomChoiceCommand.Flavor.ORACLE)); + registry.register(new RandomChoiceCommand(config, "wheel", RandomChoiceCommand.Flavor.WHEEL)); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/BoykisserCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/BoykisserCommand.java similarity index 83% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/BoykisserCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/BoykisserCommand.java index d949734..3d4b92b 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/BoykisserCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/BoykisserCommand.java @@ -1,99 +1,101 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * Boykisser метр с детерминированным результатом - */ -public class BoykisserCommand implements Command { - - private final CooldownManager cooldownManager; - - public BoykisserCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "boykisser"; - } - - @Override - public String getDescription() { - return "На сколько ты boykisser (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "boykisser [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "boykisser")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "boykisser"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - if (target.getUser().isBot()) { - context.getChannel().sendMessage("❌ Боты не целуют мальчиков!").queue(); - return; - } - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "boykisser", 5); - - // Детерминированный процент - int boykisserLevel = getDeterministicValue(target.getId(), 777, 0, 100); - - // Прогресс-бар с поцелуями - StringBuilder bar = new StringBuilder(); - int filled = boykisserLevel / 10; - for (int i = 0; i < filled; i++) { - bar.append("💋"); - } - for (int i = filled; i < 10; i++) { - bar.append("⬜"); - } - - // Определяем уровень - RatingLevel level = RatingLevel.findLevel(boykisserLevel, RatingConfig.getBoykisserLevels()); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("💋 Boykisser Метр") - .setDescription(target.getAsMention()) - .addField("Уровень", bar.toString() + "\n**" + boykisserLevel + "%** - " + level.getComment(), false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(level.getColor()) - .setFooter("💡 Детерминировано вашим Discord ID") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.feature.entertainment.commands.RatingConfig; +import creator.loki.feature.entertainment.commands.RatingLevel; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * Boykisser метр с детерминированным результатом + */ +public class BoykisserCommand implements Command { + + private final CooldownManager cooldownManager; + + public BoykisserCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "boykisser"; + } + + @Override + public String getDescription() { + return "На сколько ты boykisser (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "boykisser [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "boykisser")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "boykisser"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + if (target.getUser().isBot()) { + context.getChannel().sendMessage("❌ Боты не целуют мальчиков!").queue(); + return; + } + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "boykisser", 5); + + // Детерминированный процент + int boykisserLevel = getDeterministicValue(target.getId(), 777, 0, 100); + + // Прогресс-бар с поцелуями + StringBuilder bar = new StringBuilder(); + int filled = boykisserLevel / 10; + for (int i = 0; i < filled; i++) { + bar.append("💋"); + } + for (int i = filled; i < 10; i++) { + bar.append("⬜"); + } + + // Определяем уровень + RatingLevel level = RatingLevel.findLevel(boykisserLevel, RatingConfig.getBoykisserLevels()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("💋 Boykisser Метр") + .withDescription(target.getAsMention()) + .addField("Уровень", bar.toString() + "\n**" + boykisserLevel + "%** - " + level.getComment(), false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(level.getColor()) + .withFooter("💡 Детерминировано вашим Discord ID") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/feature/entertainment/commands/ChoiceOptionParser.java b/src/main/java/creator/loki/feature/entertainment/commands/ChoiceOptionParser.java new file mode 100644 index 0000000..77c820b --- /dev/null +++ b/src/main/java/creator/loki/feature/entertainment/commands/ChoiceOptionParser.java @@ -0,0 +1,78 @@ +package creator.loki.feature.entertainment.commands; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Pattern; + + +/** + * Разбор хвоста сообщения и строки вариантов для {@link RandomChoiceCommand}. + */ +final class ChoiceOptionParser { + + private static final Pattern OR_SPLIT = Pattern.compile("(?i)\\s+или\\s+"); + + private record SplitRule(Predicate when, Function split) { + } + + /** Порядок важен: последнее правило - «слова через пробел». */ + private static final List RULES = List.of( + new SplitRule(s -> s.contains("|"), s -> s.split("\\|", -1)), + new SplitRule(s -> s.contains(","), s -> s.split(",", -1)), + new SplitRule(s -> OR_SPLIT.matcher(s).find(), OR_SPLIT::split), + new SplitRule(s -> s.contains("/"), s -> s.split("/", -1)), + new SplitRule(s -> true, s -> s.split("\\s+", -1)) + ); + + private ChoiceOptionParser() { + } + + static String tailAfterCommand(String raw, String prefix, String commandName) { + String t = raw.trim(); + if (!t.startsWith(prefix)) { + return ""; + } + String rest = t.substring(prefix.length()).trim(); + int sp = rest.indexOf(' '); + if (sp < 0) { + return ""; + } + String cmd = rest.substring(0, sp); + if (!cmd.equalsIgnoreCase(commandName)) { + return ""; + } + return rest.substring(sp).trim(); + } + + static List parseOptions(String tail) { + String t = tail.strip(); + for (SplitRule rule : RULES) { + if (rule.when.test(t)) { + return dedupeIgnoreCase(trimNonEmpty(rule.split.apply(t))); + } + } + return List.of(); + } + + private static List trimNonEmpty(String[] arr) { + return Arrays.stream(arr).map(String::trim).filter(s -> !s.isEmpty()).toList(); + } + + private static List dedupeIgnoreCase(List parts) { + Set seen = new HashSet<>(); + List out = new ArrayList<>(); + for (String p : parts) { + String key = p.toLowerCase(Locale.ROOT); + if (seen.add(key)) { + out.add(p); + } + } + return List.copyOf(out); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/CoinCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/CoinCommand.java similarity index 74% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/CoinCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/CoinCommand.java index c6d0555..31ef8b1 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/CoinCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/CoinCommand.java @@ -1,47 +1,47 @@ -package creator.loki.command.impl.entertainment.games.random; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; - -import java.awt.Color; -import java.util.Random; - -public class CoinCommand implements Command { - private final Random random = new Random(); - - @Override - public String getName() { - return "coin"; - } - - @Override - public String getDescription() { - return "Подбросить монетку"; - } - - @Override - public String getUsage() { - return "coin"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - boolean isHeads = random.nextBoolean(); - String result = isHeads ? "Орёл" : "Решка"; - String emoji = isHeads ? "🦅" : "🪙"; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle(emoji + " Подбрасываю монетку...") - .setDescription("**Результат:** " + result) - .setColor(isHeads ? new Color(255, 215, 0) : new Color(192, 192, 192)) - .build() - ).queue(); - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; +import java.util.Random; + +public class CoinCommand implements Command { + private final Random random = new Random(); + + @Override + public String getName() { + return "coin"; + } + + @Override + public String getDescription() { + return "Подбросить монетку"; + } + + @Override + public String getUsage() { + return "coin"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "Орёл" : "Решка"; + String emoji = isHeads ? "🦅" : "🪙"; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle(emoji + " Подбрасываю монетку...") + .withDescription("**Результат:** " + result) + .withColor(isHeads ? new Color(255, 215, 0) : new Color(192, 192, 192)) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/DiceCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/DiceCommand.java similarity index 81% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/DiceCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/DiceCommand.java index fa66c81..5df9022 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/DiceCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/DiceCommand.java @@ -1,60 +1,60 @@ -package creator.loki.command.impl.entertainment.games.random; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; - -import java.awt.Color; -import java.util.Random; - -public class DiceCommand implements Command { - private final Random random = new Random(); - - @Override - public String getName() { - return "dice"; - } - - @Override - public String getDescription() { - return "Бросить кубик"; - } - - @Override - public String getUsage() { - return "dice [N]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - int maxValue = 6; - - if (!context.getArgs().isEmpty()) { - try { - maxValue = Integer.parseInt(context.getArgs().get(0)); - if (maxValue < 2 || maxValue > 100) { - context.getChannel().sendMessage("Число должно быть от 2 до 100").queue(); - return; - } - } catch (NumberFormatException e) { - context.getChannel().sendMessage("Укажи корректное число").queue(); - return; - } - } - - int result = random.nextInt(maxValue) + 1; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🎲 Бросаю кубик...") - .setDescription("**Результат:** " + result + " (из " + maxValue + ")") - .setColor(new Color(46, 204, 113)) - .build() - ).queue(); - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; +import java.util.Random; + +public class DiceCommand implements Command { + private final Random random = new Random(); + + @Override + public String getName() { + return "dice"; + } + + @Override + public String getDescription() { + return "Бросить кубик"; + } + + @Override + public String getUsage() { + return "dice [N]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + int maxValue = 6; + + if (!context.getArgs().isEmpty()) { + try { + maxValue = Integer.parseInt(context.getArgs().get(0)); + if (maxValue < 2 || maxValue > 100) { + context.getChannel().sendMessage("Число должно быть от 2 до 100").queue(); + return; + } + } catch (NumberFormatException e) { + context.getChannel().sendMessage("Укажи корректное число").queue(); + return; + } + } + + int result = random.nextInt(maxValue) + 1; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🎲 Бросаю кубик...") + .withDescription("**Результат:** " + result + " (из " + maxValue + ")") + .withColor(new Color(46, 204, 113)) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/EightBallCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/EightBallCommand.java similarity index 83% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/EightBallCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/EightBallCommand.java index 5d0e9ee..d4f52b8 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/EightBallCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/EightBallCommand.java @@ -1,74 +1,74 @@ -package creator.loki.command.impl.entertainment.games; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; - -import java.awt.Color; -import java.util.Random; - -public class EightBallCommand implements Command { - private final Random random = new Random(); - - private static final String[] ANSWERS = { - "Да, определённо", - "Бесспорно", - "Без сомнений", - "Можешь быть уверен", - "Мне кажется — да", - "Вероятнее всего", - "Хорошие перспективы", - "Знаки говорят — да", - "Да", - "Пока не ясно, попробуй снова", - "Спроси позже", - "Лучше не рассказывать", - "Сейчас нельзя предсказать", - "Сконцентрируйся и спроси опять", - "Даже не думай", - "Мой ответ — нет", - "По моим данным — нет", - "Перспективы не очень хорошие", - "Весьма сомнительно" - }; - - @Override - public String getName() { - return "8ball"; - } - - @Override - public String getDescription() { - return "Магический шар предсказаний"; - } - - @Override - public String getUsage() { - return "8ball <вопрос>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - if (context.getArgs().isEmpty()) { - context.getChannel().sendMessage("Задай вопрос!").queue(); - return; - } - - String question = String.join(" ", context.getArgs()); - String answer = ANSWERS[random.nextInt(ANSWERS.length)]; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🎱 Магический шар") - .addField("Вопрос:", question, false) - .addField("Ответ:", answer, false) - .setColor(new Color(52, 73, 94)) - .build() - ).queue(); - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; +import java.util.Random; + +public class EightBallCommand implements Command { + private final Random random = new Random(); + + private static final String[] ANSWERS = { + "Да, определённо", + "Бесспорно", + "Без сомнений", + "Можешь быть уверен", + "Мне кажется - да", + "Вероятнее всего", + "Хорошие перспективы", + "Знаки говорят - да", + "Да", + "Пока не ясно, попробуй снова", + "Спроси позже", + "Лучше не рассказывать", + "Сейчас нельзя предсказать", + "Сконцентрируйся и спроси опять", + "Даже не думай", + "Мой ответ - нет", + "По моим данным - нет", + "Перспективы не очень хорошие", + "Весьма сомнительно" + }; + + @Override + public String getName() { + return "8ball"; + } + + @Override + public String getDescription() { + return "Магический шар предсказаний"; + } + + @Override + public String getUsage() { + return "8ball <вопрос>"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + if (context.getArgs().isEmpty()) { + context.getChannel().sendMessage("Задай вопрос!").queue(); + return; + } + + String question = String.join(" ", context.getArgs()); + String answer = ANSWERS[random.nextInt(ANSWERS.length)]; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🎱 Магический шар") + .addField("Вопрос:", question, false) + .addField("Ответ:", answer, false) + .withColor(new Color(52, 73, 94)) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/FatCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/FatCommand.java similarity index 82% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/FatCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/FatCommand.java index f2755ba..773ba39 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/FatCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/FatCommand.java @@ -1,94 +1,96 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * Fat метр - проверка на жирность - */ -public class FatCommand implements Command { - - private final CooldownManager cooldownManager; - - public FatCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "fat"; - } - - @Override - public String getDescription() { - return "Проверка на жирность (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "fat [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "fat")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "fat"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "fat", 5); - - // Детерминированный процент от 0 до 100 - int fatLevel = getDeterministicValue(target.getId(), 999, 0, 100); - - // Прогресс-бар - StringBuilder bar = new StringBuilder(); - int filled = fatLevel / 10; - for (int i = 0; i < filled; i++) { - bar.append("🟩"); - } - for (int i = filled; i < 10; i++) { - bar.append("⬜"); - } - - // Определяем уровень - RatingLevel level = RatingLevel.findLevel(fatLevel, RatingConfig.getFatLevels()); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🐷 Fat Метр") - .setDescription(target.getAsMention()) - .addField("Жирность", bar.toString() + "\n**" + fatLevel + "%** - " + level.getComment(), false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(level.getColor()) - .setFooter("💡 Детерминировано вашим Discord ID") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.feature.entertainment.commands.RatingConfig; +import creator.loki.feature.entertainment.commands.RatingLevel; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * Fat метр - проверка на жирность + */ +public class FatCommand implements Command { + + private final CooldownManager cooldownManager; + + public FatCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "fat"; + } + + @Override + public String getDescription() { + return "Проверка на жирность (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "fat [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "fat")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "fat"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "fat", 5); + + // Детерминированный процент от 0 до 100 + int fatLevel = getDeterministicValue(target.getId(), 999, 0, 100); + + // Прогресс-бар + StringBuilder bar = new StringBuilder(); + int filled = fatLevel / 10; + for (int i = 0; i < filled; i++) { + bar.append("🟩"); + } + for (int i = filled; i < 10; i++) { + bar.append("⬜"); + } + + // Определяем уровень + RatingLevel level = RatingLevel.findLevel(fatLevel, RatingConfig.getFatLevels()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🐷 Fat Метр") + .withDescription(target.getAsMention()) + .addField("Жирность", bar.toString() + "\n**" + fatLevel + "%** - " + level.getComment(), false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(level.getColor()) + .withFooter("💡 Детерминировано вашим Discord ID") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/GayCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/GayCommand.java similarity index 85% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/GayCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/GayCommand.java index 7198f8c..d0c4746 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/GayCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/GayCommand.java @@ -1,97 +1,97 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * Gay-метр с детерминированным результатом - */ -public class GayCommand implements Command { - - private final CooldownManager cooldownManager; - - public GayCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "gay"; - } - - @Override - public String getDescription() { - return "Гей-метр (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "gay [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "gay")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "gay"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - if (target.getUser().isBot()) { - context.getChannel().sendMessage("❌ Боты не имеют ориентации!").queue(); - return; - } - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "gay", 5); - - // Детерминированный процент - int gayLevel = getDeterministicValue(target.getId(), 456, 0, 100); - - // Прогресс-бар с радугой - String[] rainbow = {"🟥", "🟧", "🟨", "🟩", "🟦", "🟪"}; - StringBuilder bar = new StringBuilder(); - int filled = Math.min(gayLevel / 17 + 1, 6); - for (int i = 0; i < filled; i++) { - bar.append(rainbow[i]); - } - for (int i = filled; i < 6; i++) { - bar.append("⬜"); - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🏳️‍🌈 Гей-метр") - .setDescription(target.getAsMention()) - .addField("Уровень", bar.toString() + "\n**" + gayLevel + "%**", false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(new Color(255, 0, 255)) - .setFooter("💡 Это просто мем, не воспринимайте серьезно") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * Gay-метр с детерминированным результатом + */ +public class GayCommand implements Command { + + private final CooldownManager cooldownManager; + + public GayCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "gay"; + } + + @Override + public String getDescription() { + return "Гей-метр (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "gay [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "gay")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "gay"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + if (target.getUser().isBot()) { + context.getChannel().sendMessage("❌ Боты не имеют ориентации!").queue(); + return; + } + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "gay", 5); + + // Детерминированный процент + int gayLevel = getDeterministicValue(target.getId(), 456, 0, 100); + + // Прогресс-бар с радугой + String[] rainbow = {"🟥", "🟧", "🟨", "🟩", "🟦", "🟪"}; + StringBuilder bar = new StringBuilder(); + int filled = Math.min(gayLevel / 17 + 1, 6); + for (int i = 0; i < filled; i++) { + bar.append(rainbow[i]); + } + for (int i = filled; i < 6; i++) { + bar.append("⬜"); + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🏳️‍🌈 Гей-метр") + .withDescription(target.getAsMention()) + .addField("Уровень", bar.toString() + "\n**" + gayLevel + "%**", false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(new Color(255, 0, 255)) + .withFooter("💡 Это просто мем, не воспринимайте серьезно") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/GlazbobaCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java similarity index 87% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/GlazbobaCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java index 2a3a4db..a0c0ce5 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/GlazbobaCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java @@ -1,96 +1,96 @@ -package creator.loki.command.impl.entertainment.games.random; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.stream.Collectors; - -public class GlazbobaCommand implements Command { - - @Override - public String getName() { - return "glazboba"; - } - - @Override - public String getDescription() { - return "Расширенная информация о пользователе"; - } - - @Override - public String getUsage() { - return "glazboba <@user>"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - List mentioned = context.getMessage().getMentions().getMembers(); - if (mentioned.isEmpty()) { - context.getChannel().sendMessage("Укажи пользователя через @").queue(); - return; - } - - Member target = mentioned.get(0); - - // Анимация загрузки - context.getChannel().sendMessage("🔍 Сканирую данные...").queue(loadingMsg -> { - try { - Thread.sleep(1000); - loadingMsg.editMessage("📊 Анализирую профиль...").queue(); - Thread.sleep(1000); - loadingMsg.editMessage("🔐 Взламываю Discord...").queue(); - Thread.sleep(1000); - - // Собираем информацию - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); - String createdAt = target.getUser().getTimeCreated().format(formatter); - String joinedAt = target.getTimeJoined().format(formatter); - String status = target.getOnlineStatus().name(); - String nickname = target.getNickname() != null ? target.getNickname() : "Нет"; - String roles = target.getRoles().stream() - .map(role -> role.getName()) - .limit(10) - .collect(Collectors.joining(", ")); - if (roles.isEmpty()) roles = "Нет ролей"; - - String activities = target.getActivities().isEmpty() - ? "Нет активности" - : target.getActivities().get(0).getName(); - - boolean isBot = target.getUser().isBot(); - boolean isBoosting = target.isBoosting(); - - loadingMsg.editMessageEmbeds( - EmbedFactory.create() - .setTitle("👤 Информация о " + target.getEffectiveName()) - .setThumbnail(target.getUser().getAvatarUrl()) - .addField("🆔 ID", target.getId(), true) - .addField("📛 Никнейм", nickname, true) - .addField("🤖 Бот", isBot ? "Да" : "Нет", true) - .addField("📅 Аккаунт создан", createdAt, false) - .addField("📥 Присоединился", joinedAt, false) - .addField("🟢 Статус", status, true) - .addField("🎮 Активность", activities, true) - .addField("💎 Бустер", isBoosting ? "Да" : "Нет", true) - .addField("🎭 Роли", roles, false) - .setColor(target.getColor() != null ? target.getColor() : Color.BLUE) - .setFooter("Запросил: " + context.getAuthor().getUser().getName()) - .build() - ).queue(); - - } catch (InterruptedException e) { - loadingMsg.editMessage("❌ Ошибка загрузки").queue(); - } - }); - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +public class GlazbobaCommand implements Command { + + @Override + public String getName() { + return "glazboba"; + } + + @Override + public String getDescription() { + return "Расширенная информация о пользователе"; + } + + @Override + public String getUsage() { + return "glazboba <@user>"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + List mentioned = context.getMessage().getMentions().getMembers(); + if (mentioned.isEmpty()) { + context.getChannel().sendMessage("Укажи пользователя через @").queue(); + return; + } + + Member target = mentioned.get(0); + + // Анимация загрузки + context.getChannel().sendMessage("🔍 Сканирую данные...").queue(loadingMsg -> { + try { + Thread.sleep(1000); + loadingMsg.editMessage("📊 Анализирую профиль...").queue(); + Thread.sleep(1000); + loadingMsg.editMessage("🔐 Взламываю Discord...").queue(); + Thread.sleep(1000); + + // Собираем информацию + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + String createdAt = target.getUser().getTimeCreated().format(formatter); + String joinedAt = target.getTimeJoined().format(formatter); + String status = target.getOnlineStatus().name(); + String nickname = target.getNickname() != null ? target.getNickname() : "Нет"; + String roles = target.getRoles().stream() + .map(role -> role.getName()) + .limit(10) + .collect(Collectors.joining(", ")); + if (roles.isEmpty()) roles = "Нет ролей"; + + String activities = target.getActivities().isEmpty() + ? "Нет активности" + : target.getActivities().get(0).getName(); + + boolean isBot = target.getUser().isBot(); + boolean isBoosting = target.isBoosting(); + + loadingMsg.editMessageEmbeds( + EmbedFactory.create() + .withTitle("👤 Информация о " + target.getEffectiveName()) + .withThumbnail(target.getUser().getAvatarUrl()) + .addField("🆔 ID", target.getId(), true) + .addField("📛 Никнейм", nickname, true) + .addField("🤖 Бот", isBot ? "Да" : "Нет", true) + .addField("📅 Аккаунт создан", createdAt, false) + .addField("📥 Присоединился", joinedAt, false) + .addField("🟢 Статус", status, true) + .addField("🎮 Активность", activities, true) + .addField("💎 Бустер", isBoosting ? "Да" : "Нет", true) + .addField("🎭 Роли", roles, false) + .withColor(target.getColor() != null ? target.getColor() : Color.BLUE) + .withFooter("Запросил: " + context.getAuthor().getUser().getName()) + .build() + ).queue(); + + } catch (InterruptedException e) { + loadingMsg.editMessage("❌ Ошибка загрузки").queue(); + } + }); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/IqCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/IqCommand.java similarity index 82% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/IqCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/IqCommand.java index 2e8acf9..788d102 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/IqCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/IqCommand.java @@ -1,91 +1,93 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * IQ тест с детерминированным результатом - */ -public class IqCommand implements Command { - - private final CooldownManager cooldownManager; - - public IqCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "iq"; - } - - @Override - public String getDescription() { - return "Тест IQ (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "iq [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "iq")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "iq"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "iq", 5); - - int iq; - if (target.getUser().isBot()) { - iq = 9000; // Боты умные! - } else if (target.getId().equals("1414975197269987471")) { - iq = 1000; // Специальное значение - } else { - // Детерминированный IQ от 0 до 140 - iq = getDeterministicValue(target.getId(), 123, 0, 140); - } - - // Определяем уровень - RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels()); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🧠 Тест IQ") - .setDescription("Результат для " + target.getAsMention()) - .addField("IQ Уровень", "**" + iq + "** - " + level.getComment(), false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(level.getColor()) - .setFooter("💡 Научно доказано вашим ID") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.feature.entertainment.commands.RatingConfig; +import creator.loki.feature.entertainment.commands.RatingLevel; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * IQ тест с детерминированным результатом + */ +public class IqCommand implements Command { + + private final CooldownManager cooldownManager; + + public IqCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "iq"; + } + + @Override + public String getDescription() { + return "Тест IQ (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "iq [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "iq")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "iq"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "iq", 5); + + int iq; + if (target.getUser().isBot()) { + iq = 9000; // Боты умные! + } else if (target.getId().equals("1414975197269987471")) { + iq = 1000; // Специальное значение + } else { + // Детерминированный IQ от 0 до 140 + iq = getDeterministicValue(target.getId(), 123, 0, 140); + } + + // Определяем уровень + RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🧠 Тест IQ") + .withDescription("Результат для " + target.getAsMention()) + .addField("IQ Уровень", "**" + iq + "** - " + level.getComment(), false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(level.getColor()) + .withFooter("💡 Научно доказано вашим ID") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/MemeCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java similarity index 83% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/MemeCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java index 90d40b1..a812da9 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/MemeCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java @@ -1,73 +1,73 @@ -package creator.loki.command.impl.entertainment.games.random; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; - -import java.awt.Color; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; - -public class MemeCommand implements Command { - - @Override - public String getName() { - return "meme"; - } - - @Override - public String getDescription() { - return "Случайный мем"; - } - - @Override - public String getUsage() { - return "meme"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - context.getChannel().sendMessage("🔍 Ищу мем...").queue(loadingMsg -> { - try { - URL url = new URL("https://meme-api.com/gimme"); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setRequestProperty("User-Agent", "Mozilla/5.0"); - - BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream())); - StringBuilder response = new StringBuilder(); - String line; - while ((line = in.readLine()) != null) { - response.append(line); - } - in.close(); - - JsonObject json = JsonParser.parseString(response.toString()).getAsJsonObject(); - String memeUrl = json.get("url").getAsString(); - String title = json.get("title").getAsString(); - String subreddit = json.get("subreddit").getAsString(); - - loadingMsg.editMessageEmbeds( - EmbedFactory.create() - .setTitle(title) - .setImage(memeUrl) - .setColor(new Color(255, 69, 0)) - .setFooter("r/" + subreddit) - .build() - ).queue(); - - } catch (Exception e) { - loadingMsg.editMessage("❌ Не удалось загрузить мем: " + e.getMessage()).queue(); - } - }); - } -} +package creator.loki.feature.entertainment.commands; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; + +public class MemeCommand implements Command { + + @Override + public String getName() { + return "meme"; + } + + @Override + public String getDescription() { + return "Случайный мем"; + } + + @Override + public String getUsage() { + return "meme"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + context.getChannel().sendMessage("🔍 Ищу мем...").queue(loadingMsg -> { + try { + var url = new URI("https://meme-api.com/gimme").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", "Mozilla/5.0"); + + BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + in.close(); + + JsonObject json = JsonParser.parseString(response.toString()).getAsJsonObject(); + String memeUrl = json.get("url").getAsString(); + String title = json.get("title").getAsString(); + String subreddit = json.get("subreddit").getAsString(); + + loadingMsg.editMessageEmbeds( + EmbedFactory.create() + .withTitle(title) + .withImage(memeUrl) + .withColor(new Color(255, 69, 0)) + .withFooter("r/" + subreddit) + .build() + ).queue(); + + } catch (Exception e) { + loadingMsg.editMessage("❌ Не удалось загрузить мем: " + e.getMessage()).queue(); + } + }); + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/MustacheCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/MustacheCommand.java similarity index 84% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/MustacheCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/MustacheCommand.java index 6e4d50c..6312515 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/MustacheCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/MustacheCommand.java @@ -1,103 +1,105 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * Mustache метр - проверка на усатость - */ -public class MustacheCommand implements Command { - - private final CooldownManager cooldownManager; - - public MustacheCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "mustache"; - } - - @Override - public String getDescription() { - return "Проверка на усатость (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "mustache [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "mustache")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "mustache"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "mustache", 5); - - int mustacheLevel; - - // Специальные значения - if (target.getUser().isBot() || target.getId().equals("1414975197269987471")) { - mustacheLevel = 0; // Боты и специальный ID = 0% - } else if (target.getId().equals("678283217094180915")) { - mustacheLevel = 1000; // Специальный ID = 1000% - } else { - // Детерминированный процент от 0 до 100 - mustacheLevel = getDeterministicValue(target.getId(), 888, 0, 100); - } - - // Прогресс-бар с усами - StringBuilder bar = new StringBuilder(); - int filled = Math.min(mustacheLevel / 10, 10); - for (int i = 0; i < filled; i++) { - bar.append("🧔"); - } - for (int i = filled; i < 10; i++) { - bar.append("⬜"); - } - - // Определяем уровень - RatingLevel level = RatingLevel.findLevel(mustacheLevel, RatingConfig.getMustacheLevels()); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🧔 Mustache Метр") - .setDescription(target.getAsMention()) - .addField("Усатость", bar.toString() + "\n**" + mustacheLevel + "%** - " + level.getComment(), false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(level.getColor()) - .setFooter("💡 Детерминировано вашим Discord ID") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.feature.entertainment.commands.RatingConfig; +import creator.loki.feature.entertainment.commands.RatingLevel; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * Mustache метр - проверка на усатость + */ +public class MustacheCommand implements Command { + + private final CooldownManager cooldownManager; + + public MustacheCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "mustache"; + } + + @Override + public String getDescription() { + return "Проверка на усатость (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "mustache [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "mustache")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "mustache"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "mustache", 5); + + int mustacheLevel; + + // Специальные значения + if (target.getUser().isBot() || target.getId().equals("1414975197269987471")) { + mustacheLevel = 0; // Боты и специальный ID = 0% + } else if (target.getId().equals("678283217094180915")) { + mustacheLevel = 1000; // Специальный ID = 1000% + } else { + // Детерминированный процент от 0 до 100 + mustacheLevel = getDeterministicValue(target.getId(), 888, 0, 100); + } + + // Прогресс-бар с усами + StringBuilder bar = new StringBuilder(); + int filled = Math.min(mustacheLevel / 10, 10); + for (int i = 0; i < filled; i++) { + bar.append("🧔"); + } + for (int i = filled; i < 10; i++) { + bar.append("⬜"); + } + + // Определяем уровень + RatingLevel level = RatingLevel.findLevel(mustacheLevel, RatingConfig.getMustacheLevels()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🧔 Mustache Метр") + .withDescription(target.getAsMention()) + .addField("Усатость", bar.toString() + "\n**" + mustacheLevel + "%** - " + level.getComment(), false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(level.getColor()) + .withFooter("💡 Детерминировано вашим Discord ID") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/PpCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/PpCommand.java similarity index 84% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/PpCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/PpCommand.java index 3b0f15a..7bbc0ec 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/PpCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/PpCommand.java @@ -1,102 +1,104 @@ -package creator.loki.command.impl.entertainment.games.rating; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.List; -import java.util.Random; - -/** - * PP размер с детерминированным результатом - */ -public class PpCommand implements Command { - - private final CooldownManager cooldownManager; - - public PpCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "pp"; - } - - @Override - public String getDescription() { - return "Измеритель pp (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "pp [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "pp")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "pp"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - if (target.getUser().isBot()) { - context.getChannel().sendMessage("❌ У ботов нет pp!").queue(); - return; - } - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "pp", 5); - - // Специальное значение для конкретного пользователя - int size; - if (target.getId().equals("1414975197269987471")) { - size = 100; - } else { - // Детерминированный размер от 0 до 30 см - size = getDeterministicValue(target.getId(), 6969, 0, 30); - } - - // Визуализация - StringBuilder ppVisual = new StringBuilder("8"); - for (int i = 0; i < size; i++) { - ppVisual.append("="); - } - ppVisual.append("D"); - - // Определяем уровень - RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels()); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🍆 PP Размер") - .setDescription(target.getAsMention()) - .addField("Размер", "```" + ppVisual.toString() + "```\n**" + size + " см** - " + level.getComment(), false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(level.getColor()) - .setFooter("💡 Детерминировано вашим Discord ID") - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.feature.entertainment.commands.RatingConfig; +import creator.loki.feature.entertainment.commands.RatingLevel; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.List; +import java.util.Random; + +/** + * PP размер с детерминированным результатом + */ +public class PpCommand implements Command { + + private final CooldownManager cooldownManager; + + public PpCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "pp"; + } + + @Override + public String getDescription() { + return "Измеритель pp (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "pp [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "pp")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "pp"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + if (target.getUser().isBot()) { + context.getChannel().sendMessage("❌ У ботов нет pp!").queue(); + return; + } + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "pp", 5); + + // Специальное значение для конкретного пользователя + int size; + if (target.getId().equals("1414975197269987471")) { + size = 100; + } else { + // Детерминированный размер от 0 до 30 см + size = getDeterministicValue(target.getId(), 6969, 0, 30); + } + + // Визуализация + StringBuilder ppVisual = new StringBuilder("8"); + for (int i = 0; i < size; i++) { + ppVisual.append("="); + } + ppVisual.append("D"); + + // Определяем уровень + RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels()); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🍆 PP Размер") + .withDescription(target.getAsMention()) + .addField("Размер", "```" + ppVisual.toString() + "```\n**" + size + " см** - " + level.getComment(), false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(level.getColor()) + .withFooter("💡 Детерминировано вашим Discord ID") + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/feature/entertainment/commands/RandomChoiceCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/RandomChoiceCommand.java new file mode 100644 index 0000000..936521b --- /dev/null +++ b/src/main/java/creator/loki/feature/entertainment/commands/RandomChoiceCommand.java @@ -0,0 +1,135 @@ +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.EmbedBuilder; + +import java.awt.Color; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Случайный выбор из вариантов. Разбор строки - {@link ChoiceOptionParser}. + */ +public class RandomChoiceCommand implements Command { + + public enum Flavor { + DEFAULT( + "Выбери один вариант - рандом решит за тебя.", + new String[] { + "🎲 Выпало: **%s**", + "🎯 Окей, берём **%s**", + "✨ Вселенная шепчет: **%s**", + "🍀 Фортуна на твоей стороне - **%s**", + "📌 Без лишних слов: **%s**" + } + ), + WHEEL( + "Колесо фортуны (тот же рандом, но драматичнее).", + new String[] { + "🎡 Колесо остановилось на **%s**", + "🎪 Барабанная дробь… **%s**!", + "🎠 Карусель выбрала **%s**", + "⚡ Судьба указывает на **%s**" + } + ), + ORACLE( + "Оракул в шутливом тоне.", + new String[] { + "🔮 Оракул видит… **%s**", + "🌙 Звёзды сложились: **%s**", + "✴️ Судьба-печенька говорит: **%s**", + "🧿 Сглаз обойдёт - бери **%s** и не оглядывайся" + } + ); + + private final String blurb; + private final String[] templates; + + Flavor(String blurb, String[] templates) { + this.blurb = blurb; + this.templates = templates; + } + + String randomLine(String pick) { + var rnd = ThreadLocalRandom.current(); + return String.format(templates[rnd.nextInt(templates.length)], pick); + } + } + + private final BotConfig config; + private final String commandName; + private final Flavor flavor; + + public RandomChoiceCommand(BotConfig config, String commandName, Flavor flavor) { + this.config = config; + this.commandName = commandName; + this.flavor = flavor; + } + + @Override + public String getName() { + return commandName; + } + + @Override + public String getDescription() { + return flavor.blurb; + } + + @Override + public String getUsage() { + return commandName + " вариант1 | вариант2 | …"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + String p = config.getPrefix(); + String tail = ChoiceOptionParser.tailAfterCommand(context.getMessage().getContentRaw(), p, commandName); + + if (tail.isBlank()) { + reply(context, EmbedTemplates.error("Нужны варианты", usageExamples(p))); + return; + } + + List options = ChoiceOptionParser.parseOptions(tail); + if (options.size() < 2) { + reply(context, EmbedTemplates.error( + "Мало вариантов", + "Минимум **2** разных варианта: `|`, запятая, `или`, `/` или слова через пробел." + )); + return; + } + + var rnd = ThreadLocalRandom.current(); + String pick = options.get(rnd.nextInt(options.size())); + String line = flavor.randomLine(pick); + String listed = options.stream().map(s -> "`" + s + "`").reduce((a, b) -> a + " · " + b).orElse(""); + + var embed = new EmbedBuilder() + .setTitle("🎲 " + commandName) + .setDescription(line + "\n\n*Варианты:* " + listed) + .setColor(new Color(88, 101, 242)) + .setFooter("choose · pick · decide · wheel"); + reply(context, embed.build()); + } + + private static void reply(CommandContext ctx, net.dv8tion.jda.api.entities.MessageEmbed embed) { + ctx.getChannel().sendMessageEmbeds(embed).queue(); + } + + private String usageExamples(String p) { + return "**Примеры:**\n" + + "`" + p + commandName + " пицца | суши | бургер`\n" + + "`" + p + commandName + " чай, кофе, сок`\n" + + "`" + p + commandName + " дома или в клуб`\n" + + "`" + p + commandName + " да / нет / может`"; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/random/RateCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/RateCommand.java similarity index 84% rename from src/main/java/creator/loki/command/impl/entertainment/games/random/RateCommand.java rename to src/main/java/creator/loki/feature/entertainment/commands/RateCommand.java index 5d13fae..aa443b7 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/random/RateCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/RateCommand.java @@ -1,107 +1,101 @@ -package creator.loki.command.impl.entertainment.games.random; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.core.security.CooldownManager; -import net.dv8tion.jda.api.entities.Member; - -import java.awt.Color; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; - -/** - * Оценка пользователя из 10 с детерминированным результатом - */ -public class RateCommand implements Command { - - private final CooldownManager cooldownManager; - - public RateCommand(CooldownManager cooldownManager) { - this.cooldownManager = cooldownManager; - } - - @Override - public String getName() { - return "rate"; - } - - @Override - public String getDescription() { - return "Оценить пользователя из 10 (результат всегда одинаковый)"; - } - - @Override - public String getUsage() { - return "rate [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - // Проверка cooldown (5 секунд) - if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "rate")) { - long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "rate"); - context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); - return; - } - - List mentioned = context.getMessage().getMentions().getMembers(); - Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); - - if (target == null) return; - - // Применяем cooldown - cooldownManager.applyCooldown(context.getAuthor().getId(), "rate", 5); - - // Детерминированная оценка от 1 до 10 - int rating = getDeterministicValue(target.getId(), 420, 1, 10); - - // Визуализация звездами - StringBuilder stars = new StringBuilder(); - for (int i = 0; i < rating; i++) { - stars.append("⭐"); - } - for (int i = rating; i < 10; i++) { - stars.append("☆"); - } - - // Комментарий - Map comments = new HashMap<>(); - comments.put(10, "💯 ИДЕАЛЬНО!"); - comments.put(9, "🔥 Почти идеально!"); - comments.put(8, "😍 Отлично!"); - comments.put(7, "👍 Хорошо!"); - comments.put(6, "🙂 Неплохо"); - comments.put(5, "😐 Средне"); - comments.put(4, "😕 Ниже среднего"); - comments.put(3, "😬 Так себе"); - comments.put(2, "💀 Плохо"); - comments.put(1, "🗿 Ужасно"); - - String comment = comments.getOrDefault(rating, "🤔"); - Color color = rating >= 8 ? new Color(255, 215, 0) : new Color(52, 152, 219); - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("⭐ Оценка") - .setDescription(target.getAsMention()) - .addField("Рейтинг", stars.toString() + "\n**" + rating + "/10** - " + comment, false) - .setThumbnail(target.getUser().getAvatarUrl()) - .setColor(color) - .build() - ).queue(); - } - - private int getDeterministicValue(String userId, int salt, int min, int max) { - long seed = Long.parseLong(userId) + salt; - Random random = new Random(seed); - return random.nextInt(max - min + 1) + min; - } -} +package creator.loki.feature.entertainment.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.core.security.CooldownManager; +import net.dv8tion.jda.api.entities.Member; + +import java.awt.Color; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +/** + * Оценка пользователя из 10 с детерминированным результатом + */ +public class RateCommand implements Command { + + private final CooldownManager cooldownManager; + + public RateCommand(CooldownManager cooldownManager) { + this.cooldownManager = cooldownManager; + } + + @Override + public String getName() { + return "rate"; + } + + @Override + public String getDescription() { + return "Оценить пользователя из 10 (результат всегда одинаковый)"; + } + + @Override + public String getUsage() { + return "rate [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + // Проверка cooldown (5 секунд) + if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "rate")) { + long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "rate"); + context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue(); + return; + } + + List mentioned = context.getMessage().getMentions().getMembers(); + Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0); + + if (target == null) return; + + // Применяем cooldown + cooldownManager.applyCooldown(context.getAuthor().getId(), "rate", 5); + + // Детерминированная оценка от 1 до 10 + int rating = getDeterministicValue(target.getId(), 420, 1, 10); + + // Визуализация звездами + String stars = "⭐".repeat(rating) + "☆".repeat(10 - rating); + + // Комментарий + Map comments = new HashMap<>(); + comments.put(10, "💯 ИДЕАЛЬНО!"); + comments.put(9, "🔥 Почти идеально!"); + comments.put(8, "😍 Отлично!"); + comments.put(7, "👍 Хорошо!"); + comments.put(6, "🙂 Неплохо"); + comments.put(5, "😐 Средне"); + comments.put(4, "😕 Ниже среднего"); + comments.put(3, "😬 Так себе"); + comments.put(2, "💀 Плохо"); + comments.put(1, "🗿 Ужасно"); + + String comment = comments.getOrDefault(rating, "🤔"); + Color color = rating >= 8 ? new Color(255, 215, 0) : new Color(52, 152, 219); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("⭐ Оценка") + .withDescription(target.getAsMention()) + .addField("Рейтинг", stars + "\n**" + rating + "/10** - " + comment, false) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(color) + .build() + ).queue(); + } + + private int getDeterministicValue(String userId, int salt, int min, int max) { + long seed = Long.parseLong(userId) + salt; + Random random = new Random(seed); + return random.nextInt(max - min + 1) + min; + } +} diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingConfig.java b/src/main/java/creator/loki/feature/entertainment/commands/RatingConfig.java similarity index 98% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingConfig.java rename to src/main/java/creator/loki/feature/entertainment/commands/RatingConfig.java index 01cbdc7..11e6879 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingConfig.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/RatingConfig.java @@ -1,4 +1,4 @@ -package creator.loki.command.impl.entertainment.games.rating; +package creator.loki.feature.entertainment.commands; import java.awt.Color; import java.util.Arrays; diff --git a/src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingLevel.java b/src/main/java/creator/loki/feature/entertainment/commands/RatingLevel.java similarity index 95% rename from src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingLevel.java rename to src/main/java/creator/loki/feature/entertainment/commands/RatingLevel.java index 826b2b2..9275651 100644 --- a/src/main/java/creator/loki/command/impl/entertainment/games/rating/RatingLevel.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/RatingLevel.java @@ -1,4 +1,4 @@ -package creator.loki.command.impl.entertainment.games.rating; +package creator.loki.feature.entertainment.commands; import java.awt.Color; import java.util.List; diff --git a/src/main/java/creator/loki/listener/interaction/HelpMenuHandler.java b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java similarity index 67% rename from src/main/java/creator/loki/listener/interaction/HelpMenuHandler.java rename to src/main/java/creator/loki/feature/help/HelpMenuHandler.java index 969d535..dc0c03e 100644 --- a/src/main/java/creator/loki/listener/interaction/HelpMenuHandler.java +++ b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java @@ -1,159 +1,149 @@ -package creator.loki.listener; - -import creator.loki.command.Command; -import creator.loki.config.BotConfig; -import creator.loki.core.registry.CommandRegistry; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; - -import java.awt.Color; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class HelpMenuHandler extends ListenerAdapter { - private final CommandRegistry registry; - private final BotConfig config; - - private static final Map CATEGORY_EMOJIS = new HashMap<>(); - private static final Map CATEGORY_COLORS = new HashMap<>(); - - static { - CATEGORY_EMOJIS.put("Общие", "📋"); - CATEGORY_EMOJIS.put("Роли", "🎭"); - CATEGORY_EMOJIS.put("Развлечения", "🎮"); - CATEGORY_EMOJIS.put("Модерация", "🛡️"); - CATEGORY_EMOJIS.put("Статистика", "📊"); - CATEGORY_EMOJIS.put("Утилиты", "🔧"); - CATEGORY_EMOJIS.put("Настройка", "⚙️"); - - CATEGORY_COLORS.put("Общие", new Color(52, 152, 219)); - CATEGORY_COLORS.put("Роли", new Color(155, 89, 182)); - CATEGORY_COLORS.put("Развлечения", new Color(241, 196, 15)); - CATEGORY_COLORS.put("Модерация", new Color(231, 76, 60)); - CATEGORY_COLORS.put("Статистика", new Color(46, 204, 113)); - CATEGORY_COLORS.put("Утилиты", new Color(52, 152, 219)); - CATEGORY_COLORS.put("Настройка", new Color(149, 165, 166)); - } - - public HelpMenuHandler(CommandRegistry registry, BotConfig config) { - this.registry = registry; - this.config = config; - } - - @Override - public void onStringSelectInteraction(StringSelectInteractionEvent event) { - if (!event.getComponentId().equals("help:category")) { - return; - } - - String selectedValue = event.getValues().get(0); - if (!selectedValue.startsWith("category:")) { - return; - } - - String category = selectedValue.substring("category:".length()); - - // Проверка прав для категории "Настройка" - if (category.equals("Настройка") && event.getMember() != null && - !event.getMember().hasPermission(Permission.ADMINISTRATOR)) { - event.reply("❌ Эта категория доступна только администраторам!") - .setEphemeral(true) - .queue(); - return; - } - - var commands = registry.getAllCommands(); - Map> commandsByCategory = commands.stream() - .collect(Collectors.groupingBy(Command::getCategory)); - - List categoryCommands = commandsByCategory.get(category); - if (categoryCommands == null || categoryCommands.isEmpty()) { - event.reply("❌ В этой категории нет команд!") - .setEphemeral(true) - .queue(); - return; - } - - // Создаём embed для категории - EmbedFactory embed = createCategoryEmbed(category, categoryCommands, event); - - // Пересоздаём селектор - StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("help:category") - .setPlaceholder("📂 Выберите категорию"); - - boolean isAdmin = event.getMember() != null && - event.getMember().hasPermission(Permission.ADMINISTRATOR); - - commandsByCategory.keySet().stream() - .sorted() - .forEach(cat -> { - if (cat.equals("Настройка") && !isAdmin) { - return; - } - - String emoji = CATEGORY_EMOJIS.getOrDefault(cat, "📌"); - int count = commandsByCategory.get(cat).size(); - - menuBuilder.addOption( - cat, - "category:" + cat, - count + " команд", - net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji) - ); - }); - - event.editMessageEmbeds(embed.build()) - .setActionRow(menuBuilder.build()) - .queue(); - } - - private EmbedFactory createCategoryEmbed(String category, List commands, - StringSelectInteractionEvent event) { - String emoji = CATEGORY_EMOJIS.getOrDefault(category, "📌"); - Color color = CATEGORY_COLORS.getOrDefault(category, new Color(88, 101, 242)); - - EmbedFactory embed = EmbedFactory.create() - .setTitle(emoji + " " + category) - .setDescription("Список команд категории **" + category + "**\n" + - "Префикс: `" + config.getPrefix() + "`") - .setColor(color); - - // Группируем команды по 8 в поле - StringBuilder commandList = new StringBuilder(); - int count = 0; - - for (Command cmd : commands) { - String prefix = cmd.getName().startsWith("/") ? "" : config.getPrefix(); - commandList.append("`").append(prefix).append(cmd.getUsage()).append("`\n") - .append(cmd.getDescription()).append("\n\n"); - count++; - - // Если достигли 8 команд или лимита символов, создаём новое поле - if (count % 8 == 0 || commandList.length() > 900) { - embed.addField("", commandList.toString().trim(), false); - commandList = new StringBuilder(); - } - } - - // Добавляем оставшиеся команды - if (commandList.length() > 0) { - embed.addField("", commandList.toString().trim(), false); - } - - embed.addField("💡 Подсказка", - "Используй селектор ниже для переключения между категориями", - false); - - embed.setFooter( - "Запросил: " + event.getUser().getName() + " • Всего команд: " + commands.size(), - event.getUser().getAvatarUrl() - ); - - return embed; - } -} +package creator.loki.feature.help; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.command.Command; +import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.HelpCategories; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; + +import java.awt.Color; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Singleton +public class HelpMenuHandler extends ListenerAdapter { + private final CommandRegistry registry; + private final BotConfig config; + + @Inject + public HelpMenuHandler(CommandRegistry registry, BotConfig config) { + this.registry = registry; + this.config = config; + } + + @Override + public void onStringSelectInteraction(StringSelectInteractionEvent event) { + if (!event.getComponentId().equals("help:category")) { + return; + } + + String selectedValue = event.getValues().get(0); + if (!selectedValue.startsWith("category:")) { + return; + } + + String category = selectedValue.substring("category:".length()); + + // Проверка прав для категории "Настройка" + if ("Настройка".equals(category) && event.getMember() != null + && !event.getMember().hasPermission(Permission.ADMINISTRATOR)) { + event.reply("❌ Эта категория доступна только администраторам!") + .setEphemeral(true) + .queue(); + return; + } + + if ("Супер-Админ".equals(category) && !config.getSuperAdminId().equals(event.getUser().getId())) { + event.reply("❌ Эта категория доступна только владельцу бота.") + .setEphemeral(true) + .queue(); + return; + } + + var commands = registry.getAllCommands(); + Map> commandsByCategory = commands.stream() + .collect(Collectors.groupingBy(Command::getCategory)); + + List categoryCommands = commandsByCategory.get(category); + if (categoryCommands == null || categoryCommands.isEmpty()) { + event.reply("❌ В этой категории нет команд!") + .setEphemeral(true) + .queue(); + return; + } + + // Создаём embed для категории + EmbedFactory embed = createCategoryEmbed(category, categoryCommands, event); + + // Пересоздаём селектор + StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("help:category") + .setPlaceholder("📂 Выберите категорию"); + + boolean isAdmin = event.getMember() != null + && event.getMember().hasPermission(Permission.ADMINISTRATOR); + boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId()); + + commandsByCategory.keySet().stream() + .sorted() + .forEach(cat -> { + if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper)) { + return; + } + + String emoji = HelpCategories.getEmoji(cat); + int count = commandsByCategory.get(cat).size(); + + menuBuilder.addOption( + cat, + "category:" + cat, + count + " команд", + net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji) + ); + }); + + event.editMessageEmbeds(embed.build()) + .setActionRow(menuBuilder.build()) + .queue(); + } + + private EmbedFactory createCategoryEmbed(String category, List commands, + StringSelectInteractionEvent event) { + String emoji = HelpCategories.getEmoji(category); + Color color = HelpCategories.getColor(category); + + EmbedFactory embed = EmbedFactory.create() + .withTitle(emoji + " " + category) + .withDescription("Список команд категории **" + category + "**\n" + + "Префикс: `" + config.getPrefix() + "`") + .withColor(color); + + // Группируем команды по 8 в поле + StringBuilder commandList = new StringBuilder(); + int count = 0; + + for (Command cmd : commands) { + commandList.append("`").append(config.getPrefix()).append(cmd.getUsage()).append("`\n") + .append(cmd.getDescription()).append("\n\n"); + count++; + + // Если достигли 8 команд или лимита символов, создаём новое поле + if (count % 8 == 0 || commandList.length() > 900) { + embed.addField("", commandList.toString().trim(), false); + commandList = new StringBuilder(); + } + } + + // Добавляем оставшиеся команды + if (commandList.length() > 0) { + embed.addField("", commandList.toString().trim(), false); + } + + embed.addField("💡 Подсказка", + "Используй селектор ниже для переключения между категориями", + false); + + embed.withFooter( + "Запросил: " + event.getUser().getName() + " • Всего команд: " + commands.size(), + event.getUser().getAvatarUrl() + ); + + return embed; + } +} diff --git a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java new file mode 100644 index 0000000..7ce6e6d --- /dev/null +++ b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java @@ -0,0 +1,128 @@ +package creator.loki.feature.help.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.HelpCategories; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; + +import java.awt.Color; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class HelpCommand implements Command { + private final CommandRegistry registry; + private final BotConfig config; + + public HelpCommand(CommandRegistry registry, BotConfig config) { + this.registry = registry; + this.config = config; + } + + @Override + public String getName() { + return "help"; + } + + @Override + public String getDescription() { + return "Показывает список всех команд"; + } + + @Override + public void execute(CommandContext context) { + var commands = registry.getAllCommands(); + Map> commandsByCategory = commands.stream() + .collect(Collectors.groupingBy(Command::getCategory)); + + User viewer = context.getAuthor() != null ? context.getAuthor().getUser() : context.getMessage().getAuthor(); + boolean isAdmin = context.getAuthor() != null + && context.getAuthor().hasPermission(Permission.ADMINISTRATOR); + boolean isSuper = config.getSuperAdminId().equals(viewer.getId()); + + // Главная страница + EmbedFactory embed = createMainPage(context, commandsByCategory, isAdmin, isSuper); + + // Создаём селектор категорий + StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("help:category") + .setPlaceholder("📂 Выберите категорию"); + + commandsByCategory.keySet().stream() + .sorted() + .forEach(category -> { + if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) { + return; + } + + String emoji = HelpCategories.getEmoji(category); + String description = HelpCategories.getDescription(category); + int count = commandsByCategory.get(category).size(); + + menuBuilder.addOption( + category, + "category:" + category, + description + " (" + count + " команд)", + net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji) + ); + }); + + context.getChannel() + .sendMessageEmbeds(embed.build()) + .addActionRow(menuBuilder.build()) + .queue(); + } + + private EmbedFactory createMainPage(CommandContext context, Map> commandsByCategory, + boolean isAdmin, boolean isSuper) { + int totalCommands = commandsByCategory.entrySet().stream() + .filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper)) + .mapToInt(e -> e.getValue().size()) + .sum(); + + StringBuilder categoriesText = new StringBuilder(); + commandsByCategory.keySet().stream() + .sorted() + .forEach(category -> { + if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) { + return; + } + + String emoji = HelpCategories.getEmoji(category); + int count = commandsByCategory.get(category).size(); + categoriesText.append(emoji).append(" **").append(category) + .append("** - ").append(count).append(" команд\n"); + }); + + return EmbedFactory.create() + .withTitle("📚 Справка по командам Loki Bot") + .withDescription( + "Привет! Я многофункциональный Discord бот с системой уровней, " + + "личными голосовыми каналами и множеством развлекательных команд.\n\n" + + "**Префикс команд:** `" + config.getPrefix() + "`\n" + + "**Всего команд:** " + totalCommands + "\n\n" + + "**📂 Категории команд:**\n" + categoriesText.toString() + "\n" + + "Используй селектор ниже для просмотра команд категории." + ) + .withColor(new Color(88, 101, 242)) + .withThumbnail(context.getGuild() != null ? context.getGuild().getIconUrl() : null) + .addField("💡 Как использовать", + "Все команды - с префиксом `" + config.getPrefix() + "`\n" + + "Примеры: `" + config.getPrefix() + "rank`, `" + config.getPrefix() + "ship @user1 @user2`, `" + + config.getPrefix() + "voice-setup info`", + false) + .withFooter( + "Запросил: " + context.getAuthor().getUser().getName(), + context.getAuthor().getUser().getAvatarUrl() + ); + } + + @Override + public String getCategory() { + return "Утилиты"; + } +} diff --git a/src/main/java/creator/loki/command/impl/utility/info/AvatarCommand.java b/src/main/java/creator/loki/feature/info/commands/AvatarCommand.java similarity index 82% rename from src/main/java/creator/loki/command/impl/utility/info/AvatarCommand.java rename to src/main/java/creator/loki/feature/info/commands/AvatarCommand.java index f59b4dc..b1a91b5 100644 --- a/src/main/java/creator/loki/command/impl/utility/info/AvatarCommand.java +++ b/src/main/java/creator/loki/feature/info/commands/AvatarCommand.java @@ -1,58 +1,58 @@ -package creator.loki.command.impl.utility.info; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.User; - -import java.awt.Color; -import java.util.List; - -public class AvatarCommand implements Command { - - @Override - public String getName() { - return "avatar"; - } - - @Override - public String getDescription() { - return "Показывает аватар пользователя"; - } - - @Override - public String getUsage() { - return "avatar [@user]"; - } - - @Override - public String getCategory() { - return "Развлечения"; - } - - @Override - public void execute(CommandContext context) { - User targetUser; - - // Если упомянут пользователь - показываем его аватар - List mentioned = context.getMessage().getMentions().getMembers(); - if (!mentioned.isEmpty()) { - targetUser = mentioned.get(0).getUser(); - } else { - // Иначе показываем аватар автора команды - targetUser = context.getAuthor().getUser(); - } - - String avatarUrl = targetUser.getEffectiveAvatarUrl() + "?size=512"; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🖼️ Аватар " + targetUser.getName()) - .setImage(avatarUrl) - .setColor(Color.BLUE) - .setFooter("ID: " + targetUser.getId()) - .build() - ).queue(); - } -} +package creator.loki.feature.info.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; + +import java.awt.Color; +import java.util.List; + +public class AvatarCommand implements Command { + + @Override + public String getName() { + return "avatar"; + } + + @Override + public String getDescription() { + return "Показывает аватар пользователя"; + } + + @Override + public String getUsage() { + return "avatar [@user]"; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + User targetUser; + + // Если упомянут пользователь - показываем его аватар + List mentioned = context.getMessage().getMentions().getMembers(); + if (!mentioned.isEmpty()) { + targetUser = mentioned.get(0).getUser(); + } else { + // Иначе показываем аватар автора команды + targetUser = context.getAuthor().getUser(); + } + + String avatarUrl = targetUser.getEffectiveAvatarUrl() + "?size=512"; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🖼️ Аватар " + targetUser.getName()) + .withImage(avatarUrl) + .withColor(Color.BLUE) + .withFooter("ID: " + targetUser.getId()) + .build() + ).queue(); + } +} diff --git a/src/main/java/creator/loki/command/impl/utility/info/InfoCommand.java b/src/main/java/creator/loki/feature/info/commands/InfoCommand.java similarity index 79% rename from src/main/java/creator/loki/command/impl/utility/info/InfoCommand.java rename to src/main/java/creator/loki/feature/info/commands/InfoCommand.java index f299b2b..143d2a0 100644 --- a/src/main/java/creator/loki/command/impl/utility/info/InfoCommand.java +++ b/src/main/java/creator/loki/feature/info/commands/InfoCommand.java @@ -1,121 +1,116 @@ -package creator.loki.command.impl.utility.info; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; -import creator.loki.embed.EmbedTemplates; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.User; - -import java.awt.Color; -import java.time.format.DateTimeFormatter; -import java.util.stream.Collectors; - -public class InfoCommand implements Command { - - @Override - public String getName() { - return "info"; - } - - @Override - public String getDescription() { - return "Информация о сервере или пользователе"; - } - - @Override - public String getUsage() { - return "info [server|user]"; - } - - @Override - public void execute(CommandContext context) { - String type = context.getArgs().isEmpty() ? "server" : context.getArgs().get(0).toLowerCase(); - - switch (type) { - case "server": - showServerInfo(context); - break; - case "user": - showUserInfo(context); - break; - default: - context.getChannel().sendMessageEmbeds( - EmbedTemplates.error( - "Неверный аргумент", - "Используй: `!info [server|user]`" - ) - ).queue(); - } - } - - private void showServerInfo(CommandContext context) { - Guild guild = context.getGuild(); - if (guild == null) return; - - String iconUrl = guild.getIconUrl(); - if (iconUrl == null) iconUrl = ""; - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("🏰 Информация о сервере") - .setColor(Color.BLUE) - .setThumbnail(iconUrl) - .addField("Название", guild.getName(), true) - .addField("ID", guild.getId(), true) - .addField("Владелец", guild.getOwner() != null ? guild.getOwner().getAsMention() : "Неизвестно", true) - .addField("Участников", String.valueOf(guild.getMemberCount()), true) - .addField("Каналов", String.valueOf(guild.getChannels().size()), true) - .addField("Ролей", String.valueOf(guild.getRoles().size()), true) - .addField( - "Создан", - guild.getTimeCreated().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")), - false - ) - .build() - ).queue(); - } - - private void showUserInfo(CommandContext context) { - Member member = context.getAuthor(); - if (member == null) return; - - User user = member.getUser(); - - String roles = member.getRoles().stream() - .map(Role::getAsMention) - .collect(Collectors.joining(", ")); - if (roles.isEmpty()) { - roles = "Нет ролей"; - } - - context.getChannel().sendMessageEmbeds( - EmbedFactory.create() - .setTitle("👤 Информация о пользователе") - .setColor(Color.CYAN) - .setThumbnail(user.getEffectiveAvatarUrl()) - .addField("Имя", user.getName(), true) - .addField("ID", user.getId(), true) - .addField("Никнейм", member.getNickname() != null ? member.getNickname() : "Не установлен", true) - .addField("Бот?", user.isBot() ? "Да" : "Нет", true) - .addField( - "Присоединился", - member.getTimeJoined().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")), - false - ) - .addField( - "Роли (" + member.getRoles().size() + ")", - roles, - false - ) - .build() - ).queue(); - } - - @Override - public String getCategory() { - return "Утилиты"; - } -} +package creator.loki.feature.info.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; + +import java.awt.Color; +import java.time.format.DateTimeFormatter; +import java.util.stream.Collectors; + +public class InfoCommand implements Command { + + @Override + public String getName() { + return "info"; + } + + @Override + public String getDescription() { + return "Информация о сервере или пользователе"; + } + + @Override + public String getUsage() { + return "info [server|user]"; + } + + @Override + public void execute(CommandContext context) { + String type = context.getArgs().isEmpty() ? "server" : context.getArgs().get(0).toLowerCase(); + + switch (type) { + case "server" -> showServerInfo(context); + case "user" -> showUserInfo(context); + default -> context.getChannel().sendMessageEmbeds( + EmbedTemplates.error( + "Неверный аргумент", + "Используй: `!info [server|user]`" + ) + ).queue(); + } + } + + private void showServerInfo(CommandContext context) { + Guild guild = context.getGuild(); + if (guild == null) return; + + String iconUrl = guild.getIconUrl(); + if (iconUrl == null) iconUrl = ""; + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("🏰 Информация о сервере") + .withColor(Color.BLUE) + .withThumbnail(iconUrl) + .addField("Название", guild.getName(), true) + .addField("ID", guild.getId(), true) + .addField("Владелец", guild.getOwner() != null ? guild.getOwner().getAsMention() : "Неизвестно", true) + .addField("Участников", String.valueOf(guild.getMemberCount()), true) + .addField("Каналов", String.valueOf(guild.getChannels().size()), true) + .addField("Ролей", String.valueOf(guild.getRoles().size()), true) + .addField( + "Создан", + guild.getTimeCreated().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")), + false + ) + .build() + ).queue(); + } + + private void showUserInfo(CommandContext context) { + Member member = context.getAuthor(); + if (member == null) return; + + User user = member.getUser(); + + String roles = member.getRoles().stream() + .map(Role::getAsMention) + .collect(Collectors.joining(", ")); + if (roles.isEmpty()) { + roles = "Нет ролей"; + } + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("👤 Информация о пользователе") + .withColor(Color.CYAN) + .withThumbnail(user.getEffectiveAvatarUrl()) + .addField("Имя", user.getName(), true) + .addField("ID", user.getId(), true) + .addField("Никнейм", member.getNickname() != null ? member.getNickname() : "Не установлен", true) + .addField("Бот?", user.isBot() ? "Да" : "Нет", true) + .addField( + "Присоединился", + member.getTimeJoined().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")), + false + ) + .addField( + "Роли (" + member.getRoles().size() + ")", + roles, + false + ) + .build() + ).queue(); + } + + @Override + public String getCategory() { + return "Утилиты"; + } +} diff --git a/src/main/java/creator/loki/command/impl/utility/info/PingCommand.java b/src/main/java/creator/loki/feature/info/commands/PingCommand.java similarity index 86% rename from src/main/java/creator/loki/command/impl/utility/info/PingCommand.java rename to src/main/java/creator/loki/feature/info/commands/PingCommand.java index 6f2f01e..fe6c972 100644 --- a/src/main/java/creator/loki/command/impl/utility/info/PingCommand.java +++ b/src/main/java/creator/loki/feature/info/commands/PingCommand.java @@ -1,44 +1,44 @@ -package creator.loki.command.impl.utility.info; - -import creator.loki.command.Command; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; - -import java.awt.Color; - -public class PingCommand implements Command { - - @Override - public String getName() { - return "ping"; - } - - @Override - public String getDescription() { - return "Проверка задержки бота"; - } - - @Override - public void execute(CommandContext context) { - long startTime = System.currentTimeMillis(); - - context.getChannel().sendMessage("Измеряю задержку...").queue(message -> { - long ping = System.currentTimeMillis() - startTime; - long gatewayPing = context.getMessage().getJDA().getGatewayPing(); - - message.editMessageEmbeds( - EmbedFactory.create() - .setTitle("🏓 Pong!") - .setColor(Color.GREEN) - .addField("Задержка API", ping + "ms", true) - .addField("Задержка Gateway", gatewayPing + "ms", true) - .build() - ).queue(); - }); - } - - @Override - public String getCategory() { - return "Утилиты"; - } -} +package creator.loki.feature.info.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +import java.awt.Color; + +public class PingCommand implements Command { + + @Override + public String getName() { + return "ping"; + } + + @Override + public String getDescription() { + return "Проверка задержки бота"; + } + + @Override + public void execute(CommandContext context) { + long startTime = System.currentTimeMillis(); + + context.getChannel().sendMessage("Измеряю задержку...").queue(message -> { + long ping = System.currentTimeMillis() - startTime; + long gatewayPing = context.getMessage().getJDA().getGatewayPing(); + + message.editMessageEmbeds( + EmbedFactory.create() + .withTitle("🏓 Pong!") + .withColor(Color.GREEN) + .addField("Задержка API", ping + "ms", true) + .addField("Задержка Gateway", gatewayPing + "ms", true) + .build() + ).queue(); + }); + } + + @Override + public String getCategory() { + return "Утилиты"; + } +} diff --git a/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java b/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java new file mode 100644 index 0000000..8600b07 --- /dev/null +++ b/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java @@ -0,0 +1,79 @@ +package creator.loki.feature.info.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.Category; + +import java.awt.Color; +import java.time.format.DateTimeFormatter; + +public class ServerStatsCommand implements Command { + + @Override + public String getName() { + return "serverstats"; + } + + @Override + public String getDescription() { + return "📊 Статистика сервера (участники, каналы, роли)"; + } + + @Override + public String getUsage() { + return "serverstats"; + } + + @Override + public String getCategory() { + return "Информация"; + } + + @Override + public void execute(CommandContext context) { + Guild guild = context.getGuild(); + if (guild == null) { + context.getChannel().sendMessage("Только на сервере.").queue(); + return; + } + + guild.loadMembers().onSuccess(members -> { + long total = members.size(); + long bots = members.stream().filter(m -> m.getUser().isBot()).count(); + long humans = total - bots; + long online = members.stream().filter(m -> m.getOnlineStatus() != null + && m.getOnlineStatus() != net.dv8tion.jda.api.OnlineStatus.OFFLINE + && m.getOnlineStatus() != net.dv8tion.jda.api.OnlineStatus.UNKNOWN).count(); + + long textChannels = guild.getTextChannels().size(); + long voiceChannels = guild.getVoiceChannels().size(); + long categories = guild.getCategories().size(); + long totalChannels = textChannels + voiceChannels + categories; + + int roles = guild.getRoles().size(); + int emojis = guild.getEmojis().size(); + int boostTier = guild.getBoostTier().getKey(); + int boostCount = guild.getBoostCount(); + + String created = guild.getTimeCreated().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); + + context.getChannel().sendMessageEmbeds( + EmbedFactory.create() + .withTitle("📊 Статистика сервера") + .withThumbnail(guild.getIconUrl()) + .withColor(new Color(88, 101, 242)) + .addField("👤 Участники", + "Всего: **" + total + "**\n👥 Людей: **" + humans + "**\n🤖 Ботов: **" + bots + "**\n🟢 Онлайн: **" + online + "**", true) + .addField("💬 Каналы", + "Текстовых: **" + textChannels + "**\n🔊 Голосовых: **" + voiceChannels + "**\n📁 Категорий: **" + categories + "**\nВсего: **" + totalChannels + "**", true) + .addField("⚙️ Прочее", + "Ролей: **" + roles + "**\n😀 Эмодзи: **" + emojis + "**\n🚀 Бустов: **" + boostCount + "** (ур. " + boostTier + ")", true) + .withFooter("Создан: " + created + " | Владелец: " + guild.getOwner().getUser().getGlobalName()) + .build() + ).queue(); + }); + } +} diff --git a/src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtonListener.java b/src/main/java/creator/loki/feature/leaderboard/LeaderboardButtonListener.java similarity index 69% rename from src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtonListener.java rename to src/main/java/creator/loki/feature/leaderboard/LeaderboardButtonListener.java index d286a6c..1ee2ef6 100644 --- a/src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtonListener.java +++ b/src/main/java/creator/loki/feature/leaderboard/LeaderboardButtonListener.java @@ -1,51 +1,55 @@ -package creator.loki.command.impl.stats.leaderboard; - -import creator.loki.level.LevelSystem; -import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -/** - * Обработчик кнопок таблицы лидеров (Single Responsibility) - */ -public class LeaderboardButtonListener extends ListenerAdapter { - private final LevelSystem levelSystem; - - public LeaderboardButtonListener(LevelSystem levelSystem) { - this.levelSystem = levelSystem; - } - - @Override - public void onButtonInteraction(ButtonInteractionEvent event) { - String buttonId = event.getComponentId(); - - if (!buttonId.startsWith("lb_")) { - return; - } - - event.deferEdit().queue(); - - switch (buttonId) { - case "lb_level" -> showLevelLeaderboard(event); - case "lb_messages" -> showMessageLeaderboard(event); - case "lb_voice" -> showVoiceLeaderboard(event); - } - } - - private void showLevelLeaderboard(ButtonInteractionEvent event) { - var top = levelSystem.getTopByLevel(10); - var embed = LeaderboardFormatter.formatLevelLeaderboard(top); - event.getHook().editOriginalEmbeds(embed).queue(); - } - - private void showMessageLeaderboard(ButtonInteractionEvent event) { - var top = levelSystem.getTopByMessages(10); - var embed = LeaderboardFormatter.formatMessageLeaderboard(top); - event.getHook().editOriginalEmbeds(embed).queue(); - } - - private void showVoiceLeaderboard(ButtonInteractionEvent event) { - var top = levelSystem.getTopByVoice(10); - var embed = LeaderboardFormatter.formatVoiceLeaderboard(top); - event.getHook().editOriginalEmbeds(embed).queue(); - } -} +package creator.loki.feature.leaderboard; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.feature.level.service.LeaderboardService; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + +/** + * Обработчик кнопок таблицы лидеров (Single Responsibility) + */ +@Singleton +public class LeaderboardButtonListener extends ListenerAdapter { + private final LeaderboardService leaderboardService; + + @Inject + public LeaderboardButtonListener(LeaderboardService leaderboardService) { + this.leaderboardService = leaderboardService; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + String buttonId = event.getComponentId(); + + if (!buttonId.startsWith("lb_")) { + return; + } + + event.deferEdit().queue(); + + switch (buttonId) { + case "lb_level" -> showLevelLeaderboard(event); + case "lb_messages" -> showMessageLeaderboard(event); + case "lb_voice" -> showVoiceLeaderboard(event); + } + } + + private void showLevelLeaderboard(ButtonInteractionEvent event) { + var top = leaderboardService.getTopByLevel(10); + var embed = LeaderboardFormatter.formatLevelLeaderboard(top); + event.getHook().editOriginalEmbeds(embed).queue(); + } + + private void showMessageLeaderboard(ButtonInteractionEvent event) { + var top = leaderboardService.getTopByMessages(10); + var embed = LeaderboardFormatter.formatMessageLeaderboard(top); + event.getHook().editOriginalEmbeds(embed).queue(); + } + + private void showVoiceLeaderboard(ButtonInteractionEvent event) { + var top = leaderboardService.getTopByVoice(10); + var embed = LeaderboardFormatter.formatVoiceLeaderboard(top); + event.getHook().editOriginalEmbeds(embed).queue(); + } +} diff --git a/src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtons.java b/src/main/java/creator/loki/feature/leaderboard/LeaderboardButtons.java similarity index 88% rename from src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtons.java rename to src/main/java/creator/loki/feature/leaderboard/LeaderboardButtons.java index 0a0eeac..0b91321 100644 --- a/src/main/java/creator/loki/command/impl/stats/leaderboard/LeaderboardButtons.java +++ b/src/main/java/creator/loki/feature/leaderboard/LeaderboardButtons.java @@ -1,19 +1,19 @@ -package creator.loki.command.impl.stats.leaderboard; - -import net.dv8tion.jda.api.interactions.components.buttons.Button; - -import java.util.List; - -/** - * Кнопки для таблицы лидеров - */ -public class LeaderboardButtons { - - public static List